Building a feature
This page is for a frontend developer who wants to add a page to the example app — say a dashboard — without first learning Drizzle, Postgres, or the auth internals. You write React and Mantine (through fusion-ui); the stack hands you the data and the permission flags. By the end you'll have built a working dashboard.
If you haven't yet, skim the example tour and ProcessN & ProtokollN for what's already there.
The mental model
A page in the example is four small pieces, and as a frontend dev you mostly touch the React one:
- A route file —
src/routes/_authed.<name>.tsx. The filename is the URL. Anything under_authed.requires a signed-in user (the layout redirects to/loginotherwise) and hands you the currentuser. - A server function — a plain
asyncfunction insrc/lib/<name>-server.tsthat runs only on the server. It can read the database and check permissions. You call it like any async function — there is nofetch, no REST endpoint to write. TanStack Start ships the data to the browser for you. - The component — ordinary React using fusion-ui
- Mantine components. This is the part you spend your time on.
- (Optional) a nav link — one line in the app-shell sidebar.
UI comes from fusion-ui, always. Import composed components from the
@tikab-interactive/fusion-uibarrel, and raw Mantine primitives, hooks and icons from its re-export subpaths (…/mantine,…/hooks,…/icons) — never@mantine/coredirectly. The app authors no UI; it wires data + i18n into fusion-ui. See Conventions for why the Mantine packages still sit in the app'spackage.json.
Here's the round trip. The grey box is the only part that runs on the server:
Three things make this comfortable coming from a pure-frontend background:
- You never write an API. A server function is the API. You import it and call it; types flow through end-to-end, so the data your component receives is fully typed.
- Permissions arrive as plain booleans. The server decides what you may see and
do, and returns flags like
canChangeon each row. Your component just reads them (disabled={!row.canChange}). You don't implement the security — you render it. (The server also re-checks on every write, so a faked flag changes nothing.) - The current user is handed to you. Inside any
_authedpage,Route.useRouteContext().useris{ id, name, email, isSiteAdmin }. No token juggling.
The pieces you'll touch
| Piece | File | What it's for |
|---|---|---|
| Route + component | src/routes/_authed.<name>.tsx | the URL, the loader, your React |
| Server function | src/lib/<name>-server.ts | fetch/aggregate data, check permissions (server-only) |
| UI components | @tikab-interactive/fusion-ui (+ its /mantine re-export) | PageHeader, DataTable, cards, inputs… |
| Copy text | messages/en.json + messages/sv.json → m.* | every visible string (i18n) |
| Nav link | src/routes/_authed.tsx | the sidebar entry |
Worked example: a dashboard
Goal: a /dashboard page with a row of stat tiles ("projects I can see", "my
assignments", "open points") and a small chart. We'll build it in three steps.
Note: the example app already ships a
/dashboard— a fancier, streaming version that aggregates cards across every integration (see the tour). We build a simpler one from scratch here to show the moving parts; the shape is the same. Treat the file below as illustrative — the real one already exists.
Step 1 — the data (a server function)
Create src/lib/dashboard-server.ts. A server function is just createServerFn
wrapping an async handler. Don't worry about the database calls — copy the shape
from an existing file like src/lib/processn-server.ts and change the queries. The
important habits are: start by requiring a user, and let the permission
helpers decide what counts.
import { createServerFn } from "@tanstack/react-start";
import { eq } from "drizzle-orm";
import { processnAssignedTask, processnProject } from "#/db/schema/processn";
import { getSession } from "#/lib/auth";
import { db } from "#/lib/db";
import { getProjectPermissions } from "#/permissions/processn";
export type DashboardStats = {
visibleProjects: number;
myAssignments: number;
};
export const getDashboardStats = createServerFn({ method: "GET" }).handler(
async (): Promise<DashboardStats> => {
// 1. Who's asking? (Throws 401 if not signed in.)
const session = await getSession();
if (!session) throw new Response("Unauthorized", { status: 401 });
const userId = session.user.id;
// 2. Count the ProcessN projects this user is allowed to view — the same
// permission helper the projects page uses, so the number always matches.
const projects = await db.select().from(processnProject);
let visibleProjects = 0;
for (const p of projects) {
const perms = await getProjectPermissions(db, userId, p.id);
if (perms.canView) visibleProjects++;
}
// 3. Count this user's own assignments.
const assignments = await db
.select()
.from(processnAssignedTask)
.where(eq(processnAssignedTask.userId, userId));
// 4. Return a plain object — it's typed all the way to the component.
return { visibleProjects, myAssignments: assignments.length };
},
);That's the whole "backend". The pattern is always the same — require the user,
read with a permission helper, return plain data — so once you've copied it
once, new server functions are mechanical. (For a list of editable rows you'd also
return a canChange boolean per row, exactly like listProcessnProjects does.)
Step 2 — the page (route + component)
Create src/routes/_authed.dashboard.tsx. The loader calls your server function;
the component reads the result with Route.useLoaderData() and renders it. This is
plain React.
import { createFileRoute } from "@tanstack/react-router";
import { PageHeader } from "@tikab-interactive/fusion-ui";
import { Card, SimpleGrid, Text } from "@tikab-interactive/fusion-ui/mantine";
import { getDashboardStats } from "#/lib/dashboard-server";
import { m } from "#/paraglide/messages.js";
export const Route = createFileRoute("/_authed/dashboard")({
loader: () => getDashboardStats(), // runs on the server; result is the loader data
component: Dashboard,
});
function StatCard({ label, value }: { label: string; value: number }) {
return (
<Card withBorder radius="md" padding="lg">
<Text size="xs" c="dimmed" tt="uppercase">
{label}
</Text>
<Text fz={36} fw={700}>
{value}
</Text>
</Card>
);
}
function Dashboard() {
const stats = Route.useLoaderData(); // fully typed: DashboardStats
const { user } = Route.useRouteContext(); // { id, name, email, isSiteAdmin }
return (
<>
<PageHeader
title={m.dashboard_title()}
description={m.dashboard_hello({ name: user.name })}
/>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} mt="md">
<StatCard label={m.dashboard_projects()} value={stats.visibleProjects} />
<StatCard label={m.dashboard_assignments()} value={stats.myAssignments} />
</SimpleGrid>
</>
);
}Add the dashboard_* strings to messages/en.json and messages/sv.json so
m.dashboard_title() resolves — see Internationalization. (Prefer the
m.* helpers over hard-coded strings; the build compiles them per locale.)
That's a working dashboard. Visit /dashboard signed in and the tiles show your
real, permission-scoped numbers.
Step 3 — put it in the sidebar
Open src/routes/_authed.tsx and add a link next to the others in the navbar:
{
link(m.nav_dashboard(), "/dashboard");
}Make it fancy
Everything beyond this is normal React, with the UI coming from fusion-ui:
- Charts — the repo already ships a small
BarChart(src/components/BarChart.tsx, used by the generative-UI sandbox). Return the series from your server function and render it. - More tiles / layout —
SimpleGrid,Group,Stack,Paper,RingProgress,Badgevia@tikab-interactive/fusion-ui/mantine;StatusBadge,EmptyState,DataStatefrom the fusion-ui barrel. - A table on the dashboard — reuse
DataTablefrom fusion-ui (sortable, row selection), or the wholeResourceTablePageshell if rows are editable (see below). - Loading / errors — a route can show a
pendingComponent/errorComponent; fusion-ui'sDataStaterenders the loading/error/empty trio. - Live data — for a realtime tile, the realtime sandbox shows the subscribe pattern.
- Search as a data source — universal search is a
universalSearchserver function (hybrid FTS + trigram + vector); call it from your loader to pull cross-app hits into a page. Conversation pages also now carry a header bar + a per-conversation Files panel — copyConversationHeaderfrom fusion-ui rather than rolling your own.
Patterns worth copying
You rarely start from scratch — copy one of these:
- A read-only or editable table → copy a domain page (e.g.
_authed.processn.projects.tsx). It usesResourceTablePage: you pass columns, the rows from a loader, and a per-rowcanEditpredicate, and get the Editor/Viewer badge, a gated Edit button, and an edit sheet for free. The matching*-server.tsshows the list-filters-then-update-re-checks shape. - An admin-only page → copy
_authed.admin.tsx. Its routebeforeLoadredirects non-admins, and the nav link is hidden with theusePermissionhook — same permission checked in both places (see Authorization). - A page that calls AI → copy a
/sandboxAI page; the server half is onestreamChatcall (see AI). - A form that writes → a
createServerFn({ method: "POST" })with an.inputValidator(zodSchema.parse); call it from a handler, thenrouter.invalidate()to refresh the loader data.
Cheat-sheet
| I want to… | Do this |
|---|---|
| Add a signed-in page | new src/routes/_authed.<name>.tsx with createFileRoute("/_authed/<name>") |
| Fetch data for it | a createServerFn(...).handler() in src/lib/<name>-server.ts, called from the route loader |
| Read the result | const data = Route.useLoaderData() |
| Know who's signed in | const { user } = Route.useRouteContext() |
| Gate UI on permission | render the canX boolean the server returns; for admin-only, usePermission(...) (Authorization) |
| Write data | a POST server fn with a Zod inputValidator, then router.invalidate() |
| Show text | m.<key>() from Paraglide (i18n) |
| Use components | @tikab-interactive/fusion-ui (Storybook docs); raw primitives via its /mantine re-export |
| Add a nav entry | a link(...) in src/routes/_authed.tsx |