Raw Response
Sometimes a route shouldn't render JSX at all. Sitemaps, RSS feeds, robots.txt, JSON
endpoints, file downloads — they need a specific content type and a body the framework
shouldn't wrap in HTML.
A loader returns { rawResponse } and the entire render pipeline is skipped. The view's
index.tsx is not executed. Cache headers from the same LoaderResult still apply.
// src/views/sitemap/loader.ts
import type { LoaderFunction, LoaderResult } from '@withl5e/l5e/entry-server';
export const loader: LoaderFunction = async (): Promise<LoaderResult> => {
const urls = await db.pages.findMany({ where: { indexed: true } });
const xml = renderSitemapXml(urls);
return {
rawResponse: {
body: xml,
contentType: 'application/xml',
statusCode: 200,
},
sMaxAge: 3600,
swr: 86400,
cacheTags: ['sitemap'],
};
};Shape
interface RawResponse {
body: string | Buffer;
contentType: string;
statusCode?: number; // default 200
headers?: Record<string, string>; // extra headers (added after framework defaults)
}bodycan be a string (XML/JSON/text) or aBuffer(PDFs, images, anything binary).contentTypeis required — it sets the responseContent-Type. Get it wrong and browsers will sniff or refuse.statusCodedefaults to200. Use404for "no robots.txt configured",503for maintenance pages, etc.headersis for response-specific extras. The framework still setsCache-Control/Cache-Tagfrom your loader; don't duplicate them here.
Practical patterns
robots.txt
// src/views/robots/loader.ts
export const loader: LoaderFunction = async () => {
const body = `User-agent: *
Allow: /
Sitemap: ${process.env.PUBLIC_URL}/sitemap.xml`;
return {
rawResponse: { body, contentType: 'text/plain', statusCode: 200 },
sMaxAge: 3600,
cacheTags: ['robots'],
};
};sitemap.xml
Build the XML with whatever library you like (sitemap, xmlbuilder2, or hand-roll).
import { SitemapStream, streamToPromise } from 'sitemap';
import { Readable } from 'node:stream';
const stream = new SitemapStream({ hostname: process.env.PUBLIC_URL });
const data = await streamToPromise(Readable.from(entries).pipe(stream));
return {
rawResponse: { body: data.toString(), contentType: 'application/xml' },
sMaxAge: 3600,
swr: 86400,
};RSS / Atom feed
import { Feed } from 'feed';
const feed = new Feed({ /* … */ });
posts.forEach((p) => feed.addItem({ title: p.title, link: p.url, date: p.publishedAt }));
return {
rawResponse: { body: feed.rss2(), contentType: 'application/rss+xml' },
sMaxAge: 600,
};JSON endpoint
return {
rawResponse: {
body: JSON.stringify({ items, total }),
contentType: 'application/json',
},
sMaxAge: 60,
swr: 300,
};For mutating endpoints, prefer [[16-swap-and-action]] (defineAction) — actions are aware of
request bodies, methods, and the swap protocol.
File download
const pdf = await renderInvoicePdf(invoiceId); // returns Buffer
return {
rawResponse: {
body: pdf,
contentType: 'application/pdf',
headers: {
'Content-Disposition': `attachment; filename="invoice-${invoiceId}.pdf"`,
},
},
};Pair with the global loader
Endpoints that emit XML/JSON usually don't want site chrome metadata or layout data attached. Skip the global loader for them:
// src/global-loader.ts
export function shouldIgnore(viewName: string): boolean {
return ['robots', 'sitemap', 'feed', 'api-items'].includes(viewName);
}That keeps the global loader fast for HTML pages and stops it from doing pointless work for machine-readable endpoints.
When to choose rawResponse vs. rawHtml
| You want… | Use |
|---|---|
| Non-HTML body (XML, JSON, text, binary) | rawResponse |
HTML but without the index.html shell |
rawHtml: true (loader still returns props, view still renders) |
| HTML inside the normal shell | regular view + loader |
rawHtml is rare. Reach for it only when you need full control over the document — e.g.
generating an oembed HTML snippet. Most teams reach for rawResponse first.
Edge cases
- Don't set
Content-Typeinheaders— it'll fightcontentType. Set it viacontentTypeonly. - Don't return a
Promise<Buffer>directly. Resolve it first and put theBufferinbody. - Caching applies the same way. A 404
rawResponsestill inherits the loader's cache headers — setmaxAge: 0, sMaxAge: 0if you don't want negative responses cached.