[← Back to README](./README.md) # Guide This guide builds a todo app one concept at a time. See the [Reference](./REFERENCE.md) for full options on each step, helper, and field. Nerack discovers assets by file location and seeds each one into the context of the module that owns it (see [Assets](./REFERENCE.md#assets)). * [1. Pages and Templates](#1-pages-and-templates) * [2. Show Data](#2-show-data) * [3. Accept Input](#3-accept-input) * [4. Nested Data](#4-nested-data) * [5. Calling APIs](#5-calling-apis) * [6. Tasks](#6-tasks) * [7. Modules and Events](#7-modules-and-events) ### 1. Pages and Templates Each `http(...)` declares a named resource and URL, and each verb pipeline is a list of steps. `html("home", "home_s")` renders the template asset `home` into context key `home_s`, and `http_response("home_s")` sends it. `{{url:verb:name}}` builds a link to a resource: the verb first, then the resource name. Any `:params` in the pattern are filled from the current scope by matching key names. Both pages share a layout, so `home` doubles as the layout: it declares the nav and a `{{$body}}` block whose default is the welcome page. The `todos` page extends it with `{{< home}}...{{/home}}`, overriding that block. Any template that declares a `{{$block}}` can be a parent; there is no special layout type. **`home.mustache.html`** ```html
{{$body}}

Welcome

{{/body}}
``` **`todos.mustache.html`** ```html {{< home}} {{$body}}

My Todos

Nothing yet.

{{/body}} {{/home}} ``` **`todo.c`** ```c #include #include module(todo){ http("home", "/", .get = { html("home", "home_s"), http_response("home_s") } ); http("todos", "/todos", .get = { html("todos", "todos_s"), http_response("todos_s") } ); } ``` See [HTTP Pipelines](./REFERENCE.md#http-pipelines) and [Templates](./REFERENCE.md#templates). ### 2. Show Data Bring in SQLite with `#include `, declare a database with `sqlite(...)`, and read with `sqlite_query()`. SQL files are assets like templates: `get_todos.sql` becomes the asset `get_todos`. Three new SQL files: **`create_todos_table.sql`** ```sql CREATE TABLE IF NOT EXISTS todos ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL ); ``` **`seed_todos.sql`** ```sql INSERT INTO todos(title) VALUES('Learn Nerack'); ``` **`get_todos.sql`** ```sql select id, title from todos; ``` Render the rows Nerack stores under `todos_data`: **`todos.mustache.html`** ```diff {{< home}} {{$body}}

My Todos

-

Nothing yet.

