[]previous and next page

The response from a component, a loader or middleware

useResponse sets the status, headers and cookies of a page's response from render, a loader or middleware, and what can still change after the first byte.

For agentsUpdated

Set response status, headers and cookies with useResponse(). It works during server rendering, in loaders and in middleware; in the browser or outside a request, its methods have no effect.

Start with Load data.

src/routes/account.tsxtsx
import { useResponse } from "@shadow-js/dusk";

export default function Account() {
  const response = useResponse();
  response.setHeader("cache-control", "private, no-store");
  response.setCookie("last-seen", "today", { path: "/", maxAge: 86400 });
  return <p>Your account</p>;
}

A loader sets the status before the page renders:

src/routes/posts/[id].tsxts
import { useResponse } from "@shadow-js/dusk";

export async function loader({ params }: { params: Record<string, string> }) {
  const post = await Promise.resolve(params["id"] === "1" ? { title: "First" } : undefined);
  if (post === undefined) {
    useResponse().setStatus(404);
    return { post: null };
  }
  return { post };
}

The calls#

  • setStatus(status): an integer from 200 to 599. A failure of the page (a loader that throws, an error boundary) keeps its own status, which wins over the one set here.
  • setHeader(name, value) replaces a header; appendHeader(name, value) adds one more value.
  • setCookie(name, value, options) and deleteCookie(name, options): the cookie attributes are the ones setCookie takes (path, maxAge, httpOnly, secure, sameSite). The cookie jar's defaults apply to the rest.
  • inRequest says whether the call reaches a response: false on the client and outside a request.

An invalid argument throws on every side (a RangeError for a status out of range, a TypeError for a header name or value that is not valid: a name with a space, a colon or a control character, a value with a CR, LF or NUL). Four headers are refused, with the call that does the job instead: set-cookie (use setCookie), location (use redirect()), and content-length and transfer-encoding (the framework sets them). setCookie checks the name, the path and the domain, percent-encodes the value and gives the cookie HttpOnly; Secure; SameSite=Lax, which a set-cookie header written by hand would not. event.responseHeaders is the raw Headers object behind these calls: a set-cookie appended to it directly skips those checks, so use event.cookies.

When a write counts#

A response's status and headers are decided before its first byte. The page's shell is the first byte of a streamed page, so a component that streams in later (a Suspense boundary after the shell) cannot change them. Such a write is ignored, and a development build logs a warning that names useResponse. Set the status in a loader, a middleware, or the first render.

The same rule holds for a page rendered without streaming: its status is decided when the markup is complete. A middleware that calls setHeader before it calls next() sets a header on the page.

Not built#

Declarative status components (HttpStatusCode, HttpHeader) are not part of Dusk: a status is decided before the page streams, so a component cannot set one afterward. Use useResponse().setStatus() in the first render, a loader, or HttpError and redirect() for a whole response.

Files and downloads: file()#

file(source, options) builds the answer for a file already in memory: a Uint8Array, an ArrayBuffer or a Blob. It sets Content-Type (application/octet-stream unless type says otherwise) and Content-Length. With filename it sets Content-Disposition: attachment: a plain ASCII name is quoted, and a non-ASCII name gets an ASCII fallback in filename and the RFC 5987 form in filename*. A name holding CR or LF throws a TypeError, because the header would end early. The name is a leaf, never a path: / and \ become _ (../../etc/passwd is saved as .._.._etc_passwd), controls (a tab, NUL, DEL) become _, a lone surrogate becomes U+FFFD, and an empty name is download. A % makes the name take the two-parameter form with _ in the ASCII fallback, because some clients percent-decode the plain parameter.

Pass the request as request and the answer follows its conditions. A single Range: bytes= gives 206 with Content-Range (an If-Range that does not match sends the whole file). A range outside the file gives 416 with Content-Range: bytes */size. Several ranges get the whole file. etag and lastModified set the validators: an If-None-Match that matches, or an If-Modified-Since at or after lastModified, gives 304 with no body. cacheControl is sent as given. A HEAD request gets the headers and no body.

src/routes/report.tsts
import { file, type RequestEvent } from "@shadow-js/dusk/server";

const loadReport = async (): Promise<Uint8Array<ArrayBuffer>> => new Uint8Array(0);

export const GET = async (event: RequestEvent) =>
  file(await loadReport(), {
    request: event.request,
    filename: "report 2026.pdf",
    type: "application/pdf",
    etag: '"report-1"',
    cacheControl: "private, max-age=60",
  });

file() takes bytes or a Blob, not a path: the server code runs on every platform, so it does not read the disk. On Node, files in public/ are already served with these Range and validator rules by the static server. Other files are read with the runtime's own API and passed in.

Entity tags and conditional requests#

A response's ETag decides a 304 Not Modified for a GET or HEAD whose If-None-Match matches it. Three cases:

  • A route's own ETag. The route sets the header and the framework answers 304 to a matching If-None-Match. A route that must send the full answer every time calls event.etag(false): it keeps its own header and gets no 304.
  • A buffered page. A GET answer of a page that the framework renders as one string (a page in mode: "string" or "async", or a data-only document) gets a weak ETag computed from its body (the first 16 bytes of the SHA-256, as W/"…"), when its cache-control is not no-store. cache: { etag: false } turns that off. A streamed page (the default mode: "stream") gets none: its body is not read before it is sent, so the framework does not hash it. Set your own ETag on such a page, or use mode: "string". Files the static file server sends (public/, the prerendered HTML it serves from disk) carry their own validators from the file server and are not affected by this.
  • An API answer. It gets no generated ETag by default, even when it is cacheable. event.etag(true) gives it one. The framework reads the body to compute the tag, so this is for answers of a size you accept to hold in memory.

Next.js sets an ETag on every buffered page payload and on no route handler; Dusk follows the same split, and additionally skips an answer that is no-store, which a cache may not keep.

ts
export const GET = apiRoute({
  GET: (event) => {
    event.etag(true); // a weak ETag over the body; a matching If-None-Match gets 304
    return json(catalog(), { headers: { "cache-control": "public, max-age=60" } });
  },
});

Request bodies#

A route reads its body with event.rawBody(), readJson, readTextLimited, readFormLimited or the Request readers. Their limits, charsets, Content-Encoding rules and the second-read error are in Request body limits.