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.
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.
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:
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)anddeleteCookie(name, options): the cookie attributes are the onessetCookietakes (path,maxAge,httpOnly,secure,sameSite). The cookie jar's defaults apply to the rest.inRequestsays whether the call reaches a response:falseon 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.
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 matchingIf-None-Match. A route that must send the full answer every time callsevent.etag(false): it keeps its own header and gets no 304. - A buffered page. A
GETanswer of a page that the framework renders as one string (a page inmode: "string"or"async", or a data-only document) gets a weak ETag computed from its body (the first 16 bytes of the SHA-256, asW/"…"), when itscache-controlis notno-store.cache: { etag: false }turns that off. A streamed page (the defaultmode: "stream") gets none: its body is not read before it is sent, so the framework does not hash it. Set your ownETagon such a page, or usemode: "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.
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.