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 { 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-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 (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 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('/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');      // 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: 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

  • 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 → text/plain; charset=utf-8, markup included: use type('html') to send it as a page
  • Buffer / Uint8Array → sent as raw bytes
  • Blob → sent with its own type
  • ReadableStream (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, 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". 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 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. 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

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

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
heicimage/heic
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 });
})