Nitro V3 + TanStack Router + React
Nitro V3 can run as a Vite plugin, which makes it a useful server layer for a React application without requiring a full-stack React framework.
I use this setup in Sheet. It is a client-rendered React app rather than a TanStack Start or server-rendered application:
- Vite builds the frontend.
- TanStack Router owns client-side routing and navigation.
- Nitro serves the app, runs middleware, and exposes the API.
- SWR manages client-side server state after a route has loaded.
That separation keeps the project simple. The frontend and backend share one Vite project and one TypeScript setup, but neither side has to pretend to be the other.
Creating the project
Start with a Nitro project and choose the Full Stack With Vite template:
bunx create-nitro-app
Then add React, TanStack Router, and SWR:
bun add react react-dom @tanstack/react-router swr
bun add -D @tanstack/router-plugin @types/react @types/react-dom @vitejs/plugin-react
My application code is organized like this:
src/
main.tsx
router.tsx
routeTree.gen.ts
pages/
__root.tsx
_app/
route.tsx
sheet/
index.tsx
$sheetId.tsx
server/
middleware/
auth.ts
api/
sheets.get.ts
sheets.post.ts
sheets/
[id].get.ts
src/pages belongs to TanStack Router. src/server belongs to Nitro. Files in src/server/api automatically become /api routes, while Nitro middleware can protect the whole API surface in one place.
Vite configuration
The important part of vite.config.ts is that TanStack Router runs before React, and Nitro is spread into the plugin list:
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import { nitro } from "nitro/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
tanstackRouter({
target: "react",
autoCodeSplitting: true,
routesDirectory: "./src/pages",
generatedRouteTree: "./src/routeTree.gen.ts",
}),
react(),
...nitro({
serverDir: "./src/server",
}),
],
resolve: {
tsconfigPaths: true,
},
});
The router plugin watches src/pages and generates src/routeTree.gen.ts. autoCodeSplitting keeps page code out of the initial bundle until its route is needed.
Nitro uses src/server as its server directory. A file such as src/server/api/sheets.get.ts becomes GET /api/sheets, and src/server/api/sheets/[id].get.ts becomes GET /api/sheets/:id.
TypeScript configuration
Nitro provides the base TypeScript configuration. I extend it, enable the React JSX transform, and keep one alias for code shared between the browser and server:
{
"extends": ["nitro/tsconfig"],
"compilerOptions": {
"jsx": "react-jsx",
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client"]
}
}
That lets both route components and Nitro handlers import shared types with paths such as @/lib/sheet.ts.
Router setup
The generated route tree is passed to createRouter in src/router.tsx:
import { createRouter } from "@tanstack/react-router";
import { routeTree } from "@/routeTree.gen.ts";
export const router = createRouter({
routeTree,
defaultStaleTime: 5_000,
scrollRestoration: true,
});
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
The module declaration registers the router type globally. That gives route links, params, search values, and navigation calls their generated types.
The application entry only needs to render RouterProvider:
import ReactDOM from "react-dom/client";
import { RouterProvider } from "@tanstack/react-router";
import { router } from "@/router.tsx";
import "./index.css";
const rootElement = document.getElementById("app");
if (!rootElement) {
throw new Error("Missing #app root element");
}
if (!rootElement.innerHTML) {
ReactDOM.createRoot(rootElement).render(
<RouterProvider router={router} />,
);
}
Sheet also passes the current authentication state through the router context. A pathless _app/route.tsx layout checks that context in beforeLoad, redirects signed-out users, and renders the authenticated application shell around its child routes.
export const Route = createFileRoute("/_app")({
beforeLoad: ({ context, location }) => {
if (!context.auth.isAuthenticated) {
throw redirect({
to: "/auth/login",
search: { redirect: location.href },
});
}
},
component: AppLayoutRoute,
});
The _app directory organizes protected pages without adding _app to the URL.
Nitro API routes
Nitro handlers live beside the app in src/server. A GET handler can return an object directly and Nitro serializes it as JSON:
import { defineHandler } from "nitro";
import { prisma } from "@/server/lib/db.ts";
export default defineHandler(async (event) => {
const user = event.context.user;
const sheets = await prisma.sheet.findMany({
where: {
ownerId: user.id,
},
orderBy: {
createdAt: "desc",
},
});
return { sheets };
});
I use a Nitro middleware to load the session once for /api/**, reject unauthenticated requests, and attach the user to event.context. Individual handlers can then focus on authorization for their resource instead of fetching the session again.
Nitro V3 uses the native request APIs. Request data comes from event.req, the pathname and search params come from event.url, and errors can be thrown with HTTPError.
import { defineHandler, HTTPError } from "nitro";
export default defineHandler(async (event) => {
const body = await event.req.json();
const id = event.url.pathname.split("/").pop();
if (!id) {
throw HTTPError.status(400, "Sheet ID is required.");
}
return { body, id };
});
Data fetching
I layer SWR over TanStack Router’s initial loader data.
The route loader owns the first request. It starts before the page renders, participates in navigation state, and receives an AbortSignal from the router. If the user navigates away, the in-flight request can be cancelled.
import { createFileRoute } from "@tanstack/react-router";
import useSWR from "swr";
import { fetchJson } from "@/lib/fetch-json.ts";
import type { SheetsResponse } from "@/lib/sheet.ts";
const sheetsPath = "/api/sheets";
async function fetchSheets(path: string, signal?: AbortSignal) {
return await fetchJson<SheetsResponse>(path, { signal });
}
export const Route = createFileRoute("/_app/sheet/")({
loader: ({ abortController }) =>
fetchSheets(sheetsPath, abortController.signal),
component: SheetsPage,
});
function SheetsPage() {
const initialData = Route.useLoaderData();
const { data, error, isLoading, mutate } = useSWR(
sheetsPath,
fetchSheets,
{
fallbackData: initialData,
revalidateOnMount: false,
},
);
// Render with data, error, and isLoading.
}
The result from Route.useLoaderData() becomes fallbackData for the same SWR key. This is the handoff:
- TanStack Router fetches the navigation-critical initial data.
- The component renders with that data immediately.
- SWR takes ownership of the cache for the mounted UI.
revalidateOnMount: falseprevents SWR from repeating the request the loader just completed.
After a mutation, I can refresh the data with SWR instead of invalidating and rerunning the whole route:
await fetch("/api/sheets", {
method: "POST",
body: JSON.stringify(input),
headers: {
"content-type": "application/json",
},
});
void mutate();
For sheet detail pages, I also prime SWR’s global cache inside the loader:
import { mutate } from "swr";
export async function loadSheet(
sheetId: string,
signal?: AbortSignal,
) {
const path = `/api/sheets/${encodeURIComponent(sheetId)}`;
const data = await fetchSheet(path, signal);
await mutate(path, data, {
revalidate: false,
});
return data;
}
The workspace and individual media routes read the same sheet endpoint. Priming the shared key means either route can reuse the response, while fallbackData still makes the route-to-component handoff explicit.
Search routes need one extra guard. I derive the loader dependency from the validated URL search value and use that query in the SWR key:
export const Route = createFileRoute("/_app/all")({
validateSearch: (search): { search?: string } => ({
search:
typeof search.search === "string"
? search.search
: undefined,
}),
loaderDeps: ({ search: { search } }) => ({
search: search?.trim() ?? "",
}),
loader: ({ abortController, deps }) =>
fetchSearch(
["/api/search", deps.search],
abortController.signal,
),
});
const { data } = useSWR(
searchQuery ? ["/api/search", searchQuery] : null,
fetchSearch,
{
fallbackData:
initialData.query === searchQuery
? initialData.data
: undefined,
revalidateOnMount: false,
},
);
Using null disables the SWR request when search is inactive. Matching the fallback query prevents results from a previous URL from flashing under a new search.
This pattern gives me the best part of route loaders—the page has its data as navigation completes—without making the router responsible for every client-side refresh, optimistic update, or cache mutation afterward.
HTML entry
The HTML entry remains a normal Vite document:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>My app</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Run the app with:
bun run dev
React handles the browser, Nitro handles the server, and TanStack Router connects navigation to the data each page needs. SWR then keeps that data useful after the first render.