Prerendering and the ssr flag
Two literals in a route file configure the build and the handler.
Two literals in a route file configure the build and the handler. The scanner reads them from the syntax tree without running the module, so they must be literals.
| Export | Effect |
|---|---|
export const prerender = true |
The build renders this path to dist/client/<path>/index.html. Only for a route without params; the build fails with a message otherwise |
export const prerender = ["/posts/1", "/posts/2"] |
The build renders those paths. Use it for dynamic routes |
export const prerender = async ({ params }) => [{ id: "1" }] |
A function that returns the params to render. It receives the params of the parent route (the layouts above it), and each returned object is merged onto them. See below |
export const ssr = false |
The server answers the empty document shell for this route: no beforeLoad, loader or component of it runs on the server, and the browser loads and renders the page. The build prerenders that shell for the path |
export const ssr = "data-only" |
The server runs beforeLoad and the loaders, answers their status and redirects, and sends the loader data in the document with an empty root; the browser renders the page from that data without loading it again |
dusk({ prerender: { routes: ["/extra"] } }) adds paths without a flag, and prerender: false turns the step off. Build-time
prerendering is described in Deploy.
Params from a function. A prerender function gives the param sets of its route. The build calls it once for each set its parent
produced, so a layout's function reaches every route below it, and a route's own function receives the params of the layout above it:
import { Outlet } from "@shadow-js/router";
import type { JSX } from "@shadow-js/web";
export const prerender = async (): Promise<{ lang: string }[]> => [{ lang: "en" }, { lang: "ar" }];
export default function Lang(): JSX.Element {
return (
<section>
<Outlet />
</section>
);
}
import type { JSX } from "@shadow-js/web";
export const prerender = async ({ params }: { params: { lang: string } }): Promise<{ id: string }[]> =>
params.lang === "en" ? [{ id: "1" }, { id: "2" }] : [{ id: "3" }];
export default function Post(): JSX.Element {
return <h1>Post</h1>;
}
A page with no function of its own is rendered for every set its layouts produce, and skipped for a set that leaves one of its params out. A function that returns something other than param objects, or leaves one of its own page's params out, fails the build and names the route, the file and the param.
Options. dusk({ prerender }) takes these, besides routes, origin, concurrency and failOnError:
| Option | Meaning |
|---|---|
entries |
More paths: a list, or a function that returns one (it may be async) |
crawlLinks |
true, or { depth, limit }: follow the internal links of the rendered pages. true is three hops and at most 500 added pages. Links to other origins, fragments, files (a last segment with a dot) and /_dusk are not followed |
filter |
(path) => boolean: a path (listed or found) is rendered only when it returns true |
retry |
Attempts after a page that threw or answered 5xx. Default 0, at most 5 |
onSuccess |
Called with { path, file, status } after each page is written |
onError |
Called with { path, error, explicit } for each page that did not render. explicit is false for a page the crawl found |
import { defineConfig } from "vite";
import { dusk } from "@shadow-js/dusk/vite";
export default defineConfig({
plugins: dusk({
prerender: {
entries: ["/sitemap-extra"],
crawlLinks: { depth: 2, limit: 200 },
filter: (path) => !path.startsWith("/admin"),
retry: 1,
onError: (failure) => {
console.warn(`prerender: ${failure.path} (${failure.explicit ? "listed" : "found"})`);
},
},
}),
});
A run that fails names every failed path on its own line, and the fix that applies to all of them. A crawled page that fails is
reported to onError and skipped, because the crawl found it rather than the app.
ssr is read from every route the URL matches, layouts included, and the most restrictive value wins: false, then
"data-only", then the default (render on the server). A layout with ssr = false therefore makes every page below
it client-rendered. HEAD requests get the same headers and no body. The shell is the document the build writes as
200.html: the default head tags, the stylesheets and the client entry, with an empty root element.
import type { JSX } from "@shadow-js/web";
export const ssr = false;
export const loader = (): never => {
throw new Error("runs in the browser only");
};
export default function Dashboard(): JSX.Element {
return <h1>Dashboard</h1>;
}
import { useLoaderData } from "@shadow-js/router";
import type { JSX } from "@shadow-js/web";
export const ssr = "data-only";
export const loader = (): { total: number } => ({ total: 3 });
export default function Reports(): JSX.Element {
const data = useLoaderData<{ total: number }>();
return <h1>{data()?.total} reports</h1>;
}
Because beforeLoad and the loaders of an ssr = false route do not run on the server, a guard written there protects
nothing on the server: the page shell is public, and the check happens in the browser. Keep authorization in server
functions, API routes and middleware, which always run on the server. A route whose beforeLoad redirects or whose
loader throws notFound() is still answered with that redirect or status when it is "data-only".
Params the page does not list (dynamicParams)#
By default a dynamic page renders for any param: the prerender export names what the build writes ahead, and every
other param is rendered when it is asked for. A page can close that door with a literal:
import type { JSX } from "@shadow-js/web";
export const prerender = ["/posts/1", "/posts/2"];
export const dynamicParams = false;
export default function Post(): JSX.Element {
return <h1>Post</h1>;
}
With dynamicParams = false, /posts/1 and /posts/2 render and /posts/3 is not found. Without the export, and with
dynamicParams = true, /posts/3 renders on request. The rules:
- The value must be the literal
trueorfalse; the scanner reads it without running the module. Any other value fails the build withROUTE_DYNAMIC_PARAMS_INVALID, which names the file and the export. falseneeds aprerenderexport on the same page: a page with nothing listed would answer not found for every param, so the scan refuses it. It also needs a path with params, and it belongs to a page, not to a layout, a template, an API route or a stream route.trueis the default and is accepted anywhere a page is.- The list is the same
prerenderexport the build uses: a list of paths, or a function that returns the params (a function receives the params of the layouts above the page, and those layouts' own functions feed it). A path is compared as a whole, on the decoded param values: with["/en/posts/1", "/ar/posts/2"],/en/posts/2is not found. - Server. A request for an unlisted param answers 404 and renders the app's own not-found UI, as
notFound()from a loader does. The page'sbeforeLoadand loaders do not run for it. A page withssr = falseanswers 404 too, not the empty shell. The server reads the list when the first request for such a page arrives and keeps it for the life of the process, so aprerenderfunction runs on the server as well as at build time, and what it reads (a database, an API) has to be reachable there. The development server reads it on every request. If a page setsfalseand the server's manifest has noprerenderRoutesentry for it, the handler refuses to start. - Static export. Only the listed pages are written. A link to an unlisted page that
crawlLinksfinds is not written (the server answered 404), and is reported toonErrorwithexplicitfalse. A path you list yourself inprerender.routesorentriesthat the page does not list fails the build with the 404. A static host answers its own 404 for an unlisted path, because no file exists for it. - Browser. The browser's router does not know the list, so a client-side navigation to an unlisted URL is not
refused there: the page loads in the browser as usual. A hard load, a refresh and a crawler all reach the server and
get the 404. If a client navigation must be refused too, have the page's loader throw
notFound()for an unknown param.