Routes are files
A file in src/routes is a page, a folder is a URL segment, and brackets make a segment a param.
Put a file in src/routes and it is a page. The folder path is the URL path, and index.tsx is the folder itself.
| File | URL |
|---|---|
routes/index.tsx |
/ |
routes/about.tsx |
/about |
routes/users/index.tsx |
/users |
routes/users/[id].tsx |
/users/:id |
routes/docs/[...rest].tsx |
/docs/a/b/c (one or more segments) |
routes/(marketing)/pricing.tsx |
/pricing (a group adds no segment) |
Enter a path or choose an example. The matcher shows the route file and the parameters that path supplies. A static segment takes priority over a parameter, and a parameter takes priority over a catch-all.
Show the code
import { useStore } from "@shadow-js/core";
import { Show } from "@shadow-js/web";
const FILES = [
"index.tsx",
"about.tsx",
"posts/new.tsx",
"posts/[id].tsx",
"docs/[...rest].tsx",
"(marketing)/pricing.tsx",
];
interface Hit {
readonly file: string;
readonly rank: number[];
readonly params: string[];
}
function segmentsOf(file: string): string[] {
return file
.replace(/\.tsx$/, "")
.split("/")
.filter((part) => part !== "index" && !/^\(.*\)$/.test(part));
}
function match(file: string, path: string[]): Hit | null {
const pattern = segmentsOf(file);
const rank: number[] = [];
const params: string[] = [];
for (let i = 0; i < pattern.length; i += 1) {
const part = pattern[i];
const seen = path[i];
if (part === undefined) return null;
if (part.startsWith("[...")) {
if (i !== pattern.length - 1 || path.length <= i) return null;
params.push(`${part.slice(4, -1)} = ${path.slice(i).join("/")}`);
rank.push(1);
return { file, rank, params };
}
if (seen === undefined) return null;
if (part.startsWith("[")) {
params.push(`${part.slice(1, -1)} = ${seen}`);
rank.push(2);
} else if (part === seen) {
rank.push(3);
} else {
return null;
}
}
return path.length === pattern.length ? { file, rank, params } : null;
}
function better(a: Hit, b: Hit): boolean {
for (let i = 0; i < Math.min(a.rank.length, b.rank.length); i += 1) {
const x = a.rank[i] ?? 0;
const y = b.rank[i] ?? 0;
if (x !== y) return x > y;
}
return false;
}
export function RouteMatcher() {
const [url, setUrl] = useStore("/posts/7");
const answer = (): Hit | null => {
const path = url().split("/").filter((part) => part !== "");
let best: Hit | null = null;
for (const file of FILES) {
const hit = match(file, path);
if (hit !== null && (best === null || better(hit, best))) best = hit;
}
return best;
};
return (
<div class="route-lab">
<label class="route-input">
URL path
<input aria-label="URL" value={url()} onInput={(event) => setUrl(event.currentTarget.value)} />
</label>
<div class="route-result" aria-live="polite">
<span class="label">Matched file</span>
<Show when={answer()} fallback={<p>No matching route. Dusk returns 404.</p>}>
{(hit) => (
<div>
<code>{`src/routes/${hit().file}`}</code>
<p>{hit().params.length > 0 ? `Parameters: ${hit().params.join(", ")}` : "No route parameters"}</p>
</div>
)}
</Show>
</div>
<fieldset class="route-examples">
<legend>Try a path</legend>
{["/", "/posts/new", "/posts/7", "/docs/start/here", "/pricing", "/missing"].map((path) => (
<button type="button" aria-pressed={url() === path} onClick={() => setUrl(path)}><code>{path}</code></button>
))}
</fieldset>
<p class="route-hint">Route matching model. The file conventions above are the routes available in this example.</p>
</div>
);
}
A page exports a component as its default. The params arrive through hooks, not props.
import { useParams } from "@shadow-js/router";
import type { JSX } from "@shadow-js/web";
export default function UserPage(): JSX.Element {
const params = useParams<"/users/:id">();
return <h1>User {params.id}</h1>;
}
Layouts wrap a folder#
A _layout.tsx wraps every page under its folder and renders the page where it places <Outlet />. Layouts nest: the one in routes/ wraps the one in routes/users/.
import { Outlet } from "@shadow-js/router";
import type { JSX } from "@shadow-js/web";
export default function UsersLayout(): JSX.Element {
return (
<section>
<nav>Users</nav>
<Outlet />
</section>
);
}
API routes are files too#
A file that exports GET, POST and the like, and no component, answers HTTP directly.
import { json } from "@shadow-js/dusk/server";
export const GET = () => json({ users: [] });
A name that starts with _, - or . is never a public page. Use that for private routes and for components that live beside a route.
Next: load the page's data. The complete list of file names and exports is in Routing.