Tutorial
Let's build an in-memory notes API. No database and no production auth: the goal is to walk through the core of Vafast in about 15–20 minutes.
If you've already finished the Quick Start, skip "Setup" and start from "Step 1".
Coming from another framework?
Setup
npx create-vafast-app
cd hi-vafast
npm install
npm run devMake sure http://localhost:3000 opens before you continue.
Step 1: Read Endpoints
Start by writing the logic in src/index.ts. Only implement reads for now; add writes once it runs.
import { Server, defineRoute, defineRoutes, serve, err } from 'vafast'
interface Note {
id: string
title: string
content: string
}
const notes: Note[] = [
{ id: '1', title: 'Welcome', content: 'This is the first note' },
]
const routes = defineRoutes([
defineRoute({
method: 'GET',
path: '/notes',
handler: () => notes,
}),
defineRoute({
method: 'GET',
path: '/notes/:id',
handler: ({ params }) => {
const note = notes.find((n) => n.id === params.id)
if (!note) throw err.notFound('Note not found')
return note
},
}),
])
const server = new Server(routes)
serve({ fetch: server.fetch, port: 3000 }, () => {
console.log('http://localhost:3000')
})curl http://localhost:3000/notes
curl http://localhost:3000/notes/1
curl http://localhost:3000/notes/missing # should return a 404 JSONTwo things to remember
- Leaf route =
method+path+handler - Throw business errors with
throw err.notFound(...); the framework turns them into JSON responses
Step 2: Schema + Create Endpoint
Declare the request body with Type. Validation failures return 422 automatically, and body is correctly typed inside the handler:
import { Server, defineRoute, defineRoutes, serve, Type, err } from 'vafast'
const NoteBody = Type.Object({
title: Type.String({ minLength: 1 }),
content: Type.String({ minLength: 1 }),
})
// ... same notes array as above; you can clear the seed data
const routes = defineRoutes([
defineRoute({
method: 'GET',
path: '/notes',
handler: () => notes,
}),
defineRoute({
method: 'GET',
path: '/notes/:id',
schema: {
params: Type.Object({ id: Type.String() }),
},
handler: ({ params }) => {
const note = notes.find((n) => n.id === params.id)
if (!note) throw err.notFound('Note not found')
return note
},
}),
defineRoute({
method: 'POST',
path: '/notes',
schema: { body: NoteBody },
handler: ({ body }) => {
const note: Note = {
id: String(Date.now()),
title: body.title,
content: body.content,
}
notes.push(note)
return note
},
}),
])curl -X POST http://localhost:3000/notes \
-H 'Content-Type: application/json' \
-d '{"title":"First post","content":"Hello"}'See Validation for more patterns.
Step 3: Split into Route Files
A single file grows quickly. Convention: the entry file only starts the server; routes are split into files by domain.
src/
index.ts
routes/
notes.ts// src/routes/notes.ts
import { defineRoute, defineRoutes, Type, err } from 'vafast'
interface Note {
id: string
title: string
content: string
}
const notes: Note[] = []
const NoteBody = Type.Object({
title: Type.String({ minLength: 1 }),
content: Type.String({ minLength: 1 }),
})
export const notesRoutes = defineRoutes([
defineRoute({
method: 'GET',
path: '/notes',
handler: () => notes,
}),
defineRoute({
method: 'GET',
path: '/notes/:id',
schema: { params: Type.Object({ id: Type.String() }) },
handler: ({ params }) => {
const note = notes.find((n) => n.id === params.id)
if (!note) throw err.notFound('Note not found')
return note
},
}),
defineRoute({
method: 'POST',
path: '/notes',
schema: { body: NoteBody },
handler: ({ body }) => {
const note: Note = {
id: String(Date.now()),
title: body.title,
content: body.content,
}
notes.push(note)
return note
},
}),
])// src/index.ts
import { Server, serve } from 'vafast'
import { notesRoutes } from './routes/notes'
const server = new Server(notesRoutes)
serve({ fetch: server.fetch, port: 3000 }, () => {
console.log('http://localhost:3000')
})Behavior is the same as Step 2, just better structured.
Step 4: Organize Paths with Route Groups
As /notes, /notes/:id and /notes (POST) multiply, use a route group to share a prefix:
| Type | Characteristics | Purpose |
|---|---|---|
| Leaf | Has method + handler | An actual endpoint |
| Route group | No method, has children | Path prefix + shared middleware |
Extract the leaves into constants, then attach them to the group:
// src/routes/notes.ts
import { defineRoute, defineRoutes, Type, err } from 'vafast'
// ... Note, notes, NoteBody same as above
const listHandler = defineRoute({
method: 'GET',
path: '/',
handler: () => notes,
})
const getOneHandler = defineRoute({
method: 'GET',
path: '/:id',
schema: { params: Type.Object({ id: Type.String() }) },
handler: ({ params }) => {
const note = notes.find((n) => n.id === params.id)
if (!note) throw err.notFound('Note not found')
return note
},
})
const createHandler = defineRoute({
method: 'POST',
path: '/',
schema: { body: NoteBody },
handler: ({ body }) => {
const note: Note = {
id: String(Date.now()),
title: body.title,
content: body.content,
}
notes.push(note)
return note
},
})
export const notesRoutes = defineRoutes([
defineRoute({
path: '/notes', // no method → route group
children: [listHandler, getOneHandler, createHandler],
}),
])The actual paths are still GET /notes, GET /notes/:id and POST /notes. Child routes just use relative paths.
Step 5: Add a Middleware Layer
Middleware can be attached at three levels:
| Level | How | Scope |
|---|---|---|
| Global | server.use(mw) | All routes (CORS, request ID) |
| Route group | defineRoute({ path, middleware, children }) | The group's child routes (most common) |
| Leaf | defineRoute({ method, middleware, handler }) | A single endpoint |
Group-Level Logging (Simplest)
import { defineMiddleware } from 'vafast'
const logMiddleware = defineMiddleware(async (req, next) => {
const start = Date.now()
const res = await next()
console.log(`${req.method} ${req.url} → ${res.status} (${Date.now() - start}ms)`)
return res
})
export const notesRoutes = defineRoutes([
defineRoute({
path: '/notes',
middleware: [logMiddleware], // inherited by all children
children: [listHandler, getOneHandler, createHandler],
}),
])Injecting Context: next({ ... }) → handler
Middleware isn't just for side logic; it can also pass data to the handler:
import { defineMiddleware, defineRoute, defineRoutes, err } from 'vafast'
const fakeAuth = defineMiddleware(async (req, next) => {
const token = req.headers.get('authorization')
if (!token) throw err.unauthorized('Please log in first')
// Injected fields appear in the handler params of the same route, fully typed
return next({ userId: 'demo-user' })
})
const createHandler = defineRoute({
method: 'POST',
path: '/',
middleware: [fakeAuth], // attached to the leaf: types are inferred automatically
schema: { body: NoteBody },
handler: ({ body, userId }) => {
// userId: string ← from fakeAuth
return { id: String(Date.now()), userId, ...body }
},
})Across children: Use withContext
When the parent group carries the middleware and child routes are extracted into constants, TypeScript can't infer the fields injected by the parent. Use withContext as a pure type wrapper (zero runtime cost):
import { withContext, defineRoute, defineRoutes } from 'vafast'
const defineAuthedRoute = withContext<{ userId: string }>()
const createHandler = defineAuthedRoute({
method: 'POST',
path: '/',
schema: { body: NoteBody },
handler: ({ body, userId }) => ({ id: '1', userId, ...body }),
})
export const notesRoutes = defineRoutes([
defineRoute({
path: '/notes',
middleware: [fakeAuth], // injects userId at runtime
children: [createHandler], // types are wired up by withContext
}),
])Types for group-level middleware
When leaves are moved into children, or several files share the same context, wrap your route definer with withContext. For nested routes and middleware layering see §2 and §3; for declarative parameters see §8. To roll your own JWT, see @vafast/jwt.
What You Can Do Now
// Leaf + Schema
const createHandler = defineRoute({
method: 'POST',
path: '/',
schema: { body: NoteBody },
handler: ({ body }) => { /* ... */ },
})
// Group + middleware
export const notesRoutes = defineRoutes([
defineRoute({
path: '/notes',
middleware: [logMiddleware],
children: [listHandler, createHandler],
}),
])
// Entry + global middleware
const server = new Server(notesRoutes)
server.use(/* cors, etc. */)
serve({ fetch: server.fetch, port: 3000 })Checklist:
Next Steps
- Best Practices — directory conventions,
withContext, startup config - Key Concepts — how a request flows through the framework
- Routing Guide — fuller nesting and matching rules
- Middleware —
defineMiddlewarein detail - For streaming output see SSE; for going live see Deployment