Start
Quick Start Task Tracker App
Build the first Task Tracker route tree, then layer in a shared layout and middleware with complete working files.
This page builds the first useful version of the Task Tracker app. The goal is not to show every framework feature. The goal is to give you a route tree that already feels like production code.
Minimal working example
// app/routes/tasks/index.tsx
import { Link, useLoaderData } from "react-bun-ssr/route";
interface Task {
id: string;
title: string;
done: boolean;
}
export async function loader() {
const tasks: Task[] = [
{ id: "t1", title: "Write launch checklist", done: false },
{ id: "t2", title: "Review SSR output", done: true },
];
return { tasks };
}
export default function TasksPage() {
const data = useLoaderData<{ tasks: Task[] }>();
return (
<main>
<h1>Task Tracker</h1>
<p><Link to="/tasks/new">Create task</Link></p>
<ul>
{data.tasks.map(task => (
<li key={task.id}>
<Link to={`/tasks/${task.id}`}>{task.title}</Link>
</li>
))}
</ul>
</main>
);
}
// app/routes/tasks/new.tsx
import { redirect } from "react-bun-ssr";
import type { Action } from "react-bun-ssr/route";
export const action: Action = async ({ formData }) => {
const title = String(formData?.get("title") ?? "").trim();
if (!title) {
return { error: "Title is required" };
}
// Real code should validate and persist the task here before redirecting.
return redirect("/tasks");
};
export default function NewTaskPage() {
return (
<form method="post">
<label>
Title
<input name="title" />
</label>
<button type="submit">Save task</button>
</form>
);
}
// app/routes/api/tasks.ts
import { json } from "react-bun-ssr";
export function GET() {
// Real code should load from your database or service layer here,
// not return an inline array.
return json({ tasks: [{ id: "t1", title: "Write launch checklist" }] });
}
Why this example matters
It already exercises the core runtime:
- a page loader for SSR data
- a form action for mutations
- soft navigation through
Link - a colocated API route
Add a shared tasks layout
Once the first route works, the next step is to stop repeating chrome across /tasks, /tasks/new, and future task pages.
// app/routes/tasks/_layout.tsx
import { Link, Outlet } from "react-bun-ssr/route";
export default function TasksLayout() {
return (
<main>
<header>
<h1>Task Tracker</h1>
<nav>
<Link to="/tasks">All tasks</Link>
<Link to="/tasks/new">Create task</Link>
</nav>
</header>
<Outlet />
</main>
);
}
That keeps the task navigation mounted while leaf routes change underneath it.
At that point, the route tree looks like this:
app/routes/
tasks/
_layout.tsx
index.tsx
new.tsx
api/
tasks.ts
Add middleware for the tasks section
Use nested middleware when the behavior belongs to the /tasks subtree instead of the entire app.
// app/routes/tasks/_middleware.ts
import type { Middleware } from "react-bun-ssr/route";
export const middleware: Middleware = async (ctx, next) => {
ctx.locals.section = "tasks";
if (!ctx.cookies.get("session")) {
return Response.redirect(new URL("/", ctx.url), 302);
}
return next();
};
Then the page loader can read the value that middleware attached:
// app/routes/tasks/index.tsx
import { Link, useLoaderData, type Loader } from "react-bun-ssr/route";
interface Task {
id: string;
title: string;
done: boolean;
}
export const loader: Loader = ({ locals }) => {
const tasks: Task[] = [
{ id: "t1", title: "Write launch checklist", done: false },
{ id: "t2", title: "Review SSR output", done: true },
];
return {
section: locals.section,
tasks,
};
};
export default function TasksPage() {
const data = useLoaderData<{ section: string; tasks: Task[] }>();
return (
<section>
<p>Current section: {data.section}</p>
<p><Link to="/tasks/new">Create task</Link></p>
<ul>
{data.tasks.map(task => (
<li key={task.id}>
<Link to={`/tasks/${task.id}`}>{task.title}</Link>
</li>
))}
</ul>
</section>
);
}
What this deeper version gives you
- one shared layout for the task area
- one nested middleware file for request policy and shared locals
- one place to grow into
/tasks/[id],/tasks/completed, or task-specific APIs
What to notice
loader()runs on the server and serializes its return value into the document payload.action()owns the POST request and can return plain data, redirects, orResponsevalues.- API routes live under the same
app/routestree but use HTTP method exports. _layout.tsxshares chrome without creating its own URL._middleware.tsruns before the matched task routes and can populatectx.localsor short-circuit the request.
Rules
- Keep page and API routes in the same route tree only when that improves locality.
- Use TypeScript interfaces at the route boundary so
useLoaderData()stays explicit. - Prefer redirect-after-success in actions instead of mutating client-only state first.
- Use nested
_layout.tsxwhen multiple sibling routes need the same shell. - Use nested
_middleware.tswhen the behavior belongs to a route subtree instead of the whole app.
Related APIs
Next step
Read Project Structure, then go deeper with Layouts and Groups and Middleware.