[]previous and next page

Routes are files

A file in src/routes is a page, a folder is a URL segment, and brackets make a segment a param.

For agentsUpdated

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
RouteMatcher.tsxtsx
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.

src/routes/users/[id].tsxtsx
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/.

src/routes/users/_layout.tsxtsx
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.

src/routes/api/users.tsts
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.