Skip to content

Module Structure

AnthonyChen05 edited this page Feb 23, 2026 · 1 revision

Module Structure

Full reference for the file layout, naming rules, and the AppModule contract.


File layout

backend/src/modules/<name>/
├── index.ts          required — backend entry point
└── public/           optional — static frontend files
    ├── index.html
    ├── app.js
    └── style.css

Any files in public/ are automatically served at /<name>-assets/. You don't need to configure anything.


The AppModule contract

Every module's index.ts must export a default object implementing AppModule:

import type { AppModule } from '../../shared/types/module'

const MyModule: AppModule = {
  name: 'my-module',     // display name (used in logs)
  version: '1.0.0',      // semver string

  async register(server, services, prefix) {
    // server  — the Fastify instance
    // services — CoreServices (db, timer, events, notify, scheduler, time)
    // prefix  — your module's URL base, e.g. "/my-module"
  }
}

export default MyModule

That's the entire contract. Everything else is up to you.


The prefix parameter

prefix is derived automatically from your folder name:

Folder Prefix
src/modules/dashboard/ /dashboard
src/modules/kanban/ /kanban
src/modules/my-crm/ /my-crm

Use it to namespace all your routes so they don't collide with other modules:

server.get(`${prefix}/api/items`, ...)       // GET /kanban/api/items
server.post(`${prefix}/api/items`, ...)      // POST /kanban/api/items
server.delete(`${prefix}/api/items/:id`, ...) // DELETE /kanban/api/items/:id

Serving your HTML page

import path from 'path'
import fs from 'fs'

const publicDir = path.join(process.cwd(), 'src', 'modules', 'my-module', 'public')
const assetPrefix = `${prefix}-assets` // e.g. "/my-module-assets"

server.get(prefix, { config: { public: true } } as never, async (_req, reply) => {
  const html = fs.readFileSync(path.join(publicDir, 'index.html'), 'utf-8')
    .replaceAll('{{ASSETS}}', assetPrefix)
  reply.type('text/html').send(html)
})

The {{ASSETS}} token in your HTML gets replaced with the correct asset path at serve time.


Public vs protected routes

By default all routes require JWT authentication. Mark a route as public with { config: { public: true } }:

// Public — no JWT needed
server.get(`${prefix}/api/data`, { config: { public: true } } as never, async () => {
  return { ok: true }
})

// Protected — requires Authorization: Bearer <token>
server.get(`${prefix}/api/private`, async (req) => {
  return { user: req.user }
})

Route handler patterns

Basic GET

server.get(`${prefix}/api/items`, { config: { public: true } } as never, async () => {
  return services.db.item.findMany()
})

With URL params

server.get<{ Params: { id: string } }>(
  `${prefix}/api/items/:id`,
  { config: { public: true } } as never,
  async (req, reply) => {
    const item = await services.db.item.findUnique({ where: { id: req.params.id } })
    if (!item) return reply.code(404).send({ error: 'Not found' })
    return item
  }
)

With a request body

server.post<{ Body: { title: string; column: string } }>(
  `${prefix}/api/items`,
  { config: { public: true } } as never,
  async (req, reply) => {
    if (!req.body.title) return reply.code(400).send({ error: 'title required' })
    return services.db.item.create({ data: req.body })
  }
)

Plugin auto-discovery

The plugin loader runs at startup and scans src/modules/*/index.ts. It:

  1. Derives prefix from the folder name
  2. Calls mod.register(server, services, prefix)
  3. Logs the result

You never need to register a module manually. Drop the folder in, restart.


See also

Clone this wiki locally