Skip to content

Coming from Express

Express
app.get('/users/:id', (req, res) => {
res.status(200).json({ id: req.params.id })
})
Oven
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.

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()

The signature is the biggest change, and the one to learn first.

Express
app.use((req, res, next) => {
const started = Date.now()
res.on('finish', () => console.log(Date.now() - started))
next()
})
Oven
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 below

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

Express
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:

Oven — file routing
// src/routes/users/index.get.ts → GET /users
// src/routes/users/[id].get.ts → GET /users/:id

A _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:

Oven — 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:

  • auth and tags are 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 /v1 and /v2 over 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
Express
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`
},
)
Oven
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.

Express
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)
}
})
Oven
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.

Express
res.status(201).set('Location', '/users/3').json(user)
Oven
ctx.status = 201
ctx.set('location', '/users/3')
return user
Express
res.sendFile('/path/to/report.pdf')
Oven
return Bun.file('/path/to/report.pdf') // streamed, content type detected
Express
app.use(cors({ origin: 'https://app.example.com' }))
app.use(helmet())
app.use(rateLimit({ windowMs: 60_000, max: 100 }))
Oven
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.

Express middleware attaches things to the request and later handlers read them, untyped:

Express
app.use(async (req, res, next) => {
req.tenant = await resolveTenant(req.hostname)
next()
})
app.get('/dashboard', (req, res) => res.json({ tenant: req.tenant })) // any

Oven 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:

Oven — needed by some routes
const tenant = dependency('tenant', (ctx) => resolveTenant(ctx.header('host')))
export default route({ deps: { tenant } }, (ctx) => ({ tenant: ctx.deps.tenant }))
// ^ typed
Oven — needed by every route
// 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
}
})
  • 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.ts file.
  • res.write() streaming. Return a ReadableStream instead.
  • 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. Return Bun.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 a Response you built yourself.
  • req.session. No session middleware. The auth brick issues tokens rather than server-side sessions; ctx.cookies is there if you want to build your own.
  1. Start a new project with bun create theoven my-app rather than converting in place. The directory layout is the router, so a half-converted tree is harder to reason about than two whole ones.

  2. Move routes first, one file per endpoint. Handlers usually port by deleting things: the try/catch, the next(err), the res.json.

  3. Delete middleware rather than porting it. Check the table above first — most of what an Express app registers is already on.

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

  5. Swap the infrastructure last — database, auth, uploads, jobs. Each is one bun add and one .use(); see the brick catalogue.

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.