Skip to content

Best Practices ​

This page assumes you've completed the Tutorial: you can write leaf routes, schemas, err, route groups and middleware.

Here we collect conventions for when your project grows: directory layout, nested routes, middleware layering, startup config, declarative metadata, withContext, SSE and testing.

1. Directory Layout ​

The framework doesn't enforce a structure. As routes multiply, we recommend:

src/
  index.ts           # Server + global middleware + serve
  routes/
    index.ts         # aggregated exports
    notes.ts
    users.ts
  services/          # business functions unrelated to HTTP (optional)
  utils/
  • Route files: define leaves and attach them to resource groups with children
  • Entry: only assembles and starts the app
  • You can also split by feature module (modules/notes/{routes,service}); the principle is the same: thin routes, testable logic

2. Nested Routes ​

Leaf = method + path + handler; group = only path + children (can be nested further). Groups own prefixes and shared middleware; leaves own the concrete endpoints.

typescript
// routes/notes.ts
import { defineRoute, defineRoutes, Type, err } from 'vafast'
import { listNotes, getNote, createNote } from '../services/notes'

const NoteBody = Type.Object({
  title: Type.String({ minLength: 1 }),
  content: Type.String({ minLength: 1 }),
})

const listHandler = defineRoute({
  method: 'GET',
  path: '/',
  handler: () => listNotes(),
})

const getOneHandler = defineRoute({
  method: 'GET',
  path: '/:id',
  schema: { params: Type.Object({ id: Type.String() }) },
  handler: ({ params }) => {
    const note = getNote(params.id)
    if (!note) throw err.notFound('Note not found')
    return note
  },
})

const createHandler = defineRoute({
  method: 'POST',
  path: '/',
  schema: { body: NoteBody },
  handler: ({ body }) => createNote(body),
})

export const notesRoutes = defineRoutes([
  defineRoute({
    path: '/notes',
    name: 'Notes',
    description: 'Notes API',
    children: [listHandler, getOneHandler, createHandler],
  }),
])

With multiple levels of nesting, child paths are relative, and the final URL is the concatenation of each level's path:

typescript
export const apiRoutes = defineRoutes([
  defineRoute({
    path: '/api',
    children: [
      defineRoute({
        path: '/v1',
        children: [
          defineRoute({
            path: '/notes',
            children: [listHandler, getOneHandler, createHandler],
          }),
        ],
      }),
    ],
  }),
])
// → GET /api/v1/notes, GET /api/v1/notes/:id, POST /api/v1/notes
typescript
// routes/index.ts
import { notesRoutes } from './notes'
import { usersRoutes } from './users'

export const allRoutes = [...notesRoutes, ...usersRoutes]
RecommendationWhy
Define leaves as constants before putting them in childrenReadable files, easy to attach group middleware
Use relative child paths (/, /:id)The group provides the prefix
Split files by resource; spread ...allRoutes in the entryClear composition across modules
Attach auth / logging to the groupLeaves under the same resource inherit them automatically

Shared API Prefix (Optional) ​

You can also add a prefix once in the entry file (no need to change every file):

typescript
const BASE_PATH = '/api'

const routesWithBasePath = allRoutes.map((route) => ({
  ...route,
  path: BASE_PATH + route.path,
}))

const server = new Server([
  defineRoute({ method: 'GET', path: '/', handler: () => ({ ok: true }) }),
  ...routesWithBasePath,
])

Health-check routes usually live at the unprefixed /. See the Routing Guide for more rules.

3. Where to Attach Middleware ​

Middleware follows the onion model, with three stackable levels:

LevelHowScopeTypical use
Globalserver.use(mw)All routes (including 404)CORS, request ID, access logs
Route groupdefineRoute({ path, middleware, children })The group and its descendantsAuth, tenancy, resource-level logging
LeafdefineRoute({ method, middleware, handler })A single endpointPermission guards, rate limiting, upload checks

Execution order (outside in): global → parent group → child group → leaf → handler, reversed on the way back.

typescript
import { Server, defineRoute, defineRoutes, defineMiddleware, serve, err } from 'vafast'
import { cors } from '@vafast/cors'
import { requestId } from '@vafast/request-id'

const log = defineMiddleware(async (req, next) => {
  const start = Date.now()
  const res = await next()
  console.log(`${req.method} ${new URL(req.url).pathname} ${res.status} ${Date.now() - start}ms`)
  return res
})

