Tutorial: build a bookmarks app
This tutorial builds a small but complete PlumeKit app: a bookmarks list you can add to and delete from, with server-side validation. It has the shape of a real app, so along the way you use the pieces you would reach for every day. It takes about 20 minutes, and every snippet is copy-paste-ready.
By the end you will know how to:
- Wire the conventional RESTful actions through a controller, in one call
- Build Plume views: HTML templates split across files, with a shared layout
- Use the ORM: an
@Modeltype, explicit migrations and typed queries - Handle a form: read a POST, validate it and re-render errors
- Show a flash message: a one-time notice after a redirect
If you just want the reference for each feature, browse the docs sidebar; this page is the hands-on path.
Note: You need a Swift 6.3 toolchain (swift --version) and the plumekit CLI: either the ./plumekit wrapper in a scaffolded project (it downloads the matching release for you) or a local build of the framework. See Getting started.
1. Scaffolding and running
Create the app and start it:
$plumekit new bookmarks$cd bookmarks$./plumekit dev$# → native server on http://127.0.0.1:8080, restarting on every changeplumekit new asks a few questions. Turn database on when it asks about capabilities (space toggles it); accept the defaults for everything else. Open <http://127.0.0.1:8080/> and you'll see the starter page. Leave dev running in this terminal; it rebuilds and restarts whenever you save a file.
Take a quick look around:
bookmarks/
Package.swift # the SwiftPM manifest
plumekit.toml # capabilities + per-target config
plumekit # the CLI wrapper (commit it; it pins the version)
Sources/App/
App.swift # buildApp(): app setup + middleware
Routes.swift # registerRoutes(): your routes
Database/Database.swift # runMigrations() and runSeed()
Views/
Layout.plume # the shared page shell (a component with a slot)
HomePage.plume # a page that fills the layoutEverything runs through buildApp() in Sources/App/App.swift. The native server and the Cloudflare Worker both call it, so your app behaves identically on each.
2. A model and a migration
Define the data first. Create Sources/App/Models/Bookmark.swift:
import PlumeORM
@Model
final class Bookmark: Model {
var id: Int // the primary key, by convention
var title: String
var url: String
}@Model reads the type at compile time and emits the table schema, a row codec and typed query columns. The table name is the pluralised, snake-cased type name: here, bookmarks.
Creating the table
The table itself comes from a migration. Migrations are individual files under Sources/App/Database/Migrations/; they run in filename order and are discovered automatically, so you don't register them anywhere. Create one:
$./plumekit generate migration CreateBookmarks$# + Sources/App/Database/Migrations/20260101120000_CreateBookmarks.swiftOpen that file and describe the change explicitly with the schema builder:
import PlumeORM
let createBookmarks = Migration(
version: "20260101120000_create_bookmarks",
up: { db in
try await db.createTable("bookmarks") { t in
t.id()
t.text("title")
t.text("url")
}
},
down: { db in try await db.dropTable("bookmarks") }
)Spelling the columns out keeps the migration a frozen record: editing the Bookmark model later never rewrites it. The builder covers creating, altering, renaming and dropping tables, columns, foreign keys and indexes. For anything it doesn't, run db.query("...") directly.
Apply it:
$./plumekit migrate$# plumekit migrate: applied 1 change(s)$# + 20260101120000_create_bookmarksThe Migrator records what has run in a schema_migrations ledger, so re-running is a no-op. See Migrations for altering tables, rollbacks and seeders.
3. A page, split across view files
PlumeKit's view layer is Plume: .plume templates that compile to render functions your handlers call. Views are split across files: a shared layout plus one file per page.
The layout
The scaffold already set up Views/Layout.plume as the page shell, where @slot is the hole a page's content fills:
@component Layout(title: String) {<!doctype html>
<html>
<head><title>{title}</title></head>
<body><main>@slot</main></body>
</html>}The bookmarks page
Create the bookmarks page, Views/BookmarksPage.plume. It takes the list to show and an optional error message, and calls @Layout for the shell:
@component BookmarksPage(bookmarks: [Bookmark], error: String = "") {@Layout(title: "Bookmarks") {
<h1>Bookmarks</h1>
@if error != "" {
<p class="error">{error}</p>
}
<form method="post" action="/bookmarks">
@csrf
<input name="title" placeholder="Title">
<input name="url" placeholder="https://example.com">
<button type="submit">Add</button>
</form>
@if bookmarks.size > 0 {
<ul>@for bookmark in bookmarks {
<li>
<a href="{bookmark.url}">{bookmark.title}</a>
<form method="post" action="/bookmarks/{bookmark.id}" style="display:inline">
@csrf
<input type="hidden" name="_method" value="delete">
<button type="submit">×</button>
</form>
</li>
}</ul>
} else {
<p>No bookmarks yet.</p>
}
}}A few things to notice:
{title}and{bookmark.title}are HTML-escaped by default, so untrusted values are safe.@if/@forare control flow.@csrfrenders the hidden token that form protection (on by default) checks on every POST. It's automatic: nothing to pass into the view, nothing to wire up in the handler.- HTML forms can only GET or POST, so the delete form POSTs with a hidden
_method=deletefield; PlumeKit routes it to the controller'sdestroyaction.
See Components and Syntax for the full language.
4. A controller
Rather than scatter closures in buildApp(), group the bookmark actions in a controller. Create Sources/App/Controllers/BookmarksController.swift:
import PlumeCore
import PlumeRuntime
import PlumeORM
struct BookmarksController: Controller {
// GET /bookmarks: list newest first and show the add form.
func index(_ request: Request) async throws -> Response {
let bookmarks = try await Bookmark.query().order(by: Bookmark.id, .descending).all()
return .view(bookmarksPage(bookmarks: bookmarks))
}
// POST /bookmarks: add one, or re-render the form with an error.
func create(_ request: Request) async throws -> Response {
let title = request.form["title"] ?? ""
let url = request.form["url"] ?? ""
if title.isEmpty || url.isEmpty {
let bookmarks = try await Bookmark.query().order(by: Bookmark.id, .descending).all()
return .view(bookmarksPage(bookmarks: bookmarks,
error: "Title and URL are both required."))
}
_ = try await Bookmark(title: title, url: url).save()
return .redirect(to: "/bookmarks").flash("Bookmark added")
}
// DELETE /bookmarks/:id removes one.
func destroy(_ request: Request) async throws -> Response {
if let bookmark = try await Bookmark.find(request) {
try await bookmark.delete()
}
return .redirect(to: "/bookmarks")
}
}Controller gives every action a default "405 Method Not Allowed", so you implement only the three you need. Inside a handler, ORM calls use the current request's database automatically (the ambient database). Bookmark.find(request) reads the :id route parameter and loads the row (or returns nil). .flash(_:) attaches a one-time message to the redirect, shown in the next step.
5. Wiring it up
Registering the routes
Routes live in Sources/App/Routes.swift. Open it and register the controller. app.resources maps the conventional RESTful routes to the controller's actions in one call:
import PlumeCore
import PlumeRuntime
func registerRoutes(_ app: Application) {
app.get("/") { _ in .redirect(to: "/bookmarks") }
app.resources("bookmarks", BookmarksController())
}resources("bookmarks", …) wires GET /bookmarks → index, POST /bookmarks → create and DELETE /bookmarks/:id → destroy (plus show/update if you add them). Replace the starter /, /hello and /count demo routes with these two, and delete the now-unused Views/HomePage.plume. CSRF protection is already wired in buildApp() (App.swift), so there's nothing to add there.
Showing the flash message
To show the "Bookmark added" flash, render it in the layout so every page picks it up. Edit Views/Layout.plume:
@component Layout(title: String, flash: String = "") {<!doctype html>
<html>
<head><title>{title}</title></head>
<body><main>
@if flash != "" { <p class="flash">{flash}</p> }
@slot
</main></body>
</html>}Then forward the flash from the page. In Views/BookmarksPage.plume, add a flash parameter and pass it to @Layout:
@component BookmarksPage(bookmarks: [Bookmark], error: String = "", flash: String = "") {@Layout(title: "Bookmarks", flash: flash) {and hand it in from index:
return .view(bookmarksPage(bookmarks: bookmarks, flash: request.flash?.message ?? ""))6. Running it
./plumekit dev already rebuilt on each save. Open <http://127.0.0.1:8080/bookmarks>, add a bookmark with the form, and it appears in the list with a "Bookmark added" notice. Submit with an empty field and the form comes back with the error. Click × to delete.
That's the full loop: a controller renders a Plume view, forms POST to actions, validation re-renders, and the ORM persists.
Note: If a handler throws while you're developing, dev shows a full error page (the error, the request and your route table) instead of a bare 500. In production it's a clean 500.
Where to go next
You've used the core of PlumeKit. From here:
- Validation rules: declare them on the model so
save()enforces them. See Validations. - Pagination:
Bookmark.query().paginate(page: 1, per: 20)returns aPagewithnextURL/previousURLand totals. See the ORM. - Auth:
plumekit generate authscaffolds registration, login and sessions. See Auth. - Deploy it:
./plumekit deploybuilds and ships to your configured target (Cloudflare Worker, AWS Lambda or a container). See Deploying.
The same app you just built runs unchanged on the native server and on Cloudflare; see Portability.