HTML Frontend
The server has a JSON API. Now let's add server-rendered HTML pages to the same app. The same Perch server that handles API requests will also serve HTML — page handlers call query functions directly, no HTTP round-trip needed. By the end of this page you'll be able to create, browse, edit, and delete notes in a web browser.
Step 1 — The html-builder library
Instead of building HTML with raw string concatenation, we'll use the html-builder library. It provides two core types:
Document— a fragment of escaped HTML. Documents combine with+and render to a string with.render().Attr— a single HTML attribute likeclass="card". Attributes are passed asArray[Attr].
The library also provides element functions (div, h1, p, anchor, form, input, etc.), attribute helpers (cls, id, href, attr, boolAttr), and a text() function that escapes user content automatically to prevent XSS.
Here's how it looks in practice:
import html.builder.(div, h1, p, text, cls)
let card = div([cls("card")]) {
h1 { text("Hello") } +
p { text("World") }
};
let html = card.render();
// <div class="card"><h1>Hello</h1><p>World</p></div>
Each element function takes an optional Array[Attr] and a closure that returns a Document. The closure is where composability comes from — you call other element functions inside it to build nested HTML. Closures return Document values, which you combine with +.
The key difference from raw strings: text("user input") escapes <, >, &, ", and ' automatically. You never build HTML by concatenating user data into strings — the type system prevents it.
Step 2 — Page layout
Create src/ui/layout.ks:
module notes.ui
import html.builder.(
Document,
div, nav, header, mainEl, anchor, button,
headEl, bodyEl, htmlDoc, title, style, meta, script,
text, raw,
cls, href, id, attr
)
public func page(pageTitle: String, content: () -> Document) -> Document {
raw("<!DOCTYPE html>") +
htmlDoc([attr("lang", "en")]) {
headEl {
meta([attr("charset", "UTF-8")]) +
meta([attr("name", "viewport"), attr("content", "width=device-width, initial-scale=1.0")]) +
title { text(pageTitle + " — Notes") } +
style { raw(appStyles()) }
} +
bodyEl([cls("app")]) { content() }
}
}
public func appShell(content: () -> Document) -> Document {
page("Notes") {
header([cls("topbar")]) {
nav([cls("topbar-nav")]) {
anchor([href("/")]) { raw("<strong>Notes</strong>") } +
div([cls("topbar-actions")]) {
anchor([href("/new"), cls("btn btn-primary")]) { text("New Note") }
}
}
} +
mainEl([id("content")]) { content() }
}
}
page wraps any content in a full HTML document with styles. appShell adds a top navigation bar with a "New Note" button, plus a #content area where notes appear.
Notice how attributes work: meta([attr("charset", "UTF-8")]) passes a single-element array. Multiple attributes go in the same array: [href("/new"), cls("btn btn-primary")].
Step 3 — Note views
Create src/ui/notes.ks:
module notes.ui
import html.builder.(
Document, Attr,
div, h1, h2, p, form, button, label, input, textarea, anchor, small,
text, raw, nothing,
cls, href, id, attr, boolAttr
)
import datetime.(Instant, Date, TimeZone)
import notes.models.(Note)
public func noteListView(notes: Array[Note]) -> Document {
if notes.count == 0 {
return div([cls("empty-state")]) {
h2 { text("No notes yet") } +
p { text("Create your first note to get started.") } +
anchor([href("/new"), cls("btn btn-primary")]) { text("New Note") }
}
};
var cards = Document();
for note in notes {
cards.append(noteCard(note))
};
div([cls("note-list")]) { cards }
}
func noteCard(note: Note) -> Document {
let preview = if note.body.byteCount > 140 {
note.body.asSlice().subslice(from: 0, to: 140).toOwned() + "..."
} else {
note.body
};
let dateStr = formatNoteDate(note.createdAt);
anchor([href("/note/" + note.id.formatted()), cls("note-card")]) {
div([cls("note-card-title")]) { text(note.title) } +
div([cls("note-card-preview")]) { text(preview) } +
div([cls("note-card-date")]) { text(dateStr) }
}
}
public func noteDetailView(note: Note) -> Document {
let noteId = note.id.formatted();
let dateStr = formatNoteDate(note.createdAt);
div([cls("note-detail")]) {
div([cls("note-toolbar")]) {
anchor([href("/"), cls("btn")]) { text("Back") } +
div([cls("toolbar-actions")]) {
anchor([href("/note/" + noteId + "/edit"), cls("btn")]) { text("Edit") } +
form([attr("method", "POST"), attr("action", "/note/" + noteId + "/delete")]) {
button([attr("type", "submit"), cls("btn btn-danger")]) { text("Delete") }
}
}
} +
h1 { text(note.title) } +
small([cls("note-date")]) { text(dateStr) } +
div([cls("note-body")]) { text(note.body) }
}
}
public func noteEditorView(noteId: String, noteTitle: String, noteBody: String) -> Document {
let isNew = noteId.byteCount == 0;
let action = if isNew { "/new" } else { "/note/" + noteId + "/edit" };
div([cls("note-editor")]) {
h2 { text(if isNew { "New Note" } else { "Edit Note" }) } +
form([attr("method", "POST"), attr("action", action)]) {
div([cls("field-group")]) {
label([attr("for", "title")]) { text("Title") } +
input([attr("type", "text"), attr("name", "title"), attr("id", "title"),
attr("value", noteTitle), boolAttr("required")])
} +
div([cls("field-group")]) {
label([attr("for", "body")]) { text("Body") } +
textarea([attr("name", "body"), attr("id", "body"), attr("rows", "12"),
boolAttr("required")]) { text(noteBody) }
} +
div([cls("editor-actions")]) {
anchor([href(if isNew { "/" } else { "/note/" + noteId }), cls("btn")]) { text("Cancel") } +
button([attr("type", "submit"), cls("btn btn-primary")]) {
text(if isNew { "Create" } else { "Save" })
}
}
}
}
}
func formatNoteDate(epochSecs: Int64) -> String {
let instant = Instant(secondsSinceEpoch: epochSecs);
let date = instant.toDate(in: TimeZone.utc);
let weekday = date.weekday;
"\(weekday), \(date)"
}
The note views take Note structs directly — note.title, note.id.formatted() — instead of digging through JSON values. Since the page handlers live in the same server as the database, they have direct access to typed models.
noteEditorView handles both creating and editing by checking whether noteId is empty.
The formatNoteDate function converts a Unix timestamp to a readable string. It creates an Instant from the epoch seconds, converts to a Date in UTC, and reads the weekday. Because both Weekday and Date conform to Formattable, string interpolation produces something like "Tuesday, 2026-05-27".
Step 4 — Styles
Create src/ui/styles.ks. This is just CSS returned from a function:
module notes.ui
public func appStyles() -> String {
##"
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, sans-serif; background: #09090b; color: #fafafa; min-height: 100vh; }
.topbar { background: #18181b; border-bottom: 1px solid #27272a; padding: 0.75rem 1.5rem; }
.topbar-nav { max-width: 900px; margin: 0 auto; display: flex; justify-content: space-between; align-items: center; }
.topbar-nav a { color: #fafafa; text-decoration: none; }
.topbar-actions { display: flex; gap: 0.5rem; }
#content { max-width: 900px; margin: 2rem auto; padding: 0 1.5rem; }
.btn { display: inline-block; padding: 0.5rem 1rem; border-radius: 6px; border: 1px solid #27272a; background: transparent; color: #fafafa; cursor: pointer; text-decoration: none; font-size: 0.875rem; }
.btn:hover { background: #27272a; }
.btn-primary { background: #2563eb; border-color: #2563eb; }
.btn-primary:hover { background: #1d4ed8; }
.btn-danger { color: #ef4444; border-color: #ef4444; }
.btn-danger:hover { background: #ef4444; color: white; }
.btn-full { width: 100%; }
.field-group { margin-bottom: 1rem; }
.field-group label { display: block; margin-bottom: 0.25rem; font-size: 0.875rem; color: #a1a1aa; }
.field-group input, .field-group textarea { width: 100%; padding: 0.625rem; border-radius: 6px; border: 1px solid #27272a; background: #09090b; color: #fafafa; font-size: 0.875rem; font-family: inherit; }
.note-list { display: grid; gap: 0.75rem; }
.note-card { display: block; background: #18181b; border: 1px solid #27272a; border-radius: 8px; padding: 1rem; text-decoration: none; color: #fafafa; transition: border-color 0.15s; }
.note-card:hover { border-color: #3f3f46; }
.note-card-title { font-weight: 600; margin-bottom: 0.25rem; }
.note-card-preview { color: #a1a1aa; font-size: 0.875rem; }
.note-card-date { color: #52525b; font-size: 0.75rem; margin-top: 0.5rem; }
.note-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
.toolbar-actions { display: flex; gap: 0.5rem; }
.note-date { color: #71717a; display: block; margin-bottom: 1rem; }
.note-body { margin-top: 1rem; line-height: 1.7; white-space: pre-wrap; }
.note-editor { max-width: 700px; }
.editor-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 0.5rem; }
.empty-state { text-align: center; margin-top: 4rem; }
.empty-state h2 { margin-bottom: 0.5rem; }
.empty-state p { color: #a1a1aa; margin-bottom: 1.5rem; }
"##
}
The ##"..."## syntax is a raw string literal — no escape processing, so backslashes and quotes can appear freely. The CSS gives the app a dark theme.
Step 5 — Form helpers
Add form parsing to src/helpers.ks alongside the JSON helpers from the previous page:
import http.url.(percentDecode)
public func parseForm(body: String) -> Dictionary[String, String] {
var result = Dictionary[String, String]();
for part in body.split("&") {
let kv = part.trimmed();
match kv.firstIndex(of: "=") {
.Some(eqIdx) => {
let key = percentDecode(kv.subslice(from: kv.start, to: eqIdx.value).toOwned());
let value = percentDecode(kv.subslice(from: eqIdx.value + 1, to: kv.end).toOwned());
result.insert(key, value);
},
.None => {}
}
};
result
}
public func formField(form: Dictionary[String, String], name: String) -> String {
match form(name) {
.Some(v) => v,
.None => ""
}
}
parseForm splits URL-encoded form data — title=My+note&body=Hello — into a dictionary. It iterates over &-separated parts, finds the = separator with firstIndex(of:), and uses subslice to extract the key and value, decoding percent-encoded characters. formField is a convenience that returns an empty string instead of an Optional when a key is missing.
Step 6 — Page handlers
Update src/main.ks to serve HTML pages alongside the JSON API:
module notes.main
import perch.app.(App)
import perch.request.(Request)
import perch.response.(Response)
import perch.middleware.(Logger)
import http.content.(Html)
import talon.sqlite.shared_database.(SharedDatabase)
import notes.context.(AppCtx)
import notes.db.(initSchema, listNotes, findNoteById, createNote, updateNote, deleteNote)
import notes.helpers.(parseForm, formField)
import notes.ui.(appShell, noteListView, noteDetailView, noteEditorView)
import notes.handlers.(
handleListNotes, handleCreateNote,
handleGetNote, handleUpdateNote, handleDeleteNote
)
func handleHome(req: Request, ctx: AppCtx) -> Response {
let db = ctx.db;
guard let .Ok(notes) = listNotes(db) else { return Response.internalServerError() }
let html = appShell { noteListView(notes) };
Response.ok(Html(html.render()))
}
func handleViewNote(req: Request, ctx: AppCtx) -> Response {
guard let .Some(noteId) = req.param("id") else {
return Response.redirect(to: "/")
}
guard let .Some(id) = Int64(parsing: noteId) else {
return Response.redirect(to: "/")
}
let db = ctx.db;
guard let .Ok(some note) = findNoteById(db, id: id) else {
return Response.redirect(to: "/")
}
let html = appShell { noteDetailView(note) };
Response.ok(Html(html.render()))
}
func handleEditForm(req: Request, ctx: AppCtx) -> Response {
guard let .Some(noteId) = req.param("id") else {
return Response.redirect(to: "/")
}
guard let .Some(id) = Int64(parsing: noteId) else {
return Response.redirect(to: "/")
}
let db = ctx.db;
guard let .Ok(some note) = findNoteById(db, id: id) else {
return Response.redirect(to: "/")
}
let html = appShell { noteEditorView(noteId, note.title, note.body) };
Response.ok(Html(html.render()))
}
func handleEditSubmit(req: Request, ctx: AppCtx) -> Response {
guard let .Some(noteId) = req.param("id") else {
return Response.redirect(to: "/")
}
guard let .Some(id) = Int64(parsing: noteId) else {
return Response.redirect(to: "/")
}
let form = parseForm(req.body);
let formTitle = formField(form, "title");
let formBody = formField(form, "body");
let _ = updateNote(ctx.db, id: id, formTitle, formBody);
Response.redirect(to: "/note/" + noteId)
}
func handleNewForm(req: Request, ctx: AppCtx) -> Response {
let html = appShell { noteEditorView("", "", "") };
Response.ok(Html(html.render()))
}
func handleNewSubmit(req: Request, ctx: AppCtx) -> Response {
let form = parseForm(req.body);
let formTitle = formField(form, "title");
let formBody = formField(form, "body");
guard let .Ok(note) = createNote(ctx.db, formTitle, formBody) else {
return Response.internalServerError()
}
Response.redirect(to: "/note/" + note.id.formatted())
}
func handleDeleteSubmit(req: Request, ctx: AppCtx) -> Response {
guard let .Some(noteId) = req.param("id") else {
return Response.redirect(to: "/")
}
guard let .Some(id) = Int64(parsing: noteId) else {
return Response.redirect(to: "/")
}
let _ = deleteNote(ctx.db, id: id);
Response.redirect(to: "/")
}
@main
func main() {
match initSchema("notes.db") {
.Ok(_) => {},
.Err(e) => {
println("Failed to initialize database: " + e.description());
return
}
};
let db = match SharedDatabase("notes.db") {
.Ok(d) => d,
.Err(e) => {
println("Failed to open database: " + e.description());
return
}
};
let ctx = AppCtx(db: db);
var app = App[AppCtx](ctx);
app.use(Logger[AppCtx]());
// JSON API
app.route(get: "/api/notes", handleListNotes);
app.route(post: "/api/notes", handleCreateNote);
app.route(get: "/api/notes/:id", handleGetNote);
app.route(post: "/api/notes/:id", handleUpdateNote);
app.route(delete: "/api/notes/:id", handleDeleteNote);
// HTML pages
app.route(get: "/", handleHome);
app.route(get: "/note/:id", handleViewNote);
app.route(get: "/note/:id/edit", handleEditForm);
app.route(post: "/note/:id/edit", handleEditSubmit);
app.route(get: "/new", handleNewForm);
app.route(post: "/new", handleNewSubmit);
app.route(post: "/note/:id/delete", handleDeleteSubmit);
let port: UInt16 = 8080;
println("Notes app listening on http://localhost:8080");
match app.listen(port) {
.Ok(_) => {},
.Err(e) => { println("Error: " + e.description()); }
}
}
A few things to notice:
import http.content.(Html)brings in the HTML content type.Html(string)wraps a string so Perch sets theContent-Typeheader totext/html.html.render()converts aDocumentto aString. This is the bridge between the type-safe html-builder world and the raw HTTP response.- Page handlers call query functions directly —
listNotes(db),findNoteById(db, id:)— instead of making HTTP requests. Same process, same database connection, no serialization round-trip. Response.redirect(to: "/")sends a 302 redirect. Theto:label is required.- The JSON API routes move under
/api/so they don't clash with the HTML routes. The JSON endpoints from the previous page still work — trycurl http://localhost:8080/api/notes. - Forms submit via POST and redirect on success — the standard Post/Redirect/Get pattern.
Step 7 — Try it
flock run
Open http://localhost:8080 in your browser. Create some notes, click on them, edit, and delete. Each note card shows when it was created. Everything works with full page reloads — the next page adds HTMX to make it feel like a single-page app.
What you saw
| Step | Feature |
|---|---|
| 1 | html-builder library: Document, Attr, automatic escaping |
| 2 | Template composition: page shells with html-builder |
| 3 | Note views with typed model access, Instant and Date for timestamps, Weekday formatting |
| 4 | Raw string literals (##"..."##) for CSS |
| 5 | URL-encoded form parsing |
| 6 | Html content type, Document.render(), Response.redirect(to:), same-server HTML + JSON |
The html-builder library is the key idea. There's no template language — just Kestrel functions that return Document values. The compiler type-checks your templates, text() escapes user input, and you compose them the same way you compose any other code.