+
    + {{#todos_data}} +
  • {{title}}
  • + {{/todos_data}} +
{{/body}} {{/home}} ``` Wire up the module, database, and query: **`todo.c`** ```diff #include #include +#include module(todo){ + sqlite( + "todos_db", + "file:todos.db?mode=rwc", + {"create_todos_table"}, + {"seed_todos"} + ); + http("home", "/", .get = { html("home", "home_s"), http_response("home_s") } ); http("todos", "/todos", .get = { + sqlite_query({"todos_db", "get_todos", "todos_data"}), html("todos", "todos_s"), http_response("todos_s") } ); } ``` The three positional arguments are the database name, the SQL asset, and the context key for the result table (`todos_data`). The template walks the result with `{{#todos_data}}...{{/todos_data}}`. Migrations and seeds run on first connection. See [Databases](./REFERENCE.md#databases) and [query](./REFERENCE.md#query). ### 3. Accept Input Add a `.post` verb that validates, inserts, and redirects (POST-redirect-GET). A resource-scoped `.errors` handler re-renders the form on validation failure. **`create_todo.sql`** ```sql insert into todos(title) values({{title}}); ``` Add the form, repopulating the field and showing the error after a failed submit: **`todos.mustache.html`** ```diff {{< home}} {{$body}}

My Todos

+
+ + {{#error:title}} + {{error_message:title}} + {{/error:title}} + +
    {{#todos_data}}
  • {{title}}
  • {{/todos_data}}
{{/body}} {{/home}} ``` Add a `.post` verb and an `.errors` handler: **`todo.c`** ```diff #include #include #include module(todo){ sqlite( "todos_db", "file:todos.db?mode=rwc", {"create_todos_table"}, {"seed_todos"} ); http("home", "/", .get = { html("home", "home_s"), http_response("home_s") } ); http("todos", "/todos", .get = { sqlite_query({"todos_db", "get_todos", "todos_data"}), html("todos", "todos_s"), http_response("todos_s") - } + }, + .post = { + input({"title", not_empty_input}), + sqlite_query({"todos_db", "create_todo"}), + http_redirect("todos") + }, + .errors = { + {http_bad_request, {http_reroute("todos")}} + } ); } ``` The form posts to `{{url:post:todos}}`. Because `post` is a state-changing verb, the helper appends both `http_method=post` and a fresh CSRF token to the action URL, so the form needs no hidden fields of its own. `input()` validates and promotes `title` to app scope; the `{{title}}` in `create_todo.sql` binds as a prepared-statement parameter. On failure, `http_bad_request` triggers the handler, which `reroute`s back into the GET pipeline in-process. The `input:` and `error:` scopes survive the reroute, so the form repopulates with `{{input:title}}` and shows `{{error_message:title}}`. See [input](./REFERENCE.md#input), [Error and Repair Pipelines](./REFERENCE.md#error-and-repair-pipelines), and [redirect and reroute](./REFERENCE.md#redirect-and-reroute). ### 4. Nested Data A `/todos/:id` page fetches a todo and its comments concurrently, then nests the comments inside the todo with `join()`. Three new SQL files and one new template: **`create_comments_table.sql`** ```sql CREATE TABLE IF NOT EXISTS comments ( id INTEGER PRIMARY KEY AUTOINCREMENT, todo_id INTEGER NOT NULL REFERENCES todos(id), body TEXT NOT NULL ); ``` **`get_todo.sql`** ```sql select id, title from todos where id = {{id}}; ``` **`get_comments.sql`** ```sql select id, todo_id, body from comments where todo_id = {{id}}; ``` Enter `{{#todo_data}}` first; after the join, `comments` lives inside each todo record: **`todo.mustache.html`** ```html {{< home}} {{$body}} {{#todo_data}}

{{title}}

Comments

    {{#comments}}
  • {{body}}
  • {{/comments}}
{{/todo_data}} {{/body}} {{/home}} ``` Link each list item to its detail page. `{{url:get:todo}}` resolves the `todo` resource's pattern (`/todos/:id`) and fills `:id` from the current scope, which inside `{{#todos_data}}` is the current row: **`todos.mustache.html`** ```diff {{#todos_data}} -
  • {{title}}
  • +
  • {{title}}
  • {{/todos_data}} ``` Register the migration and add a `todo` resource: **`todo.c`** ```diff #include #include #include module(todo){ sqlite( "todos_db", "file:todos.db?mode=rwc", - {"create_todos_table"}, + {"create_todos_table", "create_comments_table"}, {"seed_todos"} ); http("home", "/", .get = { html("home", "home_s"), http_response("home_s") } ); http("todos", "/todos", .get = { sqlite_query({"todos_db", "get_todos", "todos_data"}), html("todos", "todos_s"), http_response("todos_s") }, .post = { input({"title", not_empty_input}), sqlite_query({"todos_db", "create_todo"}), http_redirect("todos") }, .errors = { {http_bad_request, {http_reroute("todos")}} } ); + + http("todo", "/todos/:id", + .get = { + input({"id", positive_integer_input}), + sqlite_query( + {"todos_db", "get_todo", "todo_data", .error_on_empty = true}, + {"todos_db", "get_comments", "comments"} + ), + join("todo_data", "id", "comments", "todo_id"), + html("todo", "todo_s"), + http_response("todo_s") + } + ); } ``` Both queries in one `sqlite_query()` call run concurrently. `join()` lifts `comments` inside each `todo_data` record, so the template reaches `{{#comments}}` from within `{{#todo_data}}`. `.error_on_empty = true` returns 404 when the id matches nothing. See [join](./REFERENCE.md#join) and [query](./REFERENCE.md#query). ### 5. Calling APIs `http_fetch()` calls external HTTP services like a query calls a database. JSON parses into context tables; multiple items in one `http_fetch()` run concurrently. Show the responses on the home page: **`home.mustache.html`** ```diff
    - {{$body}} -

    Welcome

    - {{/body}} + {{$body}} +

    Welcome

    + {{#weather}} +

    {{city}}: {{precision:temp_c:0}}°C, {{conditions}}

    + {{/weather}} + {{#quote}} +
    {{content}}, {{author}}
    + {{/quote}} + {{/body}}
    ``` Fetch both services concurrently before rendering: **`todo.c`** ```diff #include #include #include module(todo){ sqlite( "todos_db", "file:todos.db?mode=rwc", {"create_todos_table", "create_comments_table"}, {"seed_todos"} ); http("home", "/", .get = { + http_fetch( + {http_get, "https://api.quotes.dev/random", "quote"}, + {http_get, "https://api.weather.dev/now", "weather"} + ), html("home", "home_s"), http_response("home_s") } ); http("todos", "/todos", .get = { sqlite_query({"todos_db", "get_todos", "todos_data"}), html("todos", "todos_s"), http_response("todos_s") }, .post = { input({"title", not_empty_input}), sqlite_query({"todos_db", "create_todo"}), http_redirect("todos") }, .errors = { {http_bad_request, {http_reroute("todos")}} } ); http("todo", "/todos/:id", .get = { input({"id", positive_integer_input}), sqlite_query( {"todos_db", "get_todo", "todo_data", .error_on_empty = true}, {"todos_db", "get_comments", "comments"} ), join("todo_data", "id", "comments", "todo_id"), html("todo", "todo_s"), http_response("todo_s") } ); } ``` Both requests run concurrently under one `http_fetch()` call. The JSON parses into context tables the template walks with `{{#quote}}` and `{{#weather}}`. `http_fetch()` also supports other verbs, headers, request bodies, and interpolated URLs. See [fetch](./REFERENCE.md#fetch). ### 6. Tasks A task is a named, reusable pipeline. Define it once with optional `.cron`; dispatch durable background runs with `dispatch("name")` (from `dispatch.h`). Two new SQL files: **`create_daily_stats_table.sql`** ```sql CREATE TABLE IF NOT EXISTS daily_stats ( id INTEGER PRIMARY KEY AUTOINCREMENT, recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, todo_count INTEGER NOT NULL ); ``` **`record_daily_stats.sql`** ```sql insert into daily_stats(todo_count) select count(*) from todos; ``` Register the migration, define the tasks, dispatch them from the POST: **`todo.c`** ```diff #include #include #include +#include module(todo){ sqlite( "todos_db", "file:todos.db?mode=rwc", - {"create_todos_table", "create_comments_table"}, + {"create_todos_table", "create_comments_table", "create_daily_stats_table"}, {"seed_todos"} ); + task("record_daily_stats", { + sqlite_query({"todos_db", "record_daily_stats"}) + }, .cron = "0 0 * * *"); + + task("notify_new_todo", { + http_fetch({ + http_post, + "https://api.push.dev/notify", + .json = "{\"text\":\"New todo: {{title}}\"}" + }) + }, .accepts = {"title"}); + http("home", "/", .get = { http_fetch( {http_get, "https://api.quotes.dev/random", "quote"}, {http_get, "https://api.weather.dev/now", "weather"} ), html("home", "home_s"), http_response("home_s") } ); http("todos", "/todos", .get = { sqlite_query({"todos_db", "get_todos", "todos_data"}), html("todos", "todos_s"), http_response("todos_s") }, .post = { input({"title", not_empty_input}), sqlite_query({"todos_db", "create_todo"}), + dispatch("notify_new_todo"), http_redirect("todos") }, .errors = { {http_bad_request, {http_reroute("todos")}} } ); http("todo", "/todos/:id", .get = { input({"id", positive_integer_input}), sqlite_query( {"todos_db", "get_todo", "todo_data", .error_on_empty = true}, {"todos_db", "get_comments", "comments"} ), join("todo_data", "id", "comments", "todo_id"), html("todo", "todo_s"), http_response("todo_s") } ); } ``` `.cron` and `dispatch(...)` both run the task on a task reactor, off the request reactors, so the POST returns immediately. Dispatched tasks are durable: a crash mid-task resumes on the next boot. To hand values to a task, list them under `.accepts`; `notify_new_todo` pulls in `title` that way. `dispatch()` comes from `dispatch.h`. See [Task Pipelines](./REFERENCE.md#task-pipelines). ### 7. Modules and Events Split features into modules that talk through pub/sub events. A module is any `.c` file declaring `module(name){ ... }`, registering that module's resources, databases, tasks, and subscribers. Nerack discovers every `module(...)` on disk. A module's assets live in its `name/` folder and are private to it; assets at the project root are shared across modules (see [Assets](./REFERENCE.md#assets)). This step moves todos into its own module and adds an `activity` module that records an entry whenever a todo is created. The top-level file becomes `main.c` and keeps just the `home` resource; `home.mustache.html` stays at the project root, shared across modules: ``` . ├── main.c ├── todos/ │ ├── todos.c │ ├── todos.mustache.html │ ├── todo.mustache.html │ ├── create_todos_table.sql │ ├── create_comments_table.sql │ ├── create_daily_stats_table.sql │ ├── seed_todos.sql │ ├── record_daily_stats.sql │ ├── get_todos.sql │ ├── get_todo.sql │ ├── get_comments.sql │ └── create_todo.sql ├── activity/ │ ├── activity.c │ ├── activity.mustache.html │ ├── create_activity_table.sql │ ├── get_activities.sql │ └── insert_activity.sql └── home.mustache.html ``` **`main.c`** ```diff #include #include -#include -#include -module(todo){ +module(main){ - sqlite( - "todos_db", - "file:todos.db?mode=rwc", - {"create_todos_table", "create_comments_table", "create_daily_stats_table"}, - {"seed_todos"} - ); - - task("record_daily_stats", { - sqlite_query({"todos_db", "record_daily_stats"}) - }, .cron = "0 0 * * *"); - - task("notify_new_todo", { - http_fetch({ - http_post, - "https://api.push.dev/notify", - .json = "{\"text\":\"New todo: {{title}}\"}" - }) - }, .accepts = {"title"}); - http("home", "/", .get = { http_fetch( {http_get, "https://api.quotes.dev/random", "quote"}, {http_get, "https://api.weather.dev/now", "weather"} ), html("home", "home_s"), http_response("home_s") } ); - - http("todos", "/todos", - .get = { - sqlite_query({"todos_db", "get_todos", "todos_data"}), - html("todos", "todos_s"), - http_response("todos_s") - }, - .post = { - input({"title", not_empty_input}), - sqlite_query({"todos_db", "create_todo"}), - dispatch("notify_new_todo"), - http_redirect("todos") - }, - .errors = { - {http_bad_request, {http_reroute("todos")}} - } - ); - - http("todo", "/todos/:id", - .get = { - input({"id", positive_integer_input}), - sqlite_query( - {"todos_db", "get_todo", "todo_data", .error_on_empty = true}, - {"todos_db", "get_comments", "comments"} - ), - join("todo_data", "id", "comments", "todo_id"), - html("todo", "todo_s"), - http_response("todo_s") - } - ); } ``` Add an Activity link to the shared nav: **`home.mustache.html`** ```diff - +
    {{$body}}

    Welcome

    {{#weather}}

    {{city}}: {{precision:temp_c:0}}°C, {{conditions}}

    {{/weather}} {{#quote}}
    {{content}}, {{author}}
    {{/quote}} {{/body}}
    ``` The todos logic moves into the module unchanged, with a `publish()` declaration and an `emit()` step added. Both todo resources move with it: **`todos/todos.c`** ```c #include #include #include #include #include module(todos){ sqlite( "todos_db", "file:todos.db?mode=rwc", {"create_todos_table", "create_comments_table", "create_daily_stats_table"}, {"seed_todos"} ); publish("todo_created", .with = {"title"} ); task("record_daily_stats", { sqlite_query({"todos_db", "record_daily_stats"}) }, .cron = "0 0 * * *"); task("notify_new_todo", { http_fetch({ http_post, "https://api.push.dev/notify", .json = "{\"text\":\"New todo: {{title}}\"}" }) }, .accepts = {"title"}); http("todos", "/todos", .get = { sqlite_query({"todos_db", "get_todos", "todos_data"}), html("todos", "todos_s"), http_response("todos_s") }, .post = { input({"title", not_empty_input}), sqlite_query({"todos_db", "create_todo"}), dispatch("notify_new_todo"), emit("todo_created"), http_redirect("todos") }, .errors = { {http_bad_request, {http_reroute("todos")}} } ); http("todo", "/todos/:id", .get = { input({"id", positive_integer_input}), sqlite_query( {"todos_db", "get_todo", "todo_data", .error_on_empty = true}, {"todos_db", "get_comments", "comments"} ), join("todo_data", "id", "comments", "todo_id"), html("todo", "todo_s"), http_response("todo_s") } ); } ``` The `activity` module owns its own table, query, template, and subscriber. Nothing in it references the todos module: **`activity/create_activity_table.sql`** ```sql CREATE TABLE IF NOT EXISTS activities ( id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL, ref TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); ``` **`activity/insert_activity.sql`** ```sql insert into activities(kind, ref) values('created', {{title}}); ``` **`activity/get_activities.sql`** ```sql select kind, ref, created_at from activities order by created_at desc; ``` **`activity/activity.mustache.html`** ```html {{< home}} {{$body}}

    Activity

      {{#activities}}
    • {{kind}}: {{ref}} ({{created_at}})
    • {{/activities}}
    {{/body}} {{/home}} ``` **`activity/activity.c`** ```c #include #include #include #include module(activity){ sqlite( "activity_db", "file:activity.db?mode=rwc", {"create_activity_table"} ); subscribe("todo_created", { sqlite_query({"activity_db", "insert_activity"}) }); http("activity", "/activity", .get = { sqlite_query({"activity_db", "get_activities", "activities"}), html("activity", "activity_s"), http_response("activity_s") } ); } ``` When the POST calls `emit("todo_created")`, Nerack propagates the keys named in `publish(...).with` (`title`) to every subscriber. The `activity` module writes its row with no direct link to the publisher. Events are durable: undelivered ones replay after a crash. A third subscriber is another module with its own `subscribe(...)`, and the publisher still does not change. See [Modules and Composition](./REFERENCE.md#modules-and-composition) and [Event Pipelines](./REFERENCE.md#event-pipelines).