[]previous and next page

Layouts and pages

A folder with layout.tsx and page.tsx becomes _layout.tsx plus index.tsx.

For agentsUpdated

A folder with layout.tsx and page.tsx becomes _layout.tsx plus index.tsx. The layout renders <Outlet /> where {children} went and stays mounted while the child changes.

jsx
// app/layout.tsx (Next.js)
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <header><Link href="/">Home</Link></header>
        <main>{children}</main>
      </body>
    </html>
  );
}
src/routes/_layout.tsxtsx
import { A, Outlet } from "@shadow-js/router";
import type { JSX } from "@shadow-js/web";

export default function RootLayout(): JSX.Element {
  return (
    <>
      <header>
        <A to="/" end activeClass="active">
          Home
        </A>
        <A to="/posts" activeClass="active">
          Posts
        </A>
      </header>
      <main>
        <Outlet />
      </main>
    </>
  );
}
src/routes/index.tsxtsx
import type { HeadTag, JSX } from "@shadow-js/web";

export const head = (): HeadTag[] => [{ tag: "title", children: "Blog" }];

export default function Home(): JSX.Element {
  return <h1>Blog</h1>;
}
src/routes/posts/index.tsxtsx
import { A, useLoaderData } from "@shadow-js/router";
import { For, type HeadTag, type JSX } from "@shadow-js/web";
import { listPosts, type Post } from "../../server/blog";

export const loader = (): Promise<Post[]> => listPosts(undefined);

export const head = (): HeadTag[] => [{ tag: "title", children: "Posts | Blog" }];

export default function Posts(): JSX.Element {
  const posts = useLoaderData<Post[]>();
  return (
    <ul>
      <For each={posts() ?? []}>
        {(post) => (
          <li>
            <A to="/posts/:id" params={{ id: post.id }}>
              {post.title}
            </A>
          </li>
        )}
      </For>
    </ul>
  );
}

Route patterns#

The folder and file names carry over with a different bracket spelling for the optional catch-all. The block runs the real file-name grammar over each Next.js path:

Next.js Dusk Params
app/posts/[id]/page.tsx routes/posts/[id].tsx { id }
app/docs/[...slug]/page.tsx routes/docs/[...slug].tsx { slug: "a/b" }
app/docs/[[...slug]]/page.tsx routes/docs/[[...slug]].tsx (the optional catch-all also matches /docs, where slug is absent) { slug: undefined }
app/(marketing)/about/page.tsx routes/(marketing)/about.tsx none
app/page.tsx in a folder index.tsx in that folder none
app/robots.ts, app/sitemap.ts routes/robots[.]txt.ts, routes/sitemap[.]xml.ts as API routes, or a file in public/ none
_components/ (private folder) -components/ (a name that starts with - is ignored) none
patterns.tsts
import { parseRouteFile, patternOf } from "@shadow-js/router/vite";

for (const file of [
  "posts/[id].tsx",
  "docs/[...slug].tsx",
  "(marketing)/about.tsx",
  "sitemap[.]xml.ts",
  "-components/card.tsx",
  "docs/[[...slug]].tsx",
]) {
  const parsed = parseRouteFile(file);
  if (parsed.status === "ignored") console.log(`${file} -> ignored`);
  else if (parsed.status === "invalid") console.log(`${file} -> ${parsed.code}`);
  else {
    const segments = parsed.leaf === undefined ? parsed.dirs : [...parsed.dirs, parsed.leaf];
    console.log(`${file} -> ${patternOf(segments)}`);
  }
}
Expected output
posts/[id].tsx -> /posts/:id
docs/[...slug].tsx -> /docs/*slug
(marketing)/about.tsx -> /about
sitemap[.]xml.ts -> /sitemap.xml
-components/card.tsx -> ignored
docs/[[...slug]].tsx -> /docs/*slug?

Dynamic params are plain strings and always present for a required segment; there is no await params. Read them in a loader (params.id) or in a component with useParams().

generateStaticParams becomes export const prerender, and export const dynamicParams = false carries over as the same literal: a param the list omits is not found. Next's default is true, and so is Dusk's. Dusk refuses false at build time when the page has no prerender export, and any value other than the literal true or false.

Pending, error and not found#

Next.js Dusk Behavior
loading.tsx export const pending Shown after pendingMs (1000 ms) while a client navigation waits for a loader, for at least 500 ms. The server never shows it: it waits for the loaders and sends the finished page
error.tsx export const error Replaces the route when its loader or component throws. Receives { error, reset }; resets on navigation
not-found.tsx, notFound() export const notFound and throw notFound() Rendered in place of the route that exports it, inside the layouts above it, with status 404
global-error.tsx none Add an error export to the route or to a layout above it
URL that matches nothing A catch-all [...rest].tsx that throws notFound() Without it the built-in 404 document is sent

On the server the status follows what was thrown: notFound() is 404, an HttpError(403) is 403, any other error is 500 and its message is replaced by Internal Server Error in production (the real error goes to onError once).

src/routes/[...rest].tsxtsx
import { notFound as signalNotFound } from "@shadow-js/router";
import type { JSX } from "@shadow-js/web";

export const beforeLoad = (): never => {
  throw signalNotFound();
};

export const notFound = (): JSX.Element => <p class="missing">Nothing lives here.</p>;

Search params#

searchParams becomes validateSearch, which turns the parsed query into the value you want and may throw to fail the navigation, and useSearch(), which returns a view with one memo per key: a component that reads ?tab does not re-run when ?page changes. In the browser, change the query with navigate(path, { search: { page: 2 } }); a key set to undefined is removed. On the server a validateSearch that throws an HttpError(400) answers 400.

src/routes/search.tsxtsx
import { type LoaderArgs, useSearch } from "@shadow-js/router";
import { HttpError } from "@shadow-js/dusk";
import type { JSX } from "@shadow-js/web";

interface Search {
  q: string;
  page: number;
}

export const validateSearch = (search: Record<string, unknown>): Search => {
  const page = Number(search["page"] ?? 1);
  if (!Number.isInteger(page) || page < 1) throw new HttpError(400, "page must be 1 or more");
  return { q: String(search["q"] ?? ""), page };
};

export const loader = ({ search }: LoaderArgs<"/search", Search>): Search => search;

export default function SearchPage(): JSX.Element {
  const search = useSearch<Search>();
  return (
    <p>
      q={search.q} page={search.page}
    </p>
  );
}

src/entry-server.ts holds the options of the request handler. The only one needed so far is the list of host names the server answers for; without it Dusk sends one warning on the first production request, because the Host header is attacker input.

src/entry-server.tsts
import type { HandlerOptions } from "@shadow-js/dusk/server";

const options: Omit<HandlerOptions, "manifest"> = {
  security: { allowedHosts: ["localhost", "blog.example.com"] },
};

export default options;