Coming from Express
The handler signature
Section titled “The handler signature”app.get('/users/:id', (req, res) => { res.status(200).json({ id: req.params.id })})app.get('/users/:id', (ctx) => { return { id: ctx.params.id }})No res. Return a value; Oven works out the rest. 200 and application/json are what
returning an object means.
Middleware you can delete
Section titled “Middleware you can delete”| Express | Oven |
|---|---|
express.json() / body-parser |
ctx.body, always on, lazy |
cookie-parser |
ctx.cookies, always on |
multer |
uploads arrive as File in ctx.body |
express-async-errors |
async throws are caught natively |
morgan |
ctx.log, request-scoped and structured |
| hand-rolled bearer-token parsing | ctx.token |
| hand-rolled graceful shutdown | built into close() |
Middleware
Section titled “Middleware”The signature is the biggest change, and the one to learn first.
app.use((req, res, next) => { const started = Date.now() res.on('finish', () => console.log(Date.now() - started)) next()})app.use(async (ctx, next) => { const started = performance.now() const result = await next() ctx.log.info('handled', { ms: performance.now() - started }) return result})Express middleware is a list you call next() to advance. Oven middleware is an onion:
next() returns, so the code after it runs on the way back out. That is why timing, logging and
header-stamping need no res.on('finish') — there is an “after” to write code in.
What next() gives you is the result, not necessarily a Response: a handler returning an
object hands you that object, and coercion happens once at the end. Framework refusals — 404,
405 — do arrive as a Response, which is what lets your headers land on them too. Pass it
through unchanged unless you mean to replace it, and use ctx.set() for headers rather than
rebuilding the response.
Scoping works the same way:
app.use('/admin', requireAdmin()) // this prefix and belowMiddleware runs for requests that match no route, which Express users often expect and do not get — so CORS preflights and 404s still pass through your logging and security headers.
Routers
Section titled “Routers”const users = express.Router()users.get('/', list)users.get('/:id', show)app.use('/users', users)There are two translations, and both are right depending on why you had a router.
If the router was a directory of endpoints, use file-based routing — the filesystem is the route table:
// src/routes/users/index.get.ts → GET /users// src/routes/users/[id].get.ts → GET /users/:idA _middleware.ts in a directory applies to it and everything under it, which is what
router.use() was for.
If the router was a group with something in common — a shared guard, a shared prefix, a set of routes you mount twice — use a router:
const users = routerFor<typeof app>({ prefix: '/users', tags: ['users'], auth: true })
users.get('/', list)users.get('/:id', { params: z.object({ id: z.uuid() }) }, show)
app.use(users)That is closer to what express.Router() actually was, with two differences worth knowing:
authandtagsare declared once for the group. Express had no equivalent; you passed middleware and hoped every route in the file was covered.- A router can be mounted more than once, so
/v1and/v2over the same routes is two lines rather than a factory function.
| Express | Oven |
|---|---|
express.Router() |
router() / routerFor<typeof app>() |
router.use(mw) |
router.use(mw) — scoped to the router’s prefix |
app.use('/users', router) |
router({ prefix: '/users' }), then app.use(router) |
router.use(otherRouter) |
router.use(otherRouter) — prefixes join |
| middleware for auth on every route | auth on the router, once |
app.use('/api', someOtherApp) |
not supported — a router is routes, not a second app |
Validation
Section titled “Validation”const { body, validationResult } = require('express-validator')
app.post('/users', body('email').isEmail(), body('age').isInt({ min: 0 }), (req, res) => { const errors = validationResult(req) if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }) // req.body is still `any` },)export default route( { body: z.object({ email: z.email(), age: z.number().int().min(0) }) }, (ctx) => ctx.body.email, // typed, already validated)One declaration replaces the chain, types the handler, and becomes the endpoint’s OpenAPI description. See Validation.
Errors
Section titled “Errors”app.get('/users/:id', async (req, res, next) => { try { const user = await db.find(req.params.id) if (!user) return res.status(404).json({ error: 'Not found' }) res.json(user) } catch (err) { next(err) }})app.get('/users/:id', async (ctx) => { const user = await db.find(ctx.params.id) if (!user) throw new NotFound() return user})The try/catch and the next(err) are gone because the framework already does that. The
404 body is a standard problem document instead of an ad-hoc shape you invented.
Status and headers
Section titled “Status and headers”res.status(201).set('Location', '/users/3').json(user)ctx.status = 201ctx.set('location', '/users/3')return userSending files
Section titled “Sending files”res.sendFile('/path/to/report.pdf')return Bun.file('/path/to/report.pdf') // streamed, content type detectedCORS, helmet, rate limiting
Section titled “CORS, helmet, rate limiting”app.use(cors({ origin: 'https://app.example.com' }))app.use(helmet())app.use(rateLimit({ windowMs: 60_000, max: 100 }))import { cors, rateLimit, securityHeaders } from '@theoven/core'
app.use(cors({ origin: 'https://app.example.com' }))app.use(securityHeaders())app.use(rateLimit({ limit: 100, window: 60_000 }))Nothing to install — these ship in core. They stay explicit rather than always-on because their policy genuinely varies by app, unlike body parsing.
req.user, res.locals and friends
Section titled “req.user, res.locals and friends”Express middleware attaches things to the request and later handlers read them, untyped:
app.use(async (req, res, next) => { req.tenant = await resolveTenant(req.hostname) next()})
app.get('/dashboard', (req, res) => res.json({ tenant: req.tenant })) // anyOven has no ctx.state bag, deliberately — an untyped bucket is invisible to autocomplete and
unknown where you read it. There are two replacements, and which one you want depends on scope:
const tenant = dependency('tenant', (ctx) => resolveTenant(ctx.header('host')))
export default route({ deps: { tenant } }, (ctx) => ({ tenant: ctx.deps.tenant }))// ^ typed// A brick: built once at boot, on the context of every request.app.use({ name: 'billing', setup: () => createBillingClient() })The rule: a dependency is per request and per route, a brick is per app. Both arrive typed; neither can be forgotten by a route that needs it, because the route names it.
Dependencies also compose and clean up, which req.locals never did:
const tx = dependency('tx', async function* (ctx) { const handle = await begin(ctx.db) try { yield handle await handle.commit() } catch (error) { await handle.rollback() throw error }})Things that genuinely do not translate
Section titled “Things that genuinely do not translate”- Connect middleware. The
(req, res, next)signature does not exist. Anything from the Express ecosystem must be rewritten as an Oven brick or a_middleware.tsfile. res.write()streaming. Return aReadableStreaminstead.- CommonJS. Oven is ESM only.
- Running on Node. Oven is Bun-only. This is a deliberate, permanent choice.
express.static(). There is no static-file middleware. ReturnBun.file(path)from a route, or — better for anything public — put a CDN or your platform’s static hosting in front.- View engines. No
res.render, no Pug or EJS integration. Return HTML from a template literal, or aResponseyou built yourself. req.session. No session middleware. Theauthbrick issues tokens rather than server-side sessions;ctx.cookiesis there if you want to build your own.
Porting an app, in order
Section titled “Porting an app, in order”-
Start a new project with
bun create theoven my-apprather than converting in place. The directory layout is the router, so a half-converted tree is harder to reason about than two whole ones. -
Move routes first, one file per endpoint. Handlers usually port by deleting things: the
try/catch, thenext(err), theres.json. -
Delete middleware rather than porting it. Check the table above first — most of what an Express app registers is already on.
-
Add schemas as you go. Each one you write buys typing and a documented endpoint, so this is the step that pays for itself fastest.
-
Swap the infrastructure last — database, auth, uploads, jobs. Each is one
bun addand one.use(); see the brick catalogue.
What you gain
Section titled “What you gain”The reason to move is not the handler signature — it is that auth, database, storage, mail and queues become configuration instead of a week of wiring. See the philosophy page for why that is the whole point.