Getting Started
Project Structure
How to organize a MoroJS project as it grows, from a single file to an enterprise platform. The framework API is the same at every size; you just split things into more files.
The one rule
Everything is built from three primitives. You don’t change frameworks as you scale — you reach for the next one when a file gets crowded:
routes/Auto-loaded files served at their literal path. Great for one-off endpoints like /health or /auth.
modules/Self-contained features registered with loadModule(). Versioned and auto-prefixed at /api/v{version}/{name}.
websockets/Auto-loaded real-time namespaces declared with app.websocket().
Three layouts, one framework
Single file
Everything lives in src/server.ts. Perfect for a prototype, a demo, or a service with a handful of endpoints. Define routes inline with the chainable API.
1my-api/
2├── src/
3│ └── server.ts # the whole app
4├── moro.config.ts # optional config
5├── package.json
6└── tsconfig.json1import { createApp, z } from '@morojs/moro';
2
3// createApp() is async — it must be awaited.
4const app = await createApp();
5
6const CreateUser = z.object({
7 name: z.string().min(2).max(50),
8 email: z.string().email(),
9});
10
11app.get('/health', (req, res) => res.json({ status: 'ok' }));
12
13// Chainable, type-safe routes — validation runs before your handler
14app.post('/users')
15 .body(CreateUser)
16 .rateLimit({ requests: 10, window: 60000 })
17 .handler((req, res) => {
18 // req.body is fully typed from the schema
19 return res.json({ created: req.body });
20 });
21
22app.listen(3000);Modular (recommended)
The default for real applications. Group each feature into a module, keep cross-cutting endpoints in routes/, and let the bootstrap file wire it all together. This mirrors the official sample app.
1my-app/
2├── src/
3│ ├── server.ts # bootstrap: createApp, loadRoutes, loadModule
4│ ├── modules/ # feature modules → /api/v{version}/{name}
5│ │ ├── users/
6│ │ │ ├── index.ts # defineModule({ name, version, routes })
7│ │ │ ├── actions.ts # handler functions (the logic)
8│ │ │ └── schemas.ts # Zod validation schemas
9│ │ ├── products/
10│ │ └── orders/
11│ ├── routes/ # auto-loaded, literal-path endpoints
12│ │ ├── health.ts
13│ │ └── auth/
14│ │ ├── index.ts
15│ │ └── actions.ts
16│ ├── websockets/ # auto-loaded real-time namespaces
17│ │ └── chat.ts
18│ ├── middleware/ # shared middleware (auth, etc.)
19│ ├── services/ # shared services (database, logger)
20│ ├── jobs/ # scheduled background jobs
21│ └── types/
22│ └── index.ts
23├── moro.config.ts
24├── package.json
25└── tsconfig.json1import { defineModule } from '@morojs/moro';
2import * as actions from './actions.js';
3import * as schemas from './schemas.js';
4
5export const usersModule = defineModule({
6 name: 'users',
7 version: '1.0.0',
8 description: 'User management',
9 routes: [
10 {
11 method: 'GET',
12 path: '/users',
13 handler: actions.listUsers,
14 validation: { query: schemas.Pagination },
15 cache: { ttl: 60 },
16 },
17 {
18 method: 'POST',
19 path: '/users',
20 handler: actions.createUser,
21 validation: { body: schemas.CreateUser },
22 rateLimit: { requests: 10, window: 60000 },
23 },
24 ],
25});
26
27export default usersModule;1import { createApp } from '@morojs/moro';
2import { usersModule } from './modules/users/index.js';
3import { ordersModule } from './modules/orders/index.js';
4
5const app = await createApp({
6 cors: true,
7 compression: true,
8});
9
10// Auto-load everything in these folders
11await app.loadRoutes('./src/routes');
12await app.loadRoutes('./src/websockets');
13
14// Register feature modules (auto-prefixed by version + name)
15await app.loadModule(usersModule);
16await app.loadModule(ordersModule);
17
18app.listen(3000);Every module is just three files. index.ts declares the routes, actions.ts holds the handler functions, and schemas.ts holds the Zod schemas. Add files only when a module outgrows them — which is exactly what the enterprise layout does next.
Enterprise
Same primitives, more structure. As modules grow, split their routes, types, config and middleware into dedicated files, and add top-level folders for the database, shared libraries and operational concerns. Nothing here is a new framework concept — it’s the modular layout with more drawers.
1platform-api/
2├── src/
3│ ├── index.ts # bootstrap entry point
4│ ├── modules/ # one folder per business domain
5│ │ ├── auth/
6│ │ │ ├── index.ts # defineModule({ name, version, config, routes })
7│ │ │ ├── routes.ts # route definitions, imported into index.ts
8│ │ │ ├── actions.ts # business logic
9│ │ │ ├── schemas.ts # Zod schemas
10│ │ │ ├── middleware.ts # module-scoped middleware
11│ │ │ ├── config.ts # module settings
12│ │ │ └── types.ts
13│ │ ├── billing/
14│ │ ├── users/
15│ │ └── notifications/
16│ ├── routes/ # top-level + versioned literal routes
17│ │ └── v1/
18│ ├── websockets/ # real-time namespaces
19│ ├── middlewares/ # global middleware (request-id, csrf, ...)
20│ ├── database/ # ORM layer
21│ │ ├── index.ts
22│ │ ├── connection.ts
23│ │ ├── migrate.ts
24│ │ ├── migrations/
25│ │ ├── schema/
26│ │ └── seeds/
27│ ├── config/ # app & domain configuration
28│ ├── libs/ # shared helpers, services, utils, types
29│ ├── services/ # cross-cutting services (queue, email, ...)
30│ ├── jobs/ # background workers
31│ └── types/
32├── moro.config.ts # full AppConfig
33├── drizzle.config.ts # ORM config (optional)
34├── Dockerfile # container build (optional)
35├── manifests/ # k8s manifests (optional)
36├── scripts/ # migrate, seed, ops scripts
37├── package.json
38└── tsconfig.json1import { defineModule } from '@morojs/moro';
2import { routes } from './routes.js';
3
4export default defineModule({
5 name: 'auth',
6 version: '1.0.0',
7 config: {
8 description: 'Authentication with role-based access control',
9 tags: ['auth', 'security'],
10 },
11 routes,
12});
13
14// Re-export the pieces other modules need
15export { authMiddleware, requireAuth, requireRole } from './middleware.js';
16export * as actions from './actions.js';
17export * from './types.js';Notice the module API is identical to the modular layout — defineModule() with a routes array. The only change is that routes.ts, middleware.ts, config.ts and types.ts are now their own files instead of living inside index.ts.
When do I use routes/ vs modules/?
routes/
- Loaded with
app.loadRoutes() - Served at the literal path — no version prefix
- Use
getApp()+app.group()or chaining - Best for
/health,/auth, webhooks, stats
modules/
- Registered with
app.loadModule() - Auto-prefixed at
/api/v{version}/{name} - Declared with
defineModule()— versioned & isolated - Best for versioned business features (users, billing)
Full breakdown: Routes vs Modules.
Configuration files
The same three config files sit at the root of every layout.
1{
2 "name": "my-app",
3 "version": "1.0.0",
4 "type": "module",
5 "main": "dist/server.js",
6 "scripts": {
7 "dev": "tsx watch src/server.ts",
8 "build": "tsc",
9 "start": "node dist/server.js"
10 },
11 "dependencies": {
12 "@morojs/moro": "^1.7.0",
13 "zod": "^4.1.8"
14 },
15 "devDependencies": {
16 "@types/node": "^24.3.1",
17 "tsx": "^4.7.0",
18 "typescript": "^5.9.2"
19 }
20}1import type { DeepPartial, AppConfig } from '@morojs/moro';
2
3const config: DeepPartial<AppConfig> = {
4 server: {
5 port: parseInt(process.env.PORT || '3000'),
6 host: process.env.HOST || 'localhost',
7 environment: process.env.NODE_ENV || 'development',
8 },
9 security: {
10 cors: { enabled: true, credentials: true },
11 rateLimit: { enabled: true, requests: 100, window: 60000 },
12 },
13 performance: {
14 compression: { enabled: true },
15 cache: { enabled: true, adapter: 'memory', ttl: 300 },
16 },
17};
18
19export default config;1{
2 "compilerOptions": {
3 "target": "ES2022",
4 "module": "NodeNext",
5 "moduleResolution": "NodeNext",
6 "esModuleInterop": true,
7 "strict": true,
8 "skipLibCheck": true,
9 "outDir": "./dist",
10 "rootDir": "./src",
11 "resolveJsonModule": true
12 },
13 "include": ["src/**/*"],
14 "exclude": ["node_modules", "dist"]
15}Because the project is ESM ("type": "module" with NodeNext), local imports use the compiled .js extension — e.g. import * as actions from './actions.js' — even though the file is .ts.
Best practices
Start small, split later
- Begin with a single file; promote to a module when it grows
- Keep a module to index + actions + schemas until it hurts
- Only add routes.ts / types.ts / config.ts when files get crowded
- Keep Zod schemas next to the actions that use them
Keep boundaries clean
- Business logic lives in actions, not in route definitions
- Versioned features go in modules; shared endpoints in routes/
- Put shared services (db, logger) in services/ and import them
- One domain per module, and resist the “misc” module