const auth = defineMiddleware(async (req, next) => {
  const token = req.headers.get('authorization')
  if (!token) throw err.unauthorized('Please log in first')
  return next({ userId: 'u_1' })
})

const adminOnly = defineMiddleware(async (req, next) => {
  // read context injected by the previous layer, or query the database yourself
  return next()
})

const routes = defineRoutes([
  // public
  defineRoute({
    method: 'GET',
    path: '/health',
    handler: () => ({ ok: true }),
  }),

  // group-level auth: everything under /account/* requires login
  defineRoute({
    path: '/account',
    middleware: [auth],
    children: [
      defineRoute({
        method: 'GET',
        path: '/profile',
        handler: ({ userId }) => ({ userId }),
      }),
      // one more layer on the leaf
      defineRoute({
        method: 'DELETE',
        path: '/profile',
        middleware: [adminOnly],
        handler: ({ userId }) => {
          console.log('delete', userId)
          return null
        },
      }),
    ],
  }),
])

const server = new Server(routes)
server.use(cors())
server.use(requestId())
server.use(log) // global: log every request
RecommendationWhy
Use global middleware for cross-cutting concernsUnrelated to business paths
Use group-level auth for a resourceAvoids repeating it on every leaf
Use leaf middleware for endpoint-specific constraintsPermission differences are obvious at a glance
Pass context with next({ ... })Handlers read fields directly; across children, see §9 withContext

Official packages (install as needed): @vafast/cors, @vafast/jwt, @vafast/request-id, @vafast/request-logger and more. See Middleware for how it works.

4. Schema and Types from One Source ​

Validate with Type and infer types with Static; don't use classes / interfaces as request models.

typescript
import { Type, type Static } from 'vafast'

export const NoteBody = Type.Object({
  title: Type.String({ minLength: 1 }),
  content: Type.String({ minLength: 1 }),
})

export type NoteBody = Static<typeof NoteBody>

Related schemas can be grouped together:

typescript
export const NoteModel = {
  create: NoteBody,
  update: Type.Partial(NoteBody),
}

When validation fails the handler is skipped, and the framework returns HTTP 422 directly:

json
{
  "code": 422,
  "message": "Request validation failed",
  "details": [
    {
      "location": "body",
      "path": "/title",
      "field": "title",
      "message": "Expected string length greater or equal to 1",
      "value": ""
    }
  ]
}
FieldDescription
details[].locationbody / query / params, etc.
details[].fieldField path
details[].messageOriginal TypeBox message (English)
details[].valueThe actual value that triggered the error (optional)

For business errors (404, 403, etc.) use err.* from the next section; those responses usually have no details. See Validation for the full details.

5. Use err.* for Errors; Keep Services HTTP-Free ​

Throw business errors with throw err.xxx() in routes / handlers and the framework turns them into JSON; the service layer only returns data or null and never builds a Response.

typescript
import { err } from 'vafast'

if (!id) throw err.badRequest('Invalid parameters')
if (!row) throw err.notFound('Resource not found')
if (!allowed) throw err.forbidden('Forbidden')

Response shape (no details, unlike the schema 422):

json
{
  "code": 404,
  "message": "Resource not found"
}

Predefined Errors ​

MethodHTTPDefault message
err.badRequest(msg?)400Bad request parameters
err.unauthorized(msg?)401Unauthorized
err.forbidden(msg?)403Forbidden
err.notFound(msg?)404Resource not found
err.conflict(msg?)409Resource conflict
err.unprocessable(msg?)422Unprocessable entity
err.tooMany(msg?)429Too many requests
err.internal(msg?)500Internal server error

The second argument is an optional business code (written to the response code; the HTTP status still follows the table above):

typescript
throw err.notFound('User not found', 10001)
// → HTTP 404, { code: 10001, message: "User not found" }

throw err('Custom message', 418, 20001) // any status code
typescript
// ❌ building a Response in the service
export function getNote(id: string) {
  if (!id) return new Response('Bad Request', { status: 400 })
}

// ✅ the service returns data or null; the route decides whether to throw err
export function getNote(id: string) {
  return db.notes.findById(id)
}
ScenarioApproach
Malformed requestRely on schema → automatic 422 + details
Business rule failsthrow err.* → matching status code, no details
Service layerStays HTTP-free; the route does the throw

See API Reference · Error Handling for more.

6. Service Layer: Plain Functions Are Fine ​

Extract logic that doesn't depend on the request into functions so they're easy to unit test:

typescript
// services/notes.ts
import type { NoteBody } from '../models/note'

