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; the inner todo::Model is the
SeaORM shape that route model binding fetches.
4. Controller
Open src/controllers/todo.rs:
use ;
use crate;
pub async
pub async
pub async
pub async
pub async
pub async
pub async
A few things to notice:
- Route model binding is automatic. Declaring
todo: todo::Modeltells the#[handler]macro to look up{todo}in the route path, fetch the SeaORM row by primary key, and 404 if it's missing. The parameter name must match the route placeholder. - The macro hands you
todo::Model; the Eloquent surface lives onTodo. The two are bridged by aFromimpl emitted by#[suprnova::model], solet todo: Todo = todo.into();is the one-line conversion.Todois 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 it has to match the
SeaORM model's primary-key type (here, i64). 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!("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
