Guide · Build an app

Build a full-stack app

One codebase with server-rendered pages, a database, server actions, a public API and live updates.

When you own the backend too, keep it in the same project. One repository, one kitr dev, one set of types from the database to the button: rename a column and the compiler shows you every screen that read it. This guide builds Acme's project tracker that way, from kitr new to a page that updates live when a teammate saves.

You will build acme-projects: a server-rendered list of a team's projects, read from Postgres through a server action, a form that creates projects and shows validation errors under the fields, a JSON API for the mobile app and partners, and live updates so Grace sees Ada's new project without reloading.

Before you start

You need Node.js 22 or newer, the kitr CLI (npm install -g kitr) and a Postgres database. If you have Docker, this starts one:

docker run -d --name acme-db -e POSTGRES_PASSWORD=acme -e POSTGRES_DB=acme -p 5432:5432 postgres:17

The guide uses zod for schemas and Drizzle for the database. Both are choices, not requirements: kitr validates with any Standard Schema library and connects any ORM.

Create the project

kitr new acme-projects --kit fullstack
cd acme-projects

The fullstack kit adds two platforms to kitrrc.ts:

// kitrrc.ts
import { defineKitrConfig } from 'kitr/app'
import { server } from 'kitr/server'
import { web } from 'kitr/web'

export default defineKitrConfig({
  // ...packages and providers
  platforms: {
    site: web({ mode: 'ssr' }),
    api: server(),
  },
})

