Documentation
Migrating from Express
Express was designed for Node.js in 2010. Kynetra FX is built for the modern Web Platform — Cloudflare Workers, Bun, Deno, and Node.js alike. The biggest mental shift is moving from mutation to return: instead of calling res.send() and mutating the response object, your handler returns a Response value via ctx.json(), ctx.text(), and friends. Everything else — routing, middleware chains, groups, error handling — maps cleanly.
Concept Mapping#
Every common Express pattern has a direct Kynetra FX equivalent. The most important structural difference is that req and res are merged into a single ctx object, and handlers return a response rather than writing to one.
| Name | Type | Description |
|---|---|---|
| express() | createFX({ name, runtime }) | Factory function; name and runtime are required. |
| (req, res, next) => ... | (ctx, next) => ... | Single context object merges req + res. |
| res.json(data) | return ctx.json(data, init?) | Must return the response, not mutate. |
| res.send(str) | return ctx.text(str) | Plain-text response. |
| res.status(201).json(data) | return ctx.json(data, { status: 201 }) | Status is passed in the second argument init object. |
| res.redirect(url) | return ctx.redirect(url, status?) | Optional status code (default 302). |
| req.params.id | ctx.params.id | Same property path, different object. |
| req.query.q | ctx.query('q') | Method call, not a property. No args returns all params. |
| req.body (after body-parser) | await ctx.jsonBody<T>() | Explicit async read; no global body-parser needed. |
| req.headers['x-foo'] | ctx.header('x-foo') | Request header reader. |
| res.set('X-Foo', val) | ctx.set('X-Foo', val) | Sets a response header. |
| res.append('X-Foo', val) | ctx.append('X-Foo', val) | Appends to a multi-value response header. |
| express.Router() | app.group('/prefix', fn) | Inline group builder replaces the Router object. |
| app.use("/path", router) | app.group('/path', fn) | Prefix mounting via group. |
| (err, req, res, next) => ... | app.hook('onError', (err, ctx) => ...) | Error hook; return a Response to override. |
| app.listen(port) | serve(app, { port }) via @kynetra/fx-node | Runtime adapter for Node.js. |
| res.cookie(name, val) | ctx.setCookie(name, val, opts) | No cookie-parser package required. |
| req.cookies.name | ctx.cookie(name) | Reads a request cookie by name. |
App Setup#
Express apps call express(), optionally register express.json()for body parsing, and start with app.listen(). Kynetra FX uses a factory function and a separate runtime adapter. There is no global body-parser — bodies are read on demand with ctx.jsonBody().
import express from 'express'const app = express()app.use(express.json())app.listen(3000, () => console.log('listening on :3000'))// Node.jsimport { createFX } from '@kynetra/fx'import { serve } from '@kynetra/fx-node' const app = createFX({ name: 'api', runtime: 'node' })serve(app, { port: 3000 }) // Cloudflare Workersimport { createFX, cloudflare } from '@kynetra/fx'const app = createFX({ name: 'api', runtime: 'cloudflare' })return cloudflare(app)Tip
ctx.jsonBody(), ctx.formBody(), and ctx.textBody() are built in and read the Request body directly from the Web Streams API.Basic Routes and Params#
Route methods map one-to-one: app.get, app.post, app.put, app.patch, app.delete. Params are accessed the same way — ctx.params.id — but query strings use a method call rather than a property.
app.get('/users/:id', (req, res) => { res.json({ id: req.params.id })}) app.get('/search', (req, res) => { const q = req.query.q res.json({ q })})app.get('/users/:id', (ctx) => { return ctx.json({ id: ctx.params.id })}) app.get('/search', (ctx) => { return ctx.json({ q: ctx.query('q') })})Middleware#
Express middleware uses the three-argument (req, res, next) pattern. Kynetra FX middleware uses (ctx, next). The onion model is the same — code before await next() runs on the way in, code after runs on the way out. The key difference: next() is always async in Kynetra FX and must be awaited when you need to run code after downstream handlers.
app.use((req, res, next) => { console.log(req.method, req.url) next()}) function requireLogin(req, res, next) { if (!req.headers.authorization) { return res.status(401).json({ error: 'Unauthorized' }) } next()} app.get('/dashboard', requireLogin, (req, res) => res.json({ ok: true }))import { logger, requestId } from '@kynetra/fx' app.use(logger())app.use(requestId()) // Custom global middlewareapp.use(async (ctx, next) => { console.log(ctx.req.method, ctx.url.pathname) await next()}) // Inline per-route middlewareconst requireLogin = async (ctx, next) => { if (!ctx.header('authorization')) { return ctx.json({ error: 'Unauthorized' }, { status: 401 }) } return next()} app.get('/dashboard', requireLogin, (ctx) => ctx.json({ ok: true }))Body Handling#
Express requires express.json() or body-parser to populate req.body. Kynetra FX reads the body explicitly via helper methods — one call per request since the underlying stream can only be consumed once.
app.post('/users', (req, res) => { const { name, email } = req.body res.status(201).json({ name, email })}) app.post('/upload', express.urlencoded({ extended: true }), (req, res) => { res.json(req.body)})app.post('/users', async (ctx) => { const { name, email } = await ctx.jsonBody() return ctx.json({ name, email }, { status: 201 })}) app.post('/upload', async (ctx) => { const form = await ctx.formBody() return ctx.json(Object.fromEntries(form.entries()))})Route Groups#
Express uses express.Router() to create a sub-router, then mounts it with app.use('/prefix', router). Kynetra FX replaces this with app.group('/prefix', fn), which is an inline builder that keeps the hierarchy clear and enables scoped middleware without a separate file.
const userRouter = express.Router()userRouter.get('/', (req, res) => res.json([]))userRouter.post('/', (req, res) => res.status(201).json({}))userRouter.get('/:id', (req, res) => res.json({ id: req.params.id }))app.use('/users', userRouter)app.group('/users', (users) => { users.get('/', (ctx) => ctx.json([])) users.post('/', (ctx) => ctx.json({}, { status: 201 })) users.get('/:id', (ctx) => ctx.json({ id: ctx.params.id }))})Groups can be nested and carry their own middleware:
app.group('/api/v1', (v1) => { v1.use(async (ctx, next) => { ctx.set('X-API-Version', '1') await next() }) v1.group('/users', (users) => { users.get('/:id', (ctx) => ctx.json({ id: ctx.params.id })) })})Validation#
Express has no built-in validation — the typical pattern is manual checks or third-party middleware. Kynetra FX has first-class contract support. Define the expected shape on the route and the validated, typed value is available as ctx.input. Validation failures automatically return a 422 before your handler runs.
app.post('/issues', (req, res) => { if (!req.body.title) { return res.status(400).json({ error: 'title required' }) } res.status(201).json(req.body)})import { createFX, fx } from '@kynetra/fx' // Built-in fx schema builderapp.route({ method: 'POST', path: '/issues', input: fx.object({ title: fx.string().min(1), priority: fx.optional(fx.string()), }), handler: (ctx) => ctx.json(ctx.input, { status: 201 }),}) // Standard Schema compatible — Zod works without an adapterimport { z } from 'zod' app.route({ method: 'POST', path: '/issues', input: z.object({ title: z.string().min(1) }), handler: (ctx) => ctx.json(ctx.input, { status: 201 }),})Error Handling#
Express identifies error handlers by the four-argument signature (err, req, res, next) and it must be registered last. Kynetra FX uses the hook system — register onError anywhere and return a Response to override the default structured error body. You can throw an FXError from any handler to produce a structured error response.
// Must be last in the middleware chainapp.use((err, req, res, next) => { console.error(err) res.status(500).json({ error: err.message })})import { FXError } from '@kynetra/fx' app.hook('onError', (err, ctx) => { console.error(err) if (err instanceof FXError) { return ctx.json({ error: err.message }, { status: err.status }) } return ctx.json({ error: 'Internal server error' }, { status: 500 })}) // Throw structured errors from any handlerapp.get('/users/:id', async (ctx) => { const user = await db.find(ctx.params.id) if (!user) throw new FXError(404, 'USER_NOT_FOUND', 'User not found') return ctx.json(user)})Cookies#
Express requires the cookie-parser package to read cookies and uses res.cookie() to set them. Kynetra FX has cookie support built in — no extra package needed.
import cookieParser from 'cookie-parser'app.use(cookieParser()) app.get('/me', (req, res) => { const token = req.cookies.token res.cookie('session', 'abc', { httpOnly: true, secure: true }) res.json({ token })})app.get('/me', (ctx) => { const token = ctx.cookie('token') ctx.setCookie('session', 'abc', { httpOnly: true, secure: true }) return ctx.json({ token })})Deploy / Entry Point#
Express calls app.listen() directly. Kynetra FX apps expose a standard fetch handler and are served via the appropriate runtime adapter. The same application code works on Node.js, Cloudflare Workers, Bun, and Deno without changes.
app.listen(3000, () => console.log('Server on :3000'))// Node.jsimport { serve } from '@kynetra/fx-node'serve(app, { port: 3000 }) // Cloudflare Workersreturn cloudflare(app) // Bunreturn { fetch: app.fetch.bind(app) } // Direct fetch — useful in testsconst response = await app.fetch(new Request('http://localhost/users/42'))Warning
res.send() to call. If a handler returns undefined, Kynetra FX treats it as an unmatched route and returns a 404. Always return ctx.json(...), return ctx.text(...), or another response helper. This is the single most common mistake when migrating from Express.Migration Checklist#
- Replace
express()withcreateFX({ name, runtime }) - Remove
express.json()andbody-parser— useawait ctx.jsonBody() - Remove
cookie-parser— usectx.cookie()andctx.setCookie() - Change
(req, res)signatures to(ctx)that return a response - Replace
res.json(data)withreturn ctx.json(data) - Replace
res.status(N).json(data)withreturn ctx.json(data, { status: N }) - Replace
res.send(str)withreturn ctx.text(str) - Replace
res.redirect(url)withreturn ctx.redirect(url) - Replace
req.params.idwithctx.params.id - Replace
req.query.qwithctx.query('q') - Replace
req.bodywithawait ctx.jsonBody() - Replace
req.headers['x-foo']withctx.header('x-foo') - Replace
res.set('X-Foo', val)withctx.set('X-Foo', val) - Replace
express.Router()andapp.use('/path', router)withapp.group('/path', fn) - Replace four-argument error middleware with
app.hook('onError', fn) - Replace
res.cookie/req.cookieswithctx.setCookie/ctx.cookie - Replace
app.listen()withserve(app, opts)orcloudflare(app)
Further Reading
See Routing for the full route API, Body for body-reading details, Cookies for cookie options, Contracts for typed validation, and Errors for the FXError class.