Walkthrough
Every file in the starter, the messages feature, and how to go further.
src/
hooks.server.c # boots the norns runtime, eager-loads feature modules
app.css, app.html # global styles + html shell
routes/
+layout.c, +layout.n # app shell with the <Header>
+page.n # one-page demo: form + list of messages
+page.server.c # load() + send action wired to the messages feature
api/messages/+server.c # GET + POST endpoint through route()
lib/
components/Header.n # site nav
norns/
messages/ # one feature folder, in-memory store
server/{module,repo,service,public}.c
shared/schema.c # valibot schema shared by the form action and the API route
tests/
civet-loader.js # bun plugin: compiles .c on import so tests can load feature code
messages.test.js # the feature through the DI container
wrangler.toml # commented Cloudflare stub#The starter feature
src/lib/norns/messages/ is a complete feature folder in miniature. The feature folders page prints it in full.
repo.cis an in-memorylist/add. Replace it with a database when you need persistence; onlylistandaddare called from outside.service.cholds the business logic and takes the validated input type fromshared/schema.c.module.cregistersmessages.repoandmessages.serviceon the container. It is the only fileboot()reaches.public.cexportsmessages(container), the facade routes import.
#How it is wired
src/routes/+page.server.ccallsmessages(container).list()inpage.loadandmessages(container).create(input)in thesendaction, validated bysendMessageSchema.src/routes/+page.nrenders<Form>from norns-ui. Field errors arrive through theformprop and the Form context; no per-page boilerplate.src/routes/api/messages/+server.cexposes the same operations asGETandPOSTthroughroute(). Responses are content-negotiated by the TRON serializer installed inhooks.server.c, soAccept: application/trongets TRON and everyone else gets JSON.hooks.server.cglobs./lib/norns/*/server/module.cand callsboot({ features, serializer: tronSerializer() }).
#The test
tests/messages.test.js boots the feature's module into a fresh container and calls the facade, the same way a route would. That is the pattern for every feature: no HTTP, no mocks of the framework, just the container. Bun loads the Civet files through the small plugin in tests/civet-loader.js.
#Going further
Delete src/lib/norns/messages/ and src/routes/+page.* and rewrite, or copy the feature folder for each new domain. That is the whole pattern.
For persistence, bind a database in one module (see Drivers or Cloudflare D1) and resolve it from the repo. For a fuller example with SQLite, dynamic routes, error pages and a component-heavy page, clone norns-demo.
The repository's CLAUDE.md carries the pitfalls and the verification order so coding agents working in your app follow them.