A vertical slice of Suprnova that exercises the full stack: a migration, a
#[suprnova::model], Inertia-rendered Svelte 5 pages, route model binding,
form validation, and type-safe route helpers generated from routes.rs.
Work through this once and the project loop - migration, model, controller,
route, page - becomes muscle memory.
This assumes you've followed Installation and have the
suprnova CLI on your PATH. The scaffolder defaults to Svelte 5, which
is what this tutorial uses.
What you'll build
A todo page with create, list, toggle-complete, edit, and delete. No
separate JSON API: Inertia serialises props and the Svelte page consumes
them as $props() - the same struct flows from Rust to the browser.
1. Scaffold
2. Migration
Open the new migration under src/migrations/:
use *;
;
Both created_at and updated_at are present because the model in the
next step uses timestamps, which expects both columns and auto-manages
them. Then run migrations and regenerate entities:
db:sync runs pending migrations and refreshes the SeaORM entity layer
the #[suprnova::model] macro relies on.
3. Model
Create src/models/todo.rs:
use ;
use model;
// The model macro emits an inner `todo` module with the SeaORM
// Entity, ActiveModel, Column, and Model types. Re-export the ones
// you want to reach from outside the file.
pub use ;
Wire the new module in src/models/mod.rs:
The fillable list gates mass assignment; timestamps auto-manages
created_at / updated_at on every save. The user-facing Todo struct
is the type you'll work with in handlers, route model binding included;
the inner todo module holds the SeaORM entity the macro generates.
4. Controller
Open src/controllers/todo.rs:
use ;
use crateTodo;
pub async
pub async
pub async
pub async
pub async
pub async
pub async
A few things to notice:
- Route model binding is automatic.
#[suprnova::model]makesTodobindable, so declaringtodo: Todotells the#[handler]macro to read{todo}from the route path and fetch the row throughTodo::query()by its primary key. A value that matches no row, or is not a number, answers 404 before the handler runs, and beforeupdatereads its form. The argument's name must match the route placeholder; the router checks that when it starts. - The bound value is the model.
todois aTodo, the type that carriesupdate,delete, and the rest of the user-facing API. #[request]covers validation. Adding it to a struct generatesDeserialize,Validate, andFormRequest- the framework rejects malformed input with a 422 before your handler runs. There's no need to also deriveInertiaPropson a request DTO; that derive is for outgoing page props.- Mass assignment goes through
attrs!.Todo::create(attrs! { ... })andtodo.update(attrs! { ... })route through the fillable filter, so fields not in the model'sfillablelist silently drop instead of bypassing the guard. updateanddeleteconsumeself. That's whytogglereads!todo.completedinto a local before callingtodo.update(...).
Register the new controller module in src/controllers/mod.rs:
Why Suprnova diverges
In Laravel, the same controller would normally return JSON for an API or
a Blade view for a server-rendered page. Suprnova returns Inertia
responses for both initial loads and SPA navigations - the framework
detects the X-Inertia header and serves HTML or JSON accordingly,
without a parallel API layer. You write your handlers once, your
frontend stays a real SPA, and there's no second router to keep in
sync. See Inertia Responses for the
mechanics.
5. Routes
src/routes.rs:
use ;
use cratetodo;
routes!
The {todo} placeholder is what route model binding hooks onto: it has
to match the handler parameter name (todo), and its value is parsed as
the model's primary-key type (here, i64). A route whose handler reads a
parameter its path does not declare stops the server at startup. The
optional .name(...) suffix is what the route-type generator in the next
step uses to build the frontend helpers.
6. Generate TypeScript types
generate-types does two things in one pass:
- Walks every
#[derive(InertiaProps)]struct insrc/and writes them tofrontend/src/types/inertia-props.ts. - Walks
src/routes.rsand writes typed URL builders for every named route tofrontend/src/types/routes.ts.
The route helpers come out as a nested object - controllers.todos.toggle({ todo: "1" })
returns a { url, method } pair that Inertia 3's Link and router
accept directly. Path parameters are typed; the compiler catches a
missing todo argument before the page hits the browser.
You don't have to edit these files. Re-run suprnova generate-types
whenever you add or rename props/routes, or pass --watch to keep them
in sync as you go.
7. Pages
Each page lives under frontend/src/pages/Todos/. The names match the
strings you pass to inertia_response!, so inertia_response!(&req, "Todos/Index", ...)
resolves to frontend/src/pages/Todos/Index.svelte.
Index
frontend/src/pages/Todos/Index.svelte:
<script lang="ts">
import { Link, router } from '@inertiajs/svelte'
import type { Todo, TodoIndexProps } from '../../types/inertia-props'
import { controllers } from '../../types/routes'
let { todos }: TodoIndexProps = $props()
function toggle(todo: Todo) {
router.visit(controllers.todos.toggle({ todo: String(todo.id) }))
}
function remove(todo: Todo) {
if (confirm('Delete this todo?')) {
router.visit(controllers.todos.destroy({ todo: String(todo.id) }))
}
}
</script>
<div class="mx-auto max-w-2xl p-8">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold">My Todos</h1>
<Link
href={controllers.todos.create()}
class="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
Add todo
</Link>
</div>
{#if todos.length === 0}
<p class="text-center text-gray-500">No todos yet.</p>
{:else}
<ul class="space-y-2">
{#each todos as todo (todo.id)}
<li class="flex items-center gap-3 rounded border p-3">
<input
type="checkbox"
checked={todo.completed}
onchange={() => toggle(todo)}
class="h-5 w-5"
/>
<span class={todo.completed ? 'flex-1 text-gray-400 line-through' : 'flex-1'}>
{todo.title}
</span>
<Link
href={controllers.todos.edit({ todo: String(todo.id) })}
class="text-blue-600 hover:underline"
>
Edit
</Link>
<button
onclick={() => remove(todo)}
class="text-red-600 hover:underline"
>
Delete
</button>
</li>
{/each}
</ul>
{/if}
</div>
Create
frontend/src/pages/Todos/Create.svelte:
<script lang="ts">
import { Link, useForm } from '@inertiajs/svelte'
import { controllers } from '../../types/routes'
const form = useForm({ title: '' })
function submit(e: SubmitEvent) {
e.preventDefault()
form.post(controllers.todos.store().url)
}
</script>
<div class="mx-auto max-w-md p-8">
<h1 class="mb-6 text-2xl font-bold">Create todo</h1>
<form onsubmit={submit} class="space-y-4">
<div>
<label for="title" class="mb-1 block text-sm font-medium">Title</label>
<input
id="title"
type="text"
bind:value={form.title}
class="w-full rounded border px-3 py-2"
placeholder="What needs to be done?"
/>
{#if form.errors?.title}
<p class="mt-1 text-sm text-red-600">{form.errors.title}</p>
{/if}
</div>
<div class="flex gap-3">
<button
type="submit"
disabled={form.processing}
class="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{form.processing ? 'Creating...' : 'Create'}
</button>
<Link
href={controllers.todos.index()}
class="px-4 py-2 text-gray-600 hover:underline"
>
Cancel
</Link>
</div>
</form>
</div>
Edit
frontend/src/pages/Todos/Edit.svelte:
<script lang="ts">
import { Link, useForm } from '@inertiajs/svelte'
import type { TodoFormProps } from '../../types/inertia-props'
import { controllers } from '../../types/routes'
const props: TodoFormProps = $props()
const todo = props.todo!
const form = useForm({ title: todo.title })
function submit(e: SubmitEvent) {
e.preventDefault()
form.put(controllers.todos.update({ todo: String(todo.id) }).url)
}
</script>
<div class="mx-auto max-w-md p-8">
<h1 class="mb-6 text-2xl font-bold">Edit todo</h1>
<form onsubmit={submit} class="space-y-4">
<div>
<label for="title" class="mb-1 block text-sm font-medium">Title</label>
<input
id="title"
type="text"
bind:value={form.title}
class="w-full rounded border px-3 py-2"
/>
{#if form.errors?.title}
<p class="mt-1 text-sm text-red-600">{form.errors.title}</p>
{/if}
</div>
<div class="flex gap-3">
<button
type="submit"
disabled={form.processing}
class="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{form.processing ? 'Saving...' : 'Save'}
</button>
<Link
href={controllers.todos.index()}
class="px-4 py-2 text-gray-600 hover:underline"
>
Cancel
</Link>
</div>
</form>
</div>
The equivalent React 19 and Vue 3.5 starters take the same props through their own templating - the backend doesn't change.
8. Run it
Visit http://127.0.0.1:8765/todos, add a few rows, toggle them, edit
one, delete another. The page transitions happen through Inertia - no
full reload - and every form submission validates server-side before
the redirect lands.
What just happened
| Layer | File | What it does |
|---|---|---|
| Schema | src/migrations/m_create_todos_table.rs |
Creates the todos table |
| Model | src/models/todo.rs |
The user-facing Todo struct + the inner SeaORM module |
| HTTP | src/controllers/todo.rs |
Seven #[handler]s, including route model binding |
| Router | src/routes.rs |
Named routes that drive the generated route helpers |
| Props | frontend/src/types/inertia-props.ts |
Generated from #[derive(InertiaProps)] |
| Routes | frontend/src/types/routes.ts |
Generated from named routes in routes.rs |
| Pages | frontend/src/pages/Todos/*.svelte |
The three Svelte 5 pages that consume the props |
That's the standard Suprnova feature loop: migration -> model -> controller
-> route -> page, with suprnova generate-types regenerating the
TypeScript bridge whenever you reshape props or rename a route.
Next
- Eloquent -
attrs!, the query builder, casts, scopes, observers - Validation - what
#[request]and#[derive(Validate)]give you - Routing - named routes, route model binding, resource routing, signed URLs
- Inertia Responses -
inertia_response!, partial reloads, shared props - Authentication - adding per-user todos with the starter's session auth