site renders pages on the server through TanStack Start and hydrates them in the browser. api is the h3 server in server/main.ts. The kit also wrote app/routes/api.$.ts, which hands every /api/* request the site receives to that same server, so in production one process serves pages and the API. Run both in development:

cp .env.example .env
npm run dev

The site answers on http://localhost:3000 and the API on http://localhost:3001. kitr dev site runs the pages alone.

Where the code goes:

Folder What lives there Runs on
app/routes/ Pages Server, then browser
app/actions/ Server actions: functions the browser calls Server
app/channels/ Realtime channel definitions Both
app/features/ Queries, forms and components by feature Browser
server/ main.ts, routes.ts, controllers, the database schema Server
config/ One file per package Where the package runs

Connect the database

kitr add db
pnpm add drizzle-orm postgres
pnpm add -D drizzle-kit

kitr add db registers the DB provider, writes config/db.ts and adds DATABASE_URL to .env.example. Describe the table:

// server/db/schema.ts
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core'

export const projects = pgTable('projects', {
  id: text('id').primaryKey(),
  teamId: text('team_id').notNull(),
  name: text('name').notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
})

export type Project = typeof projects.$inferSelect

Connect it in config/db.ts, and export the client's type so the rest of the app can ask for it:

// config/db.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import { defineDb } from 'kitr/db'
import postgres from 'postgres'
import * as schema from '#server/db/schema'

const createDb = () => drizzle(postgres(process.env.DATABASE_URL!), { schema })

export type AppDb = ReturnType<typeof createDb>

export default defineDb({
  connect: () => createDb(),
  disconnect: (db) => db.$client.end(),
})

Point .env at the database and create the table with Drizzle's own tools:

# .env
DATABASE_URL=postgres://postgres:acme@localhost:5432/acme
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit'

export default defineConfig({
  schema: './server/db/schema.ts',
  out: './server/db/migrations',
  dialect: 'postgresql',
  dbCredentials: { url: process.env.DATABASE_URL! },
})
pnpm drizzle-kit generate
pnpm drizzle-kit migrate

Restart kitr dev. The provider connects during boot, before the first request; if Postgres is not running, the server does not start and the log shows the driver's error, such as ECONNREFUSED. config/db.ts never reaches the browser bundle.

Read projects with a server action

A server action is a function you define once and call from the browser as if it were local. kitr sends the request, validates the input on the server, runs the handler there and brings back the result. Generate one:

kitr make:query projects/list

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

// app/actions/projects/list.ts
import { desc, eq } from 'drizzle-orm'
import { defineQuery } from 'kitr/actions'
import { useDb } from 'kitr/db'
import { useRequestApp } from 'kitr/server'
import { z } from 'zod'
import { app } from '#app/app'
import type { AppDb } from '#config/db'
import { projects } from '#server/db/schema'

export const listProjects = defineQuery('projects.list', {
  input: z.object({ teamId: z.string() }),
  handler: ({ input }) =>
    useDb<AppDb>(useRequestApp(app))
      .select()
      .from(projects)
      .where(eq(projects.teamId, input.teamId))
      .orderBy(desc(projects.createdAt)),
})

The handler, Drizzle and the Postgres driver only run on the server; none of it reaches the browser bundle. useRequestApp(app) is the app for the current request, so services bound per request work inside the handler.

The name projects.list is the action's address. kitr types (which kitr dev runs, and reruns whenever a file in app/actions/ changes) writes it into .kitr/actions.ts, so a component can use it by name with full types. Load it in the page's loader and read it in the component:

// app/routes/projects/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { queryOptions, useSuspenseQuery } from 'kitr/query'
import { seo } from 'kitr/seo'

export const Route = createFileRoute('/projects/')({
  loader: ({ context }) =>
    context.queryClient.ensureQueryData(queryOptions('projects.list', { teamId: 'team_acme' })),
  head: () => seo({ title: 'Projects · Acme', description: 'Every project the Acme team runs.' }),
  component: ProjectsPage,
})

function ProjectsPage() {
  const { data: projects } = useSuspenseQuery('projects.list', { teamId: 'team_acme' })
  return (
    <main className="mx-auto grid max-w-2xl gap-4 p-6">
      <h1 className="text-2xl font-semibold">Projects</h1>
      <ul>
        {projects.map((project) => (
          <li key={project.id}>{project.name}</li>
        ))}
      </ul>
    </main>
  )
}

Open /projects. The server runs the loader, which calls the handler in-process (no HTTP hop), renders the list into the HTML and sends the data along, so the browser does not fetch it again. View the page source and the project names are there, which is what search engines and link previews read. useSuspenseQuery('projects.lst') is a type error: the name is not registered.

Create projects with a form

Put the schema where both sides can import it. The form validates with it in the browser, and the action validates with it again on the server, because the browser can be bypassed:

// app/features/projects/schema.ts
import { z } from 'zod'

export const newProjectSchema = z.object({
  teamId: z.string(),
  name: z.string().trim().min(2, 'Give the project a name of at least two letters'),
})

export type NewProject = z.output<typeof newProjectSchema>
kitr make:mutation projects/create

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

// app/actions/projects/create.ts
import { and, eq } from 'drizzle-orm'
import { defineMutation } from 'kitr/actions'
import { KitrError } from 'kitr/app'
import { useDb } from 'kitr/db'
import { useRequestApp } from 'kitr/server'
import { app } from '#app/app'
import type { AppDb } from '#config/db'
import { newProjectSchema } from '#app/features/projects/schema'
import { projects } from '#server/db/schema'

export const createProject = defineMutation('projects.create', {
  input: newProjectSchema,
  effects: { invalidate: [['action', 'projects.list']] },
  handler: async ({ input }) => {
    const db = useDb<AppDb>(useRequestApp(app))
    const taken = await db
      .select()
      .from(projects)
      .where(and(eq(projects.teamId, input.teamId), eq(projects.name, input.name)))
    if (taken.length) throw KitrError.validation({ name: ['This team already has a project with that name'] })
    const [project] = await db
      .insert(projects)
      .values({ id: `prj_${crypto.randomUUID().slice(0, 8)}`, ...input })
      .returning()
    return project!
  },
})

effects says what the mutation changes in the cache. Action queries are cached under ['action', '<name>', input], so ['action', 'projects.list'] covers every team's list. When the mutation succeeds, every list on screen refetches. No component has to remember to.

Use it from a form:

// app/features/projects/new-project-form.tsx
import { FieldInput, Form, FormField, SubmitButton, useSchemaForm } from 'kitr/forms'
import { useMutation } from 'kitr/query'
import { newProjectSchema } from './schema'

export function NewProjectForm({ teamId }: { teamId: string }) {
  const create = useMutation('projects.create')
  const form = useSchemaForm(newProjectSchema, { defaultValues: { teamId, name: '' } })
  return (
    <Form form={form} onSubmit={create.mutateAsync} resetOnSuccess className="flex items-end gap-2">
      <FormField name="name" label="New project">
        <FieldInput placeholder="Apollo launch" />
      </FormField>
      <SubmitButton pendingText="Creating…">Create</SubmitButton>
    </Form>
  )
}

Render <NewProjectForm teamId="team_acme" /> above the list in ProjectsPage. Type Apollo launch and press Create: the project appears at the top of the list. Submit the same name again and "This team already has a project with that name" appears under the field. The handler threw KitrError.validation, the error crossed the network with its field errors intact, and <Form> put each message under its input. Anything else a handler throws becomes a 500, logged on the server; in production the browser only sees Internal server error.

Open an API for other clients

Server actions are for your own React app. When something else calls the same logic, such as the iPhone app, a partner or a webhook, give it a URL. API routes are controllers with typed routes, validation and an OpenAPI document:

kitr make:controller projects

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

// server/controllers/projects_controller.ts
import { desc, eq } from 'drizzle-orm'
import type { HttpContext } from 'kitr/api'
import { KitrError } from 'kitr/app'
import { useDb } from 'kitr/db'
import type { AppDb } from '#config/db'
import { projects } from '#server/db/schema'

export default class ProjectsController {
  async index({ app, params }: HttpContext<{ teamId: string }>) {
    return useDb<AppDb>(app)
      .select()
      .from(projects)
      .where(eq(projects.teamId, params.teamId))
      .orderBy(desc(projects.createdAt))
  }

  async show({ app, params }: HttpContext<{ teamId: string; id: string }>) {
    const [project] = await useDb<AppDb>(app).select().from(projects).where(eq(projects.id, params.id))
    if (!project || project.teamId !== params.teamId) throw KitrError.notFound('Project')
    return project
  }
}
// server/routes.ts
import { defineRoutes } from 'kitr/api'
import HealthController from '#server/controllers/health_controller'
import ProjectsController from '#server/controllers/projects_controller'

export const routes = defineRoutes((router) =>
  router.group({ prefix: '/api' }, (api) =>
    api
      .get('/health', [HealthController, 'show'], { name: 'health' })
      .get('/teams/:teamId/projects', [ProjectsController, 'index'], { name: 'projects.index' })
      .get('/teams/:teamId/projects/:id', [ProjectsController, 'show'], { name: 'projects.show' }),
  ),
)

export type AppRoutes = typeof routes.$types
curl http://localhost:3000/api/teams/team_acme/projects
[{ "id": "prj_5f1c2a9e", "teamId": "team_acme", "name": "Apollo launch", "createdAt": "2026-11-07T09:14:00.000Z" }]

The same URL answers on port 3001 from the api platform. A missing project answers 404 with { "code": "NOT_FOUND", "message": "Project not found", "status": 404 }, the same error shape actions use. kitr api:sync writes openapi.json for partners and the route table the typed browser client needs; the API guide shows api.projects.index({ params: { teamId } }).

Both read the same table with the same code, so pick per caller, not per feature: actions for your own UI, API routes for everyone else.

Push changes to every open tab

Grace has the projects page open when Ada creates "Apollo launch". With Realtime, Grace's list refreshes on its own. A channel is defined once and imported by both ends:

// app/channels/projects.ts
import { defineChannel } from 'kitr/realtime'
import { z } from 'zod'

export const teamProjectsChannel = defineChannel({
  name: 'team-projects',
  params: z.object({ teamId: z.string() }),
  events: {
    created: {
      schema: z.object({ id: z.string(), name: z.string() }),
      effects: () => ({ invalidate: [['action', 'projects.list']] }),
    },
  },
})

Register it with the realtime server the kit wrote:

// server/realtime.ts
import { createRealtimeServer } from 'kitr/realtime/server'
import { teamProjectsChannel } from '#app/channels/projects'

export const realtime = createRealtimeServer({
  channels: [teamProjectsChannel],
})

Emit from the action, after the insert:

// app/actions/projects/create.ts
// ...
import { teamProjectsChannel } from '#app/channels/projects'
import { realtime } from '#server/realtime'

export const createProject = defineMutation('projects.create', {
  // ...input and effects as before
  handler: async ({ input }) => {
    // ...the duplicate check and the insert as before
    realtime.use(teamProjectsChannel, { teamId: input.teamId }).emit('created', {
      id: project!.id,
      name: project!.name,
    })
    return project!
  },
})

And subscribe on the page. The socket only exists in the browser, so subscribe from a component that mounts after hydration:

// app/features/projects/live-projects.tsx
import { useChannel } from 'kitr/realtime/react'
import { teamProjectsChannel } from '#app/channels/projects'
import { realtime } from '#app/realtime'

export function LiveProjects({ teamId }: { teamId: string }) {
  const live = useChannel(realtime(), teamProjectsChannel, { teamId })
  return <small>{live.status === 'open' ? 'Live' : 'Connecting…'}</small>
}

Open /projects in two windows and create a project in one. The other shows it within a moment: the server sent one created event to every connection subscribed to team-projects with { teamId: 'team_acme' }, each browser validated the payload against the schema and ran the event's effects, which refetched the list. Add live updates covers authorising channels, writing events straight into the cache and running more than one server.

Test the API and the action

The kit includes Testing. The server is a fetch handler, so an API test calls it without opening a port:

// @vitest-environment node
// test/projects.test.ts
import { createTestClient } from 'kitr/testing'
import { expect, it } from 'vitest'
import { server } from '#server/main'

it('answers 404 for a project in another team', async () => {
  const res = await createTestClient(server).get('/api/teams/team_acme/projects/prj_missing')
  expect(res.status).toBe(404)
  expect(res.body).toMatchObject({ code: 'NOT_FOUND' })
})
npm test

The first request boots the server's app with every provider in kitrrc.ts, as kitr dev does, including the database. Point it at a throwaway database, never the one you develop against: Test your app sets one up with .env.test, and covers components, pages and CI.

Ship it

npm run build
npm start

kitr build builds every platform: the site into dist/site (client/ and server/server.js), and the API typechecks. kitr start site serves pages and /api/* from one process on port 3000. Run pnpm drizzle-kit migrate in your deploy step before the new version starts. Deploy to production covers Docker, environment variables and hosts.

Next steps