Docs
CLI
Migrations
Compare
Benchmarks
Examples

© 2024 MoroJs

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 — prototypes & tiny APIs
Modular — the recommended default
Enterprise — large platforms & teams

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.

Directory layout

bash

1my-api/
2├── src/
3│   └── server.ts          # the whole app
4├── moro.config.ts         # optional config
5├── package.json
6└── tsconfig.json

src/server.ts

typescript

1import { createApp, z } from '@morojs/moro';
2
3const app = createApp();
4
5const CreateUser = z.object({
6  name: z.string().min(2).max(50),
7  email: z.string().email(),
8});
9
10app.get('/health', (req, res) => res.json({ status: 'ok' }));
11
12// Chainable, type-safe routes — validation runs before your handler
13app.post('/users')
14  .body(CreateUser)
15  .rateLimit({ requests: 10, window: 60000 })
16  .handler((req, res) => {
17    // req.body is fully typed from the schema
18    return res.json({ created: req.body });
19  });
20
21app.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.

Directory layout

bash

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.json

src/modules/users/index.ts — the module

typescript

1import { 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;

src/server.ts — the bootstrap

typescript

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.

Directory layout

bash

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.json

src/modules/auth/index.ts — a richer module

typescript

1import { 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.

package.json

json

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}

moro.config.ts

typescript

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;

tsconfig.json — MoroJS uses native ESM (note the .js imports)

json

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 — resist the “misc” module

Next steps