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 { 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
.get('/text', () => 'Hello world')
// Object or array → application/json
.get('/json', () => ({ hello: 'world' }))
// HTTP status code shorthand (empty body)
.post('/create', () => 201)
// A number as the body itself, rather than a status
.get('/answer', () => json(42))
// A bare chainable helper is finalized for you (empty body)
.get('/gone', () => status(410))
.get('/', () => type('html').send('<h1>Hello</h1>'))
// 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 (quoted and escaped, with an RFC 5987 filename* added for non-ASCII names), 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.url.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('/data/x') looks for x under 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('/data/x'); // data/x inside the bucket
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: json(42) sends 42. It deliberately does not accept JSX, that's HTML, so return it directly or through send().
import { 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:
string→text/plain; charset=utf-8, markup included: usetype('html')to send it as a pageBuffer/Uint8Array→ sent as raw bytesBlob→ sent with its own typeReadableStream(or a Node stream) → streamed through- a generator, or any iterable that isn't an array → streamed as it yields
- 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". To return a number as the body, use json(42). 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";
// A string is text until you say otherwise
.get('/feed', () => type('xml').send('<rss>...</rss>'))
.get('/page', () => type('html').send('<h1>Hello</h1>'))
// 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);
})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. A returned number is always a status code from 200 to 599, sent with an empty body, so to send a number as the body, return json(42).
See also
json()andsend(): the terminal helpers a status is usually chained onto.- Inline replies: the values a route can return.
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 objects or files, since returning them (or using file()) sets the type already. Reach for it for any string that isn't plain text, and when sending a Buffer or a stream, whose type can't be guessed:
// Markup, which a returned string is never assumed to be
.get('/page', () => type('html').send(render(data)))
// XML
.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 |
heic | image/heic |
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 });
})