Reply
Middleware can return a Response or simple value, but for more control, Server.js exports reply helpers that can be used standalone or chained together:
| Helper | Description |
|---|---|
cache | Set Cache-Control from a duration like '1h' or a number (seconds). |
cookies | Set or delete response cookies, with options like path and expires. |
download | Send a file as an attachment so the browser downloads it. |
file | Stream a file from disk, guessing its Content-Type, 404 if missing. |
headers | Set one or many response headers at once. |
json | Stringify objects and send them as Content-Type: application/json. |
redirect | Send a 302 redirect through the Location header. |
send | Send any body, auto-detecting text, HTML, JSON, a Buffer or a stream. |
status | Set the response's HTTP status code, like 201 or 404. |
type | Set the Content-Type from a MIME type or a file extension like csv. |
import { status, json, redirect, ... } from '@server/next';The chainable helpers can be strung together and end with either .send() or a terminal method like .json() or .redirect().
Inline replies
The simplest way to reply is to just return a value from your middleware:
// Plain string → text/plain; charset=utf-8
// (or text/html; charset=utf-8 if it starts with a tag)
.get('/text', () => 'Hello world')
.get('/html', () => '<h1>Hello world</h1>')
// Object or array → application/json
.get('/json', () => ({ hello: 'world' }))
// HTTP status code shorthand (empty body)
.post('/create', () => 201)
// A bare chainable helper is finalized for you (empty body)
.get('/gone', () => status(410))
// Buffer / Blob → raw bytes (a Blob keeps its own type)
.get('/bytes', () => Buffer.from([0x89, 0x50]))
.get('/pdf', () => new Blob([data], { type: 'application/pdf' }))
// A ReadableStream, or a (async) generator, streams chunk by chunk
.get('/stream', async function* () { yield 'a'; yield 'b'; })
// A bucket file handle → streamed back, typed, 404 if missing
.get('/avatar', () => bucket.file('avatars/me.jpg'))
// A web-standard Response object (e.g. from fetch())
.get('/custom', () => new Response('ok', { status: 200 }))This covers most routes; the helpers below are for when you also need to set a status, a header, a cookie or an exact content type.
cache()
Set the Cache-Control header from a duration or a number of seconds:
return cache('1h').json(posts); // Cache-Control: public, max-age=3600
return cache(false).json(data); // Cache-Control: no-storeIt's chainable. A duration string ('30m', '1h', '7d') or a number of seconds becomes public, max-age=<seconds>; false or 0 sends no-store, which is how a single route opts out of a global cache default. For anything more specific (private, s-maxage, stale-while-revalidate, ...), set the header directly with headers().
import { cache, headers } from '@server/next';
// Public list, cacheable for 10 minutes
.get('/posts', async () => cache('10m').json(await db.posts.list()))
// Per-user data: cache it, but only in the browser
.get('/me', (ctx) => headers('cache-control', 'private, max-age=60').json(ctx.user))Use cache() when the value depends on the route or request; for a fixed default across all routes, set the cache option instead. A Cache-Control set here always wins over that default. Server.js also adds an ETag to buffered GET responses and answers 304 to matching If-None-Match requests on its own.
See also
cacheoption: the project-wide default.headers(): full control over theCache-Controlvalue.
cookies()
Set or delete response cookies:
return cookies('token', 'abc123').send(); // simple value
return cookies('token', { value: 'abc123', ... }); // with options
return cookies('token', null).send(); // delete it
return cookies({ theme: 'dark', lang: 'en' }).send(); // several at onceIt's chainable. Pass a name and a string for a plain cookie, or an options object for control over its attributes; every cookie defaults to Path=/. Values are URL-encoded, so they can hold any character, and ctx.cookies decodes them back:
| Option | Type | Description |
|---|---|---|
value | string | null | The cookie value; null deletes the cookie |
path | string | URL scope, defaults to / |
expires | string | number | Date | A duration like '7d', milliseconds from now, or a Date |
maxAge | number | Lifetime in seconds (Max-Age) |
httpOnly | boolean | Hide it from JavaScript (document.cookie) |
secure | boolean | Only send it over HTTPS |
sameSite | 'Strict' | 'Lax' | 'None' | Cross-site sending policy |
import { cookies } from '@server/next';
.post('/login', async (ctx) => {
const user = await authenticate(ctx.body);
if (!user) return 401;
return cookies('session', {
value: user.sessionId,
expires: '7d',
httpOnly: true,
secure: true,
sameSite: 'Lax',
}).redirect('/dashboard');
})Deleting cookies
Set the cookie to null and an already-expired cookie is sent, so the browser drops it:
.post('/logout', () => cookies('session', null).redirect('/'))Auth cookies
You rarely manage the login cookie by hand: auth sets and clears it for you. Use cookies() for your own cookies, like a theme, a language, or state you keep per browser.
See also
ctx.cookies: read the cookies the client sent.auth: the login cookie, handled for you.
download()
Send a file as an attachment, so the browser downloads it instead of rendering it:
return download('export.csv').file('./data/export.csv'); // a file on disk
return download('sales.csv').send(toCsv(rows)); // generated contentIt's chainable: it sets Content-Disposition: attachment, adds the filename when you pass one (URL-encoded, so spaces and unicode are safe), and infers the Content-Type from the filename's extension when no type is set yet. Chain .file() for a file's contents or .send() for generated ones.
import { download } from '@server/next';
// Generate and download a report
.get('/export', async (ctx) => {
const rows = await db.report(ctx.query);
return download('sales.csv').send(toCsv(rows));
})
// Without a filename: just the download prompt
.get('/raw', () => download().file('./data/export.csv'))See also
file(): serve the same file inline, without the download prompt.type(): set the type explicitly when the filename has no extension.
file()
Stream a file, from a disk path or a Bucket file handle; a missing file responds with 404:
return file('./index.html'); // a path on disk
return file(bucket.file('me.jpg')); // a stored bucket fileIt's a terminal helper, async, and it streams the contents rather than buffering them, so large files are fine. The Content-Type comes from the file extension (or the bucket file's stored type). You can return it directly without await:
import { file } from '@server/next';
.get('/', () => file('./index.html'))
// Serve a stored file behind your own checks
.get('/invoices/:id', async (ctx) => {
if (!ctx.user) return 401;
return file(bucket.file(`${ctx.url.params.id}.pdf`));
})For a folder of open static assets use the public option instead; file() is for single files and for files served behind your own logic. See Serving stored files for the full private-uploads flow and File handling for buckets.
Paths and keys
file('...') takes a path on disk, so it's for locations your code decides. A bucket takes a key instead, which is why user input belongs there: a key that climbs out of the bucket throws, and one that looks absolute is read as a key, so bucket.file('/var/data/x') looks for x under var/data/ inside the bucket rather than at the root of your disk:
const uploads = bucket.FS('/srv/uploads');
uploads.file('photo.jpg'); // /srv/uploads/photo.jpg
uploads.file('/srv/uploads/x'); // /srv/uploads/srv/uploads/x, a 404
uploads.file('../secrets'); // throwsRoute params that climb or look absolute are rejected before any of this, by traversalProtection.
See also
download(): chain it in front to force a download prompt.publicoption: serve a whole folder as static assets.
headers()
Set the response headers from a route handler:
return headers('key', 'value').send(); // a single header
return headers({ key1: 'a', key2: 'b' }).send(); // several at once
return headers('link', ['<a>', '<b>']).send(); // repeat one headerIt's chainable, so finish with a terminal helper, and combine it freely with the others:
import { headers, status } from '@server/next';
// Make sure the browser never caches this page
.get('/account', () => headers('cache-control', 'no-store').send(html))
// Rate limited: reply when to retry
.post('/api/messages', () => {
return status(429)
.headers('retry-after', '60')
.json({ error: 'Too many requests' });
})An array value sends the same header several times, for list headers like Link. Header names are case-insensitive.
.get('/download', () => {
return headers({
'content-disposition': 'attachment; filename="report.csv"',
'cache-control': 'no-store',
}).type('csv').send(csv);
})Headers on every response
Helpers only apply to the route that returns them. To add a header to every response, use the onResponse hook:
server({
onResponse: (res) => {
res.headers.set('x-powered-by', 'server.js');
return res;
},
});See also
type(): the dedicated helper forContent-Type.cache(): the dedicated helper forCache-Control.cookies(): builds theSet-Cookieheader for you, with options.onResponseoption: edit every outgoing response.
json()
Stringify a value and send it as Content-Type: application/json:
return json({ users: [] }); // 200 with a JSON body
return status(201).json(user); // with another statusIt's a terminal helper, returning the Response. Returning a plain object or array from a route sends JSON all the same, so use json() explicitly when you chain it after status(), headers() or cookies(), or when the value is something else (a string, a number) that would otherwise be sent as text or a status. It deliberately does not accept JSX, that's HTML, so return it directly or through send().
import { status, json } from '@server/next';
.get('/users/:id', async (ctx) => {
const user = await db.users.find(ctx.url.params.id);
if (!user) return 404;
return json(user);
})Anything JSON.stringify accepts works, and values it drops (functions, undefined) are dropped the same way. The header is application/json with no charset parameter, since JSON is UTF-8 by definition.
See also
- Inline replies: returning an object or array is the shorthand for
json(). send(): falls back to JSON for anything that isn't a string, bytes or a stream.
redirect()
Redirect the client to another URL, sending a 302 with the Location header:
return redirect('/new-path');It's terminal, returning the Response. Use it after a form submission or to send visitors elsewhere, and chain other helpers like cookies() in front:
import { redirect, cookies } from '@server/next';
.post('/login', async (ctx) => {
const user = await authenticate(ctx.body);
if (!user) return redirect('/login?error=1');
return cookies('session', user.sessionId).redirect('/dashboard');
})Other redirect codes
302 is only the default: set the status first for a permanent (301) or method-preserving (307, 308) redirect:
.get('/old', () => status(301).redirect('/new'))See also
send()
Send a response with any body; every chain ends here or on another terminal helper:
return send('pong'); // a body
return send(); // empty body
return status(204).send(); // finish a chain of other helperssend(x) accepts whatever return x does, so the two are interchangeable; reach for send() when a chain of other helpers needs finishing. Unless you've already set the Content-Type (via type() or headers()), it is detected from the body:
stringstarting with a tag, like<p>or<!doctype→text/html; charset=utf-8- other
string→text/plain; charset=utf-8 Buffer/Uint8Array→ sent as raw bytesReadableStream(or a Node stream) → streamed through- JSX → rendered and sent as
text/html; charset=utf-8 - a
Response→ sent as-is, with anything set on the chain applied on top - a bucket file → streamed with its type,
404when missing - a promise → awaited first, so
send(fetch(url))needs noawait - anything else → stringified as
application/json
// Proxy a response, adding a header of your own
.get('/upstream', () => headers('x-cache', 'miss').send(fetch(url)))
// Serve a stored file behind your own check
.get('/invoice', (ctx) => cache(false).send(uploads.file(ctx.user.invoice)))The terminal helpers (send(), json(), redirect(), file()) are async, since a file has to be read before its status is known. Returning them from a route is all you ever need, because a route awaits whatever it returns; reach for await only if you want to inspect the Response yourself.
One thing differs from returning a value: a number is a status code when returned (return 201) but a JSON body when sent (send(201)), since send() is explicitly "this is the body"; use status() instead. An async JSX component is still refused (with a pointer to fix it), because the renderer has no async support anywhere, not just here. That is the one promise send() will not take: a component that returns one, rather than a promise you hand it directly.
Set the type yourself when the detection isn't enough, e.g. for XML or raw bytes:
import { type, send } from '@server/next';
// XML string (would be detected as text/html)
.get('/feed', () => type('xml').send('<rss>...</rss>'))
// A Buffer (send() won't guess its type)
.get('/report.csv', () => type('csv').send(csvBuffer))
// A ReadableStream is streamed through, e.g. proxying an upstream response
.get('/proxy', async () => {
const upstream = await fetch('https://example.com/data');
return type('json').send(upstream.body);
})send() handles strings, Buffers and ReadableStreams. A Blob or a generator only work as a direct return (see Inline replies), not through send().
See also
json(): the shortcut for a JSON body.type(): set the content type before sending bytes or a stream.file(): stream a file instead of an in-memory body.
status()
Set the HTTP status code of the response:
return status(404); // on its own: that status, empty body
return status(201).json(user); // with a bodyIt's chainable, so pair it with a terminal helper like .json() or .send() for the body, or return it on its own for an empty-body response. For the codes that forbid a body (101, 204, 205, 304), the body is always dropped, so status(204).send() is safe even mid-chain.
import { status } from '@server/next';
.post('/users', async (ctx) => {
const user = await db.users.create(ctx.body);
return status(201).json(user);
})When all you need is the bare code, return 201 is the shorthand and reads better; reach for status() when a body or other helpers are involved.
See also
json()andsend(): the terminal helpers a status is usually chained onto.- Inline replies: returning a number as the status shorthand.
type()
Set the Content-Type header from a file extension or a full MIME type:
return type('csv').send(rows); // by extension ('csv' or '.csv')
return type('application/pdf').send(bytes); // by full MIME typeIt's chainable and replaces any type already set. Extensions are looked up in the built-in MIME table below (the leading dot is optional); text types resolve with ; charset=utf-8 appended, so type('html') sends text/html; charset=utf-8. A full MIME type or an unknown value is used verbatim, so to declare another charset pass it in full: type('text/plain; charset=windows-1252').
import { type } from '@server/next';
.get('/export.csv', async () => {
const csv = toCsv(await db.report());
return type('csv').send(csv);
})You rarely need type() for strings, objects or files, since returning them (or using file()) sets the type already. Reach for it when sending a Buffer or a stream, whose type can't be guessed, or to override the detected one:
// XML string (would otherwise be detected as text/html)
.get('/feed', () => type('xml').send('<rss>...</rss>'))
// A Buffer (send() won't guess it)
.get('/report.csv', () => type('csv').send(csvBuffer))The MIME table
Text types (text/*) gain ; charset=utf-8 when resolved from these names:
| Name | Content-Type |
|---|---|
epub | application/epub+zip |
gz | application/gzip |
jar | application/java-archive |
json | application/json |
jsonld | application/ld+json |
doc | application/msword |
bin | application/octet-stream |
ogx | application/ogg |
pdf | application/pdf |
rtf | application/rtf |
azw | application/vnd.amazon.ebook |
mpkg | application/vnd.apple.installer+xml |
xul | application/vnd.mozilla.xul+xml |
xls | application/vnd.ms-excel |
eot | application/vnd.ms-fontobject |
ppt | application/vnd.ms-powerpoint |
odp | application/vnd.oasis.opendocument.presentation |
ods | application/vnd.oasis.opendocument.spreadsheet |
odt | application/vnd.oasis.opendocument.text |
pptx | application/vnd.openxmlformats-officedocument.presentationml.presentation |
xlsx | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
docx | application/vnd.openxmlformats-officedocument.wordprocessingml.document |
rar | application/vnd.rar |
vsd | application/vnd.visio |
7z | application/x-7z-compressed |
abw | application/x-abiword |
bz | application/x-bzip |
bz2 | application/x-bzip2 |
cda | application/x-cdf |
csh | application/x-csh |
arc | application/x-freearc |
php | application/x-httpd-php |
sh | application/x-sh |
tar | application/x-tar |
xhtml | application/xhtml+xml |
xml | application/xml |
zip | application/zip |
aac | audio/aac |
mid / midi | audio/midi |
mp3 | audio/mpeg |
oga | audio/ogg |
opus | audio/opus |
wav | audio/wav |
weba | audio/webm |
otf | font/otf |
ttf | font/ttf |
woff | font/woff |
woff2 | font/woff2 |
avif | image/avif |
bmp | image/bmp |
gif | image/gif |
jpeg / jpg | image/jpeg |
png | image/png |
svg | image/svg+xml |
tif / tiff | image/tiff |
ico | image/vnd.microsoft.icon |
webp | image/webp |
ics | text/calendar |
css | text/css |
csv | text/csv |
htm / html | text/html |
js / mjs | text/javascript |
md | text/markdown |
text / txt | text/plain |
3gp | video/3gpp |
3g2 | video/3gpp2 |
ts | video/mp2t |
mp4 | video/mp4 |
mpeg | video/mpeg |
ogv | video/ogg |
webm | video/webm |
avi | video/x-msvideo |
See also
send(): the auto-detection that runs when no type is set.file(): sets the type from the file's extension for you.
Chaining examples
The chainable helpers (status, type, headers, cache, cookies, download) return a Reply instance, so you can chain as many as needed before a terminal call (send, json, redirect, file), which returns the Response:
import {
status, headers, cookies, json,
} from '@server/next';
.post('/login', async (ctx) => {
const user = await authenticate(ctx.body);
if (!user)
return status(401).json({
error: 'Invalid credentials',
});
return status(200)
.cookies('session', {
value: user.sessionId, path: '/',
})
.json({ ok: true, user });
})