Client pages with TanStack Router¶
This starter kit uses TanStack Router with file-based routing. Files in apps/client/src/routes/ become URLs, and the generated route tree provides typed navigation, parameters, search values, and loader data.
Project structure¶
apps/client/src/
routes/
__root.tsx # Root layout and 404 component
index.tsx # /
widgets.tsx # /widgets
widgets.$id.tsx # /widgets/:id
main.tsx # Mounts RouterProvider
router.tsx # Creates and registers the router
routeTree.gen.ts # Generated route tree, do not edit
Add widget pages¶
Create a page¶
Create a file in apps/client/src/routes/ and export a constant named Route:
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/widgets")({
component: WidgetsPage,
});
function WidgetsPage() {
return (
<main className="mx-auto max-w-2xl px-4 py-12">
<h1 className="text-2xl font-semibold">Widgets</h1>
</main>
);
}
Generated route types¶
TanStack Router generates apps/client/src/routeTree.gen.ts during development, builds, and type checking. Commit this file, but never edit it manually.
Regenerate it explicitly when needed:
Link between pages¶
Use Link for internal navigation:
TypeScript checks the destination and any required parameters against the generated route tree.
Dynamic paths¶
Create widgets.$id.tsx for /widgets/:id:
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/widgets/$id")({
component: WidgetPage,
});
function WidgetPage() {
const { id } = Route.useParams();
return <main>Widget {id}</main>;
}
Link to it with typed parameters:
Path parameters are strings in the browser. Validate or coerce them again when they reach the API.
Load and filter data¶
Load API data¶
Use a route loader when the page needs data before rendering:
import type { WidgetListResponse } from "@mern/shared";
import { createFileRoute } from "@tanstack/react-router";
import axios from "axios";
export const Route = createFileRoute("/widgets")({
loader: async () => {
const { data } = await axios.get<WidgetListResponse>("/api/widgets");
return data;
},
pendingComponent: () => <p role="status">Loading widgets...</p>,
errorComponent: () => (
<p role="alert">Unable to load widgets. Please try again.</p>
),
component: WidgetsPage,
});
function WidgetsPage() {
const { widgets } = Route.useLoaderData();
return (
<main>
<h1>Widgets</h1>
{widgets.length === 0 ? (
<p>No widgets yet.</p>
) : (
<ul>
{widgets.map((widget) => (
<li key={widget.id}>{widget.name}</li>
))}
</ul>
)}
</main>
);
}
This starter is a client-rendered Vite application. Loaders run in the browser during navigation. Requests to /api use Vite's same-origin proxy in development, so session cookies are included without a separate API origin or CORS setup.
Search parameters¶
Use validateSearch to give query-string values a stable type and default:
export const Route = createFileRoute("/widgets")({
validateSearch: (search: Record<string, unknown>) => {
const page = Number(search.page);
return { page: Number.isInteger(page) && page > 0 ? page : 1 };
},
component: WidgetsPage,
});
function WidgetsPage() {
const { page } = Route.useSearch();
return <p>Page {page}</p>;
}
Create typed links by supplying the search object:
Client validation improves navigation, but the server must still validate its own query parameters because callers can bypass the UI.
Root layout and 404 page¶
routes/__root.tsx renders the shared layout through <Outlet /> and supplies notFoundComponent. Providers, navigation, or a footer added to the root route also wrap every child page and the 404 page.
See 404 page before changing the fallback.
Verify¶
Run the client, open http://localhost:3000/widgets, and check its loading, empty, success, and error states. Then run:
Next step¶
Continue to Emails when a feature needs to notify someone.