Your First App
This is a hands-on tour: you'll build a small link board — a page that lists saved links and a form to add new ones — in about 30 minutes. By the end you'll have touched the pieces you'll use in every Rudder app: the scaffolder, a model and its table, a controller view, request validation, and the form-submit pattern.
The What is Rudder? page explains the why. This page is the how.
What you'll build
A single page at /links:
- A list of links (title + URL), newest first, loaded from the database.
- A form to add a link, validated on the server, that updates the list without a full page reload.
Nothing here is throwaway — it's the same shape as a real feature.
Prerequisites
- Node.js
>=22.12.0 - pnpm (or npm/yarn/bun — the scaffolder adapts)
1. Scaffold the project
pnpm create rudder linkboardPick Web app for the recipe, accept Native for the database (the default — the built-in engine with zero-config SQLite), React for the frontend, and let it install. When it finishes:
cd linkboard && pnpm devOpen http://localhost:3000 — you'll see the welcome page. Leave pnpm dev running; every change below hot-reloads.
::: tip Non-interactively (CI / agents)
CLAUDECODE=1 npx create-rudder linkboard --recipe=web-app --framework=react --install=true produces the same project with no prompts.
:::
2. Define the table and its model
The table comes first — describe it with a migration, the native engine's tracked unit of schema change:
pnpm rudder make:migration create_links_tableThat writes a timestamped stub into database/migrations/. Fill in the blueprint:
// database/migrations/<timestamp>_create_links_table.ts
import { Migration, Schema } from '@rudderjs/database'
export default class extends Migration {
async up() {
await Schema.create('links', (t) => {
t.id() // auto-increment integer primary key
t.string('title')
t.string('url')
t.timestamps() // createdAt + updatedAt
})
}
async down() {
await Schema.dropIfExists('links')
}
}Apply it:
pnpm rudder migratemigrate does two things: it creates the table, and it regenerates .rudder/types/models.d.ts — a typed map of every table's columns, introspected from the real database.
That second part is why the model is now tiny. Create app/Models/Link.ts:
// app/Models/Link.ts
import { Model } from '@rudderjs/orm'
export class Link extends Model.for<'links'>() {
static table = 'links'
static fillable = ['title', 'url'] // columns mass-assignable from create()/update()
// no id!/title!/url! fields — column types come from the generated registry
}Model.for<'links'>() binds the class to the generated registry, so link.id (number), link.title, link.url, and link.createdAt (Date) are all typed from the migrated schema — they can't drift from the database. static fillable is the allowlist of columns a form can set — any other key in the request body is dropped before it reaches the database, so a crafted payload can't write columns you didn't intend.
::: tip Using Prisma or Drizzle instead?
The same model API works on all three engines — only the schema step differs. On Prisma you'd add the Link model to prisma/schema/modules.prisma and run pnpm rudder db:push && pnpm rudder db:generate, then declare the model's fields by hand (Model.for is native-only; Prisma generates its own typed client). See Prisma and Drizzle.
:::
3. Load the list — a controller view
A controller view is a route handler that returns a view by id. Open routes/web.ts and add the routes below. Your web-app project already defines const webMw = [CsrfMiddleware()] near the top — reuse it; if it isn't there, add the import and the line.
// routes/web.ts
import { Route } from '@rudderjs/router'
import { view } from '@rudderjs/view'
import { CsrfMiddleware } from '@rudderjs/middleware'
import { validate, z } from '@rudderjs/core'
import { Link } from '../app/Models/Link.js'
const webMw = [CsrfMiddleware()] // already present in a web-app scaffold
// List page
Route.get('/links', async () => {
const rows = await Link.query().orderBy('createdAt', 'DESC').get()
const links = rows.map(l => ({ id: l.id, title: l.title, url: l.url }))
return view('links', { links })
}, webMw)The view id 'links' maps to the file app/Views/Links.tsx and is served at /links — ids map to URLs 1:1 by default.
::: warning Pass plain objects to view(), not model instances
Props are serialized for SSR, and a model's prototype methods don't survive that boundary. Map to a plain object (as above) or call .toJSON().
:::
4. Build the view
Create app/Views/Links.tsx. The form submits with fetch and a CSRF token — the same pattern the scaffolded auth views use (see the CSRF note below):
// app/Views/Links.tsx
import { useState } from 'react'
import { navigate } from 'vike/client/router'
import { getCsrfToken } from '@rudderjs/middleware/client'
// Exporting `Props` opts this view into compile-time prop checks —
// the view('links', { links }) call above is now type-checked.
export interface Props {
links: { id: number; title: string; url: string }[]
}
export default function Links({ links }: Props) {
const [title, setTitle] = useState('')
const [url, setUrl] = useState('')
const [error, setError] = useState('')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
const res = await fetch('/links', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken(),
},
body: JSON.stringify({ title, url }),
})
if (res.ok) {
setTitle(''); setUrl('')
await navigate('/links') // re-run the route, refresh the list
} else {
const body = await res.json().catch(() => ({})) as { message?: string }
setError(body.message ?? 'Could not save the link.')
}
}
return (
<main style={{ maxWidth: 640, margin: '2rem auto', fontFamily: 'system-ui' }}>
<h1>Link board</h1>
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: 8 }}>
<input value={title} onChange={e => setTitle(e.currentTarget.value)} placeholder="Title" required />
<input value={url} onChange={e => setUrl(e.currentTarget.value)} placeholder="https://…" type="url" required />
<button type="submit">Add</button>
</form>
{error && <p style={{ color: 'crimson' }}>{error}</p>}
<ul>
{links.map(link => (
<li key={link.id}>
<a href={link.url} target="_blank" rel="noreferrer">{link.title}</a>
</li>
))}
</ul>
</main>
)
}5. Handle the submission — validate and persist
Back in routes/web.ts, add the POST handler:
// routes/web.ts (continued)
Route.post('/links', async (req, res) => {
const data = await validate(
z.object({
title: z.string().min(1).max(120),
url: z.string().url(),
}),
req,
)
const link = await Link.create(data)
return res.status(201).json({ data: { id: link.id, title: link.title, url: link.url } })
}, webMw)validate() merges the request's params, query, and body, parses them against the Zod schema, and returns a fully-typed object. On invalid input it throws ValidationError, which the framework renders as a 422 with a structured { message, errors } body — that's the body.message the view falls back to. Link.create(data) writes the row, respecting fillable.
6. Try it
With pnpm dev running, open http://localhost:3000/links, add a link, and watch it appear at the top of the list. Submit an empty title or a non-URL and you'll see the validation message — the server rejected it with a 422 before touching the database.
That's a complete vertical slice: route → validation → model → database → view.
A note on CSRF
A web-app project protects state-changing requests with CsrfMiddleware (that's the webMw you reused). Because of it, a plain <form method="post"> would be rejected — the canonical Rudder pattern is a client fetch that sends the token via the X-CSRF-Token header, read with getCsrfToken() from @rudderjs/middleware/client. The middleware issues the token on the GET request and validates it on the POST. The scaffolded app/Views/Auth/Login.tsx is the reference implementation — it does exactly this.
Keep going
Each of these is a small, self-contained next step:
- A detail page. Add
Route.get('/links/:link', …)and resolve the param to aLinkautomatically with route model binding. - Named routes. Chain
.name('links.index')and generate URLs withroute('links.index')— see Routing. - Make it the home page. Add
export const route = '/'to the top ofLinks.tsxand delete the welcome route. - Write a test.
@rudderjs/testingcan boot the app and assertGET /linksrenders andPOST /linkscreates a row — see Testing. - Move it into a controller. As the feature grows, group the routes into a class — see Controllers.
Where to next
- Routing — parameters, groups, named routes, model binding
- Models — the full query API, relations, scopes, casts
- Validation — form requests, custom rules, error shaping
- Frontend — controller views vs. Vike pages, layouts, page context