Layouts and pages
A folder with layout.tsx and page.tsx becomes _layout.tsx plus index.tsx.
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.
// 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>
);
}
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>
</>
);
}
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>;
}
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 |
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)}`);
}
}
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).
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.
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.
import type { HandlerOptions } from "@shadow-js/dusk/server";
const options: Omit<HandlerOptions, "manifest"> = {
security: { allowedHosts: ["localhost", "blog.example.com"] },
};
export default options;