Skip to content
Fusion

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:

  1. A route filesrc/routes/_authed.<name>.tsx. The filename is the URL. Anything under _authed. requires a signed-in user (the layout redirects to /login otherwise) and hands you the current user.
  2. A server function — a plain async function in src/lib/<name>-server.ts that runs only on the server. It can read the database and check permissions. You call it like any async function — there is no fetch, no REST endpoint to write. TanStack Start ships the data to the browser for you.
  3. The component — ordinary React using fusion-ui
    • Mantine components. This is the part you spend your time on.
  4. (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-ui barrel, and raw Mantine primitives, hooks and icons from its re-export subpaths (…/mantine, …/hooks, …/icons) — never @mantine/core directly. The app authors no UI; it wires data + i18n into fusion-ui. See Conventions for why the Mantine packages still sit in the app's package.json.

Here's the round trip. The grey box is the only part that runs on the server:

Loading diagram...

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 canChange on 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 _authed page, Route.useRouteContext().user is { id, name, email, isSiteAdmin }. No token juggling.

The pieces you'll touch

PieceFileWhat it's for
Route + componentsrc/routes/_authed.<name>.tsxthe URL, the loader, your React
Server functionsrc/lib/<name>-server.tsfetch/aggregate data, check permissions (server-only)
UI components@tikab-interactive/fusion-ui (+ its /mantine re-export)PageHeader, DataTable, cards, inputs…
Copy textmessages/en.json + messages/sv.jsonm.*every visible string (i18n)
Nav linksrc/routes/_authed.tsxthe 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.

src/lib/dashboard-server.ts
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.

src/routes/_authed.dashboard.tsx
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 / layoutSimpleGrid, Group, Stack, Paper, RingProgress, Badge via @tikab-interactive/fusion-ui/mantine; StatusBadge, EmptyState, DataState from the fusion-ui barrel.
  • A table on the dashboard — reuse DataTable from fusion-ui (sortable, row selection), or the whole ResourceTablePage shell if rows are editable (see below).
  • Loading / errors — a route can show a pendingComponent / errorComponent; fusion-ui's DataState renders the loading/error/empty trio.
  • Live data — for a realtime tile, the realtime sandbox shows the subscribe pattern.
  • Search as a data sourceuniversal search is a universalSearch server 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 — copy ConversationHeader from 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 uses ResourceTablePage: you pass columns, the rows from a loader, and a per-row canEdit predicate, and get the Editor/Viewer badge, a gated Edit button, and an edit sheet for free. The matching *-server.ts shows the list-filters-then-update-re-checks shape.
  • An admin-only page → copy _authed.admin.tsx. Its route beforeLoad redirects non-admins, and the nav link is hidden with the usePermission hook — same permission checked in both places (see Authorization).
  • A page that calls AI → copy a /sandbox AI page; the server half is one streamChat call (see AI).
  • A form that writes → a createServerFn({ method: "POST" }) with an .inputValidator(zodSchema.parse); call it from a handler, then router.invalidate() to refresh the loader data.

Cheat-sheet

I want to…Do this
Add a signed-in pagenew src/routes/_authed.<name>.tsx with createFileRoute("/_authed/<name>")
Fetch data for ita createServerFn(...).handler() in src/lib/<name>-server.ts, called from the route loader
Read the resultconst data = Route.useLoaderData()
Know who's signed inconst { user } = Route.useRouteContext()
Gate UI on permissionrender the canX boolean the server returns; for admin-only, usePermission(...) (Authorization)
Write dataa POST server fn with a Zod inputValidator, then router.invalidate()
Show textm.<key>() from Paraglide (i18n)
Use components@tikab-interactive/fusion-ui (Storybook docs); raw primitives via its /mantine re-export
Add a nav entrya link(...) in src/routes/_authed.tsx