Build a dashboard on an existing API
A signed-in admin app with a sortable, paged table and a create form, against a backend you already have.
Most teams already have a backend. What they need is the screen in front of it: sign in, see the records in a table, sort and page through them, create a new one, and trust that the table is right afterwards. This guide builds that screen for Acme's orders, from kitr new to a passing test.
You will build acme-admin, a single-page app where Ada signs in, sees every order in a table that keeps its sorting and paging in the URL, and creates orders with a form that shows the API's validation errors under the right fields. The backend is any REST API that speaks JSON. Until you point the app at yours, Mocks answers in its place, so you can follow every step without a server.
Before you start
You need Node.js 22 or newer and the kitr CLI (npm install -g kitr, see Getting started). The guide assumes your API exposes these endpoints. Adjust the paths as you go if yours differ:
| Endpoint | Returns |
|---|---|
POST /sign-in |
{ token } for an email and password |
GET /me |
The signed-in user, for a bearer token |
GET /orders?page=&size=&sort=&status= |
{ rows, total }: one page of orders and the total count |
POST /orders |
The created order, or 422 with field errors |
Create the project
The app kit is made for exactly this: a single-page web app that talks to an API someone else runs.
kitr new acme-admin --kit appFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
It installs and wires Router, Query, HTTP, Forms, Table, State, Tailwind, Theme, Auth, Storage, Mocks and Testing, and copies a set of UI components into app/components/ui/. The parts this guide touches:
acme-admin/
├── app/
│ ├── routes/ # one file per page
│ ├── components/ui/ # data-table, form, button, input, app-shell: yours to edit
│ ├── mocks/ # handlers.ts and browser.ts
│ ├── abilities.ts # the User type and permission rules
│ ├── app.ts # the app, built from kitrrc.ts and config/
│ └── router.tsx # puts the session in the router context
├── config/
│ ├── auth.ts # where sessions come from
│ └── http.ts # the API's base URL and token
├── kitrrc.ts # platforms: { site: web({ mode: 'spa' }) }
└── .env.example
Start it:
cd acme-admin
cp .env.example .env
npm run dev
npm run dev runs kitr dev, which starts the site web platform on http://localhost:3000. Edit app/routes/index.tsx and the page updates as you save.
Point the app at your API
Every request goes through one HTTP client, configured in config/http.ts. It reads the base URL from VITE_API_URL, which app/env.ts validates when the app starts:
// config/http.ts
import { defineConfig } from 'kitr/http'
import { env } from '#app/env'
export default defineConfig({
baseUrl: env.VITE_API_URL,
})
Set the URL of your API in .env. This guide uses /api under port 3333, which is what the mocks below answer:
# .env
VITE_API_URL=http://localhost:3333/api
VITE_MOCKS=true
VITE_ variables are public: Vite inlines them into the browser bundle, so never put a secret in one. A malformed URL stops the app at startup with the variable's name in the message, not halfway through a request.
Fake the API while you build
With VITE_MOCKS=true, MSW answers requests in the browser before they reach the network. Write one handler per endpoint, with Acme's data:
// app/mocks/handlers.ts
import { KitrError } from 'kitr/app'
import { HttpResponse, http, kitrErrorResponse } from 'kitr/mocks'
const orders = [
{ id: 'ord_1042', customer: 'Ada', total: 129, status: 'paid', placedAt: '2026-10-02' },
{ id: 'ord_1043', customer: 'Grace', total: 480, status: 'open', placedAt: '2026-10-05' },
{ id: 'ord_1044', customer: 'Linus', total: 75, status: 'open', placedAt: '2026-10-09' },
]
export const handlers = [
http.post('*/api/sign-in', () => HttpResponse.json({ token: 'tok_ada' })),
http.get('*/api/me', () =>
HttpResponse.json({ id: 'u_ada', email: '[email protected]', role: 'admin' }),
),
http.get('*/api/orders', ({ request }) => {
const url = new URL(request.url)
const status = url.searchParams.get('status')
const rows = status ? orders.filter((o) => o.status === status) : orders
return HttpResponse.json({ rows, total: rows.length })
}),
http.post('*/api/orders', async ({ request }) => {
const body = (await request.json()) as { customer: string; total: number }
if (body.customer.length < 2)
return kitrErrorResponse(KitrError.validation({ customer: ['Enter the customer’s name'] }))
const order = { id: `ord_${1042 + orders.length}`, status: 'open', placedAt: '2026-10-10', ...body }
orders.push(order)
return HttpResponse.json(order, { status: 201 })
}),
]
MSW needs its worker script served from the site's root. Copy it once and commit it:
npx msw init public --save
Then start the worker in the client entry, before the app boots, so the first request is already answered. The kit wrote enableMocks() in app/mocks/browser.ts; it does nothing unless VITE_MOCKS is 'true' in development:
// app/client.tsx
import { StartClient } from '@tanstack/react-start/client'
import { StrictMode, startTransition } from 'react'
import { hydrateRoot } from 'react-dom/client'
import { app } from './app'
async function start() {
if (import.meta.env.DEV) {
const { enableMocks } = await import('#app/mocks/browser')
await enableMocks()
}
await app.boot()
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<StartClient />
</StrictMode>,
)
})
}
start()
The dynamic import() keeps MSW and the fake orders out of the production bundle. When your real API is ready, set VITE_MOCKS=false and restart kitr dev; nothing else changes.
Sign in
Two pieces of the app need the session: Auth, which signs users in and keeps their token, and the HTTP client, which sends that token with every request. Put the adapter in its own module so both can import it without importing each other:
// app/auth/adapter.ts
import { localStorageTokens, tokenAdapter } from 'kitr/auth'
import { type User, abilities } from '#app/abilities'
import { env } from '#app/env'
export const authAdapter = tokenAdapter<User>({
abilities,
storage: localStorageTokens(),
fetchUser: async (token) => {
const res = await fetch(`${env.VITE_API_URL}/me`, { headers: { authorization: `Bearer ${token}` } })
return res.ok ? ((await res.json()) as User) : null
},
signIn: async (credentials) => {
const res = await fetch(`${env.VITE_API_URL}/sign-in`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(credentials),
})
if (!res.ok) throw new Error('Wrong email or password')
return (await res.json()) as { token: string }
},
})
// config/auth.ts
import { defineConfig } from 'kitr/auth'
import { authAdapter } from '#app/auth/adapter'
export default defineConfig({ adapter: authAdapter, signInPath: '/sign-in' })
// config/http.ts
import { defineConfig } from 'kitr/http'
import { authAdapter } from '#app/auth/adapter'
import { env } from '#app/env'
export default defineConfig({
baseUrl: env.VITE_API_URL,
getToken: () => authAdapter.getToken?.(),
onUnauthorized: () => window.location.assign('/sign-in'),
})
Every request now carries Authorization: Bearer tok_ada once Ada signs in, and a 401 from the API sends her back to the sign-in page. localStorageTokens() keeps the token across reloads; the Auth guide explains when to keep it in memory instead.
Now the sign-in page. requireAuth() sends anonymous visitors here with the page they wanted in ?redirect=, so send them back after a successful sign-in:
// app/routes/sign-in.tsx
import { createFileRoute } from '@tanstack/react-router'
import { useAuth } from 'kitr/auth'
import { useNavigate } from 'kitr/router'
import { type FormEvent, useState } from 'react'
import type { User } from '#app/abilities'
export const Route = createFileRoute('/sign-in')({
validateSearch: (search: Record<string, unknown>) => ({
redirect: typeof search.redirect === 'string' ? search.redirect : '/orders',
}),
component: SignInPage,
})
function SignInPage() {
const { redirect } = Route.useSearch()
const navigate = useNavigate()
const auth = useAuth<User, { email: string; password: string }>()
const [error, setError] = useState<string | null>(null)
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const data = new FormData(event.currentTarget)
try {
await auth.signIn({ email: String(data.get('email')), password: String(data.get('password')) })
await navigate({ to: redirect })
} catch (err) {
setError(err instanceof Error ? err.message : 'Sign-in failed')
}
}
return (
<form onSubmit={onSubmit} className="mx-auto mt-24 grid max-w-sm gap-3">
<h1 className="text-2xl font-semibold">Sign in to Acme</h1>
{error && <p role="alert">{error}</p>}
<input name="email" type="email" autoComplete="email" defaultValue="[email protected]" />
<input name="password" type="password" autoComplete="current-password" />
<button type="submit">Sign in</button>
</form>
)
}
Protect every page behind the layout
A file whose name starts with _ is a layout with no URL segment. Put the guard and the app's chrome on it, and every page inside app/routes/_app/ needs a signed-in user:
// app/routes/_app.tsx
import { createFileRoute } from '@tanstack/react-router'
import { useAuth, useUser } from 'kitr/auth'
import { Outlet, requireAuth } from 'kitr/router'
import type { User } from '#app/abilities'
import { AppShell } from '#components/ui/app-shell'
export const Route = createFileRoute('/_app')({
beforeLoad: requireAuth(),
component: AppLayout,
})
function AppLayout() {
const auth = useAuth()
const user = useUser<User>()
return (
<AppShell
brand="Acme admin"
nav={[{ to: '/orders', label: 'Orders' }]}
user={
<button type="button" onClick={() => auth.signOut()}>
Sign out {user?.email}
</button>
}
>
<Outlet />
</AppShell>
)
}
The kit's app/router.tsx already puts the auth session in the router context, which is what requireAuth() reads. Open http://localhost:3000/orders signed out and you land on /sign-in?redirect=%2Forders; sign in and you are back on /orders.
Warning Route guards decide what the browser shows. They do not protect data: anyone can call your API without loading the page. Your API must check the token on every request it answers.
Describe the orders
Each feature keeps its data in one queries.ts: the cache keys, the queries that read and the mutations that write. Let the CLI write a starting point:
kitr make:queries ordersFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
It writes app/features/orders/queries.ts with a list query, a detail query and an update mutation over the HTTP client. Replace it with the two operations this screen needs: one page of orders for the table, and a create mutation:
// app/features/orders/queries.ts
import { createKeys } from 'kitr/app'
import { httpMutation, httpQuery } from 'kitr/http'
import { mutationFrom, queryFrom } from 'kitr/query'
import type { TableSearch } from 'kitr/table'
import { app } from '#app/app'
const http = app.container.use('http')
export const orderKeys = createKeys('orders')
export interface Order {
id: string
customer: string
total: number
status: 'open' | 'paid' | 'refunded'
placedAt: string
}
export interface OrdersPage {
rows: Order[]
total: number
}
export const ordersPageQuery = queryFrom(
httpQuery<TableSearch, OrdersPage>(http, {
path: 'orders',
key: (search) => orderKeys.list(search),
searchParams: (search) => ({
page: search.page,
size: search.size,
sort: search.sort,
status: search.filter?.status,
}),
}),
)
export const createOrder = mutationFrom(
httpMutation<{ customer: string; total: number }, Order>(http, {
path: 'orders',
effects: { invalidate: [orderKeys.lists()] },
}),
)
Three things to notice:
- The whole table search is part of the key (
['orders', 'list', { page: 2, sort: '-total' }]), so every page and sort order is cached on its own and the back button is instant. searchParamsdropsundefinedvalues, so page 1 with no sort requests/ordersand nothing more.createOrderdeclares what it changes:invalidate: [orderKeys.lists()]refetches every orders list, whatever its page or sort. No component has to remember to refresh the table. Loading and saving data explains the idea.
Show the orders in a table
Columns describe what each cell shows. Keep them outside the component so the table does not rebuild them on every render:
// app/features/orders/columns.tsx
import type { ColumnDef, DataTableFeatures } from 'kitr/table'
import type { Order } from './queries'
export const columns: ColumnDef<DataTableFeatures, Order>[] = [
{ accessorKey: 'id', header: 'Order', enableSorting: false },
{ accessorKey: 'customer', header: 'Customer' },
{ accessorKey: 'total', header: 'Total', cell: ({ row }) => `$${row.original.total.toFixed(2)}` },
{ accessorKey: 'status', header: 'Status', enableSorting: false },
{
accessorKey: 'placedAt',
header: 'Placed',
cell: ({ row }) => new Date(row.original.placedAt).toLocaleDateString('en-US'),
},
]
The page validates the search params, loads the matching page before it renders, and binds the table's sorting and paging to the URL:
// app/routes/_app/orders/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { Link } from 'kitr/router'
import { useSuspenseQuery } from 'kitr/query'
import { parseTableSearch, useUrlTableState } from 'kitr/table'
import { DataTable } from '#components/ui/data-table'
import { columns } from '#app/features/orders/columns'
import { ordersPageQuery } from '#app/features/orders/queries'
export const Route = createFileRoute('/_app/orders/')({
validateSearch: parseTableSearch,
loaderDeps: ({ search }) => search,
loader: ({ context, deps }) => context.queryClient.ensureQueryData(ordersPageQuery.options(deps)),
component: OrdersPage,
})
function OrdersPage() {
const search = Route.useSearch()
const navigate = Route.useNavigate()
const { data } = useSuspenseQuery(ordersPageQuery.options(search))
const urlState = useUrlTableState({
search,
setSearch: (patch) => navigate({ search: (prev) => ({ ...prev, ...patch }) }),
})
return (
<section className="grid gap-4 p-6">
<header className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Orders</h1>
<Link to="/orders/new">New order</Link>
</header>
<DataTable columns={columns} data={data.rows} rowCount={data.total} {...urlState} />
</section>
)
}
Open /orders: three rows. Click the Total header and the URL becomes /orders?sort=-total, the loader fetches that page and Grace's $480 order moves to the top. Reload and it is still sorted; share the URL and a teammate sees the same view.
parseTableSearch keeps what is valid in the URL and drops the rest, because anyone can edit a URL. The API should do the same: sort only by columns it allows. The Table guide shows the server half.
Create an order with a form
Start with a schema. The form validates with it in the browser, and your API can validate with the same rules:
// app/features/orders/schema.ts
import { z } from 'zod'
export const newOrderSchema = z.object({
customer: z.string().min(1, 'Enter the customer’s name'),
total: z.coerce.number().positive('The total must be more than zero'),
})
export type NewOrder = z.output<typeof newOrderSchema>
The page runs the mutation with mutateAsync and goes back to the table when it succeeds:
// app/routes/_app/orders/new.tsx
import { createFileRoute } from '@tanstack/react-router'
import { useNavigate } from 'kitr/router'
import { FieldInput, Form, FormField, SubmitButton, useSchemaForm } from 'kitr/forms'
import { useMutation } from 'kitr/query'
import { createOrder } from '#app/features/orders/queries'
import { newOrderSchema } from '#app/features/orders/schema'
export const Route = createFileRoute('/_app/orders/new')({
component: NewOrderPage,
})
function NewOrderPage() {
const create = useMutation(createOrder)
const navigate = useNavigate()
const form = useSchemaForm(newOrderSchema, { defaultValues: { customer: '', total: '' } })
return (
<Form
form={form}
onSubmit={async (order) => {
await create.mutateAsync(order)
await navigate({ to: '/orders' })
}}
className="grid max-w-md gap-4 p-6"
>
<h1 className="text-2xl font-semibold">New order</h1>
<FormField name="customer" label="Customer">
<FieldInput autoComplete="off" />
</FormField>
<FormField name="total" label="Total (USD)">
<FieldInput inputMode="decimal" />
</FormField>
<SubmitButton pendingText="Creating…">Create order</SubmitButton>
</Form>
)
}
Fill in Linus and 75, press the button, and you are back on /orders with Linus's new order in the table. Nobody told the table to refresh: the mutation's invalidate effect did.
Now type a single letter as the customer and submit. The schema allows it, so the request goes out, and the API answers 422 with { fieldErrors: { customer: ['Enter the customer’s name'] } }. <Form> puts that message under the Customer field. Use mutateAsync, not mutate: mutate swallows the error, so the form would never see it.
Test the table
The kit includes Testing and a vitest.config.ts. A component test renders the page's table with the app's providers and the same mock handlers you use in the browser, so the HTTP client, the query cache and the table all run as in production:
// app/features/orders/orders-table.test.tsx
import { screen } from '@testing-library/react'
import httpProvider from 'kitr/http/provider'
import { setupNodeMocks } from 'kitr/mocks'
import { useSuspenseQuery } from 'kitr/query'
import queryProvider from 'kitr/query/provider'
import { createTestApp } from 'kitr/testing'
import { renderWithKitr } from 'kitr/testing/react'
import { Suspense } from 'react'
import { afterAll, beforeAll, expect, it } from 'vitest'
import { handlers } from '#app/mocks/handlers'
import { ordersPageQuery } from './queries'
let mocks: Awaited<ReturnType<typeof setupNodeMocks>>
beforeAll(async () => {
mocks = await setupNodeMocks(handlers)
})
afterAll(() => mocks.close())
function OrderCustomers() {
const { data } = useSuspenseQuery(ordersPageQuery.options({ sort: '-total' }))
return <p>{data.rows.map((order) => order.customer).join(', ')}</p>
}
it('loads the orders from the API', async () => {
const app = await createTestApp({
providers: [queryProvider, httpProvider],
config: { http: { baseUrl: 'http://localhost:3333/api' } },
})
renderWithKitr(
<Suspense fallback="Loading orders">
<OrderCustomers />
</Suspense>,
{ app },
)
expect(await screen.findByText('Ada, Grace, Linus')).toBeTruthy()
})
npm test
The mock handler above ignores sort, so the names come back in insertion order. Make it sort, or point the test at a staging API, when the order matters to you. The Testing guide covers page tests, API tests and CI.
Next steps
- Add sign-in, end to end: refresh tokens, permissions and protecting the API side.
- Table: filters, row selection, column visibility and client-side tables.
- Forms: editing an existing record, repeating fields and custom inputs.
- Deploy to production: ship the
spabuild to any static host. - Deploy to production: ship the SPA to a static host.