const notes: Array<NoteBody & { id: string }> = []

export function listNotes() {
  return notes
}

export function getNote(id: string) {
  return notes.find((n) => n.id === id)
}

export function createNote(input: NoteBody) {
  const note = { id: String(Date.now()), ...input }
  notes.push(note)
  return note
}

Routes handle validation, auth context, calling services and mapping to err. No need to force MVC.

7. Startup and serve Configuration ​

The entry only assembles: attach global middleware (see §3), then serve:

typescript
import { Server, serve, defineRoute } from 'vafast'
import { cors } from '@vafast/cors'
import { requestId } from '@vafast/request-id'
import { allRoutes } from './routes'

const server = new Server([
  defineRoute({ method: 'GET', path: '/', handler: () => ({ ok: true }) }),
  ...allRoutes,
])

server.use(cors())
server.use(requestId())

serve({
  fetch: server.fetch,
  port: 3000,
  hostname: '0.0.0.0',
  bodyLimit: 1024 * 1024,       // default 1MB; raise it for uploads, 0 = unlimited
  timeout: { requestTimeout: 30_000 },
  gracefulShutdown: true,
  trustProxy: true,             // get the real IP behind a reverse proxy
})

Common serve() Options ​

OptionDefaultPurpose
fetch(required)Usually server.fetch
port3000Port to listen on
hostname'0.0.0.0'Bind address
bodyLimit1MBMax request body size (bytes); exceeding it → 413; 0 = unlimited
timeout.requestTimeout0 (unlimited)Per-request processing timeout (ms); exceeding it → 504
timeout.headersTimeoutNode defaultTimeout for receiving all request headers
timeout.keepAliveTimeoutNode defaultKeep-Alive idle timeout
gracefulShutdownofftrue or an object: on SIGTERM/SIGINT, wait for in-flight requests before closing
trustProxyfalseTrust the reverse proxy and take the IP from X-Forwarded-*; request.ip / ips
onError—Fallback for uncaught errors in the Node adapter
ScenarioRecommendation
Pure JSON APIKeep bodyLimit at 1MB or smaller
File uploadsTune per use case, e.g. bodyLimit: 10 * 1024 * 1024
Behind Nginx / IngressLeave most timeouts to the proxy; enable trustProxy if you need the real IP
Directly on the public internetSet timeout.requestTimeout (e.g. 30–120s) to guard against slow DoS
K8s / containersgracefulShutdown: true (or set timeout)

See API Reference · serve() for all fields and examples.

8. Routes Can Carry Parameters: Declarative Metadata ​

The Idea ​

In Vafast, a route is a configuration object, not just method + path + handler.

ConceptMeaning
DeclarativeThe route config describes "what this endpoint can do and which capabilities it has"
Single source of truthMetadata lives on the leaf alongside the handler; no separate path→permission/billing mapping table
QueryableMiddleware, docs and webhooks read the same config via RouteRegistry
ExplicitBehavior switches live on the route (e.g. sse: true) instead of being inferred implicitly

:id in the path and schema govern how requests come in; parameters on the route govern the endpoint's own capabilities and policies.

Built-in Metadata ​

typescript
defineRoute({
  method: 'POST',
  path: '/notes',
  name: 'create_note',           // machine-readable (docs, tooling)
  description: 'Create a note',     // human-readable
  docs: { tags: ['notes'] },      // OpenAPI, etc.
  sse: true,                      // explicitly declare SSE (if needed)
  schema: { body: NoteBody },
  handler: ({ body }) => createNote(body),
})

Business Extension Fields ​

Any custom field is preserved on the flattened route. Common uses:

typescript
defineRoute({
  method: 'POST',
  path: '/notes',
  name: 'create_note',
  description: 'Create a note',
  webhook: true,                    // write operations trigger a webhook
  permission: 'notes.create',       // permission code
  // billing: { price: 0.01 },     // as needed: billing, auditing, etc.
  schema: { body: NoteBody },
  handler: ({ body }) => createNote(body),
})

Middleware uses getRouteRegistry() to look up metadata for the current request instead of hard-coding paths:

typescript
import { defineMiddleware, getRouteRegistry, err } from 'vafast'

const requirePermission = defineMiddleware(async (req, next) => {
  const route = getRouteRegistry().get(req.method, new URL(req.url).pathname)
  if (route?.permission) {
    const allowed = await checkPermission(req, route.permission)
    if (!allowed) throw err.forbidden('Forbidden')
  }
  return next()
})
RecommendationWhy
Put metadata on leaf routesIt lives with the handler and changes together with the endpoint
Have middleware read the registryDecouples cross-cutting logic from specific paths
Keep extension field names stableMakes bulk collection with registry.filter('webhook') easy

When you need TypeScript constraints on extension fields, use the second generic parameter of withContext from the next section. See Routing · Extension Fields for details.

9. Use withContext to Wrap Type-Safe Routes ​

withContext creates route definers with a preset context: define once, reuse everywhere. Handlers automatically get the types of fields injected by middleware, and the second generic parameter constrains the extension fields from the previous section (e.g. webhook, permission).

Common scenarios:

ScenarioPurpose
Context injected by middlewareUse userId / role directly in the handler, fully typed
Routes split across filesExport defineAuthedRoute so every module shares the same context contract
Group middleware + childrenTS can't infer parent injections across calls; the definer wires them up explicitly
Extension field typeswithContext<Ctx, { webhook?: boolean; permission?: string }>()
typescript
import {
  defineRoute,
  defineRoutes,
  defineMiddleware,
  withContext,
  err,
} from 'vafast'

const auth = defineMiddleware(async (req, next) => {
  const token = req.headers.get('authorization')
  if (!token) throw err.unauthorized('Please log in first')
  return next({ userId: 'u_1', role: 'admin' as const })
})

// define once: preset context + extension field types
const defineAuthedRoute = withContext<
  { userId: string; role: 'admin' | 'user' },
  { webhook?: boolean; permission?: string }
>()

const profileHandler = defineAuthedRoute({
  method: 'GET',
  path: '/profile',
  permission: 'account.read',
  handler: ({ userId, role }) => ({ userId, role }),
})

const updateHandler = defineAuthedRoute({
  method: 'PATCH',
  path: '/profile',
  webhook: true,
  permission: 'account.write',
  handler: ({ userId, body }) => ({ userId, ...body }),
})

export const accountRoutes = defineRoutes([
  defineRoute({
    path: '/account',
    middleware: [auth],
    children: [profileHandler, updateHandler],
  }),
])
CaseApproach
Leaf attaches its own middleware, no custom extension typesA plain defineRoute is enough
Reusing context / constraining extension fields / group injectionWrap a definer with withContext
Rolling your own JWT signing / verification@vafast/jwt

See Middleware · withContext for how it works.

10. SSE Streaming ​

For one-way real-time push (AI chat, progress, notifications), use the built-in SSE: set sse: true explicitly on the route, write the handler as an async function*, and just yield.

typescript
import { defineRoute, Type } from 'vafast'

defineRoute({
  method: 'POST',
  path: '/chat',
  sse: true,
  schema: {
    body: Type.Object({
      prompt: Type.String({ minLength: 1 }),
    }),
  },
  handler: async function* ({ body }) {
    yield { type: 'start' }
    for await (const chunk of streamModel(body.prompt)) {
      yield { type: 'delta', content: chunk }
    }
    yield { type: 'done' }
  },
})
PointDescription
sse: trueMust be declared explicitly for the framework to use text/event-stream
async function*yield any JSON-serializable data
Schema / middlewareSame as regular routes: validated before entering the generator
WebSocketNot built into the core yet; manage bidirectional channels yourself or via a proxy

Use the sse() helper when you need an event name / id. See SSE for full usage.

CapabilityDocs
Webhooks on write operationsWebhook
Request logging / CORSRequest Logger, CORS
Static filesStatic
OpenAPIOpenAPI
Typed frontend clientAPI Client
CookieCookie

11. Testing ​

Use server.fetch; no need to start a port:

typescript
import { describe, it, expect } from 'vitest'
import { Server, defineRoute, defineRoutes } from 'vafast'

const server = new Server(
  defineRoutes([
    defineRoute({
      method: 'GET',
      path: '/health',
      handler: () => ({ ok: true }),
    }),
  ]),
)

describe('health', () => {
  it('returns ok', async () => {
    const res = await server.fetch(new Request('http://localhost/health'))
    expect(res.status).toBe(200)
    expect(await res.json()).toEqual({ ok: true })
  })
})

Unit test service functions on their own. See Unit Testing for more.

Summary ​

StageFocus
Getting startedSchema, leaf routes, request types, simple middleware (Quick Start)
TutorialSchema, err, splitting files, route groups, middleware
This pageNested routes, middleware layering, serve config, declarative parameters, withContext, SSE, testing