Reply

Middleware can return different types of values to send a response. For simple cases you can return a value directly; for more control, Server.js exports reply helpers that can be used standalone or chained together:

  • status: set the response's HTTP status code, like 201 or 404.
  • json: stringify the objects and send them as Content-Type: application/json.
  • redirect: send a 302 (or custom) redirect through the Location header.
  • headers: set one or many response headers at once.
  • type: set the Content-Type from a MIME type or a file extension like csv.
  • 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.
  • send: send any body, auto-detecting text, HTML, JSON, a Buffer or a stream.
  • file: stream a file from disk, guessing its Content-Type, 404 if missing.
  • download: send a file as an attachment so the browser downloads it.
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
// (or text/html if it starts with '<')
.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 }))

status()

Set the HTTP status code. It's chainable, so pair it with a terminal helper like .json() or .send(), or return it on its own for an empty-body response:

import { status, json } from '@server/next';

// Just a status code, empty body
.delete('/users/:id', () => status(204))

// With a JSON body
.post('/users', (ctx) => {
  const user = createUser(ctx.body);
  return status(201).json(user);
})

Reach for status() when you need a body or other helpers; for a bare status code, return 201 is the shorthand.

json()

Stringify a value and send it as Content-Type: application/json. It's a terminal helper, returning the Response.

import { status, json } from '@server/next';

.get('/users', () => json({ users: [] }))

// With a status code
.post('/users', (ctx) => {
  return status(201).json({ id: 1, ...ctx.body });
})

Returning a plain object also sends JSON; use json() explicitly when you need to chain status() or headers().

redirect()

Redirect the client to another URL. The helper sends a 302 with the Location header; chain status() before it for a different code, like 301, 307 or 308. It's terminal, returning the Response.

import { redirect, status } from '@server/next';

// 302 by default
.get('/old-path', () => redirect('/new-path'))

// Permanent redirect
.get('/moved', () => {
  return status(301).redirect('/permanent-path');
})

headers()

Set one or more response headers. Pass a key and value, or an object to set several at once. It's chainable, so finish with a terminal helper like .send().

import { headers } from '@server/next';

// A single header
.get('/', () => headers('x-custom', 'value').send('Hello'))

// Several at once
.get('/', () => {
  return headers({ 'x-foo': 'bar', 'x-baz': 'qux' })
    .send('Hello');
})

headers() appends rather than replacing, and a value can be an array to send the same header more than once.

type()

Set the Content-Type header. Accepts a file extension (csv, .csv) or a full MIME type (text/csv). It's chainable.

import { type } from '@server/next';

// By extension
.get('/data.csv', () => type('csv').send('a,b,c'))

// By full MIME type
.get('/data', () => {
  return type('application/json').send('{"a":1}');
})

Extensions are looked up in the built-in MIME table (the leading dot is optional); an unknown value is used as the content type verbatim. The full table:

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

cache()

Set the Cache-Control header. Accepts a duration like '1h', a number of seconds, or false/0 for no-store. It sets public, max-age=<seconds> and is chainable. For anything more specific (private, s-maxage, ...), set the header directly with headers().

import { cache, headers } from '@server/next';

// Cache-Control: public, max-age=3600
.get('/posts', () => cache('1h').json(posts))

// Full control: set the header yourself
.get('/me', (ctx) => headers('cache-control', 'private, max-age=300').json(ctx.user))

Use this when the value depends on the request; for a fixed default across routes, set the cache option instead. A Cache-Control set here always wins over that default.

cookies()

Set or delete response cookies. Pass a string value, an options object (value, path, expires, and the rest), null to delete, or an object of names to set several. It's chainable.

import { cookies } from '@server/next';

// Simple string value
.post('/login', () => cookies('token', 'abc123').send())

// With options
.post('/login', () => {
  return cookies('token', {
    value: 'abc123',
    path: '/',
    expires: '7d',
  }).send();
})

Delete a cookie by setting it to null, or pass an object to set several at once:

// Delete a cookie
.post('/logout', () => cookies('token', null).send())

// Several at once
.get('/', () => {
  return cookies({ theme: 'dark', lang: 'en' }).send();
})

expires accepts a duration like '7d' or a Date; deleting sets an already-expired cookie so the browser drops it.

send()

The base terminal method that every chain ends on. Sends the response with an optional body, or an empty one when called with no arguments.

import { send, status } from '@server/next';

.get('/ping', () => send('pong'))
.delete('/item', () => status(204).send())

Unless you've already set the Content-Type (via type() or headers()), send() auto-detects it from the body:

  • string starting with <text/html
  • other stringtext/plain
  • Buffer → sent as-is
  • ReadableStream → streamed
  • anything else → application/json

Set the type yourself to override this, e.g. to send XML or raw bytes:

import { type } from '@server/next';

// XML string
.get('/feed', () => type('xml').send('<rss>…</rss>'))

// A Buffer (set the type; send() won't guess it)
.get('/report.csv', () => type('csv').send(csvBuffer))

// A ReadableStream is streamed through
.get('/proxy', () => 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().

file()

Stream a file, from a disk path or a Bucket file handle. A missing file responds with 404.

import { file } from '@server/next';

// A disk path
.get('/', () => file('./index.html'))
.get('/report', () => file('./report.pdf'))

// A stored bucket file (e.g. serving an upload behind auth)
.get('/avatar', () => file(bucket.file('avatars/me.jpg')))

file() is async and streams the contents rather than buffering, so it's fine for large files. Return it directly, or chain it after download() to force a download. To serve a stored file behind auth, see Serving stored files.

download()

Send a file as an attachment, so the browser downloads it instead of rendering it (Content-Disposition: attachment). Pass a filename to name the download, then chain .file() for the contents.

import { download } from '@server/next';

// Named download
.get('/export', () => {
  return download('export.csv')
    .file('./data/export.csv');
})

// Without a filename
.get('/raw', () => {
  return download().file('./data/export.csv');
})

When the filename has an extension and no Content-Type is set yet, download() also sets the type from that extension.

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 });
})