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:

HelperDescription
cacheSet Cache-Control from a duration like '1h' or a number (seconds).
cookiesSet or delete response cookies, with options like path and expires.
downloadSend a file as an attachment so the browser downloads it.
fileStream a file from disk, guessing its Content-Type, 404 if missing.
headersSet one or many response headers at once.
jsonStringify objects and send them as Content-Type: application/json.
redirectSend a 302 redirect through the Location header.
sendSend any body, auto-detecting text, HTML, JSON, a Buffer or a stream.
statusSet the response's HTTP status code, like 201 or 404.
typeSet 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-store

It'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

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 once

It'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:

OptionTypeDescription
valuestring | nullThe cookie value; null deletes the cookie
pathstringURL scope, defaults to /
expiresstring | number | DateA duration like '7d', milliseconds from now, or a Date
maxAgenumberLifetime in seconds (Max-Age)
httpOnlybooleanHide it from JavaScript (document.cookie)
securebooleanOnly 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 content

It'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 file

It'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');      // throws

Route params that climb or look absolute are rejected before any of this, by traversalProtection.

See also

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 header

It'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 for Content-Type.
  • cache(): the dedicated helper for Cache-Control.
  • cookies(): builds the Set-Cookie header for you, with options.
  • onResponse option: 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 status

It'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

  • status(): pick the redirect code.
  • cookies(): commonly set right before redirecting after a login.

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 helpers

send(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 starting with a tag, like <p> or <!doctypetext/html; charset=utf-8
  • other stringtext/plain; charset=utf-8
  • Buffer / Uint8Array → sent as raw bytes
  • ReadableStream (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, 404 when missing
  • a promise → awaited first, so send(fetch(url)) needs no await
  • 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 body

It'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() and send(): 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 type

It'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:

NameContent-Type
epubapplication/epub+zip
gzapplication/gzip
jarapplication/java-archive
jsonapplication/json
jsonldapplication/ld+json
docapplication/msword
binapplication/octet-stream
ogxapplication/ogg
pdfapplication/pdf
rtfapplication/rtf
azwapplication/vnd.amazon.ebook
mpkgapplication/vnd.apple.installer+xml
xulapplication/vnd.mozilla.xul+xml
xlsapplication/vnd.ms-excel
eotapplication/vnd.ms-fontobject
pptapplication/vnd.ms-powerpoint
odpapplication/vnd.oasis.opendocument.presentation
odsapplication/vnd.oasis.opendocument.spreadsheet
odtapplication/vnd.oasis.opendocument.text
pptxapplication/vnd.openxmlformats-officedocument.presentationml.presentation
xlsxapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheet
docxapplication/vnd.openxmlformats-officedocument.wordprocessingml.document
rarapplication/vnd.rar
vsdapplication/vnd.visio
7zapplication/x-7z-compressed
abwapplication/x-abiword
bzapplication/x-bzip
bz2application/x-bzip2
cdaapplication/x-cdf
cshapplication/x-csh
arcapplication/x-freearc
phpapplication/x-httpd-php
shapplication/x-sh
tarapplication/x-tar
xhtmlapplication/xhtml+xml
xmlapplication/xml
zipapplication/zip
aacaudio/aac
mid / midiaudio/midi
mp3audio/mpeg
ogaaudio/ogg
opusaudio/opus
wavaudio/wav
webaaudio/webm
otffont/otf
ttffont/ttf
wofffont/woff
woff2font/woff2
avifimage/avif
bmpimage/bmp
gifimage/gif
jpeg / jpgimage/jpeg
pngimage/png
svgimage/svg+xml
tif / tiffimage/tiff
icoimage/vnd.microsoft.icon
webpimage/webp
icstext/calendar
csstext/css
csvtext/csv
htm / htmltext/html
js / mjstext/javascript
mdtext/markdown
text / txttext/plain
3gpvideo/3gpp
3g2video/3gpp2
tsvideo/mp2t
mp4video/mp4
mpegvideo/mpeg
ogvvideo/ogg
webmvideo/webm
avivideo/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 });
})