[]previous and next page

Prerendering and the ssr flag

Two literals in a route file configure the build and the handler.

For agentsUpdated

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:

src/routes/[lang]/_layout.tsxtsx
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>
  );
}
src/routes/[lang]/posts/[id].tsxtsx
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
vite.config.tsts
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.

src/routes/dashboard.tsxtsx
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>;
}
src/routes/reports.tsxtsx
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:

src/routes/posts/[id].tsxtsx
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 true or false; the scanner reads it without running the module. Any other value fails the build with ROUTE_DYNAMIC_PARAMS_INVALID, which names the file and the export.
  • false needs a prerender export 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. true is the default and is accepted anywhere a page is.
  • The list is the same prerender export 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/2 is 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's beforeLoad and loaders do not run for it. A page with ssr = false answers 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 a prerender function 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 sets false and the server's manifest has no prerenderRoutes entry for it, the handler refuses to start.
  • Static export. Only the listed pages are written. A link to an unlisted page that crawlLinks finds is not written (the server answered 404), and is reported to onError with explicit false. A path you list yourself in prerender.routes or entries that 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.