Data loaders

A data loader lets a route name a JSON file whose contents are loaded into the page's render context — so a data-driven page needs no handler code. It is the declarative first step toward server load functions and collection-backed loaders.

Loaders are opt-in and fully backward compatible — a route without a data field renders exactly as it did before.

Declaring a loader

Add an optional "data" field to a route in backend/routes.json. Its value is the path of a JSON file relative to backend/:

{
  "routes": [
    { "path": "/", "template": "index" },
    {
      "path": "/team",
      "template": "team",
      "data": "data/team.json"
    }
  ]
}

You can combine data with layout and the SEO title/description fields on the same route. Existing fields keep working untouched.

The merge rule

Before the template renders, the named file is parsed and merged into the context:

  1. If the loaded JSON is an object, its top-level keys are spread at the root

of the context, so a template reads them directly: {{ heading }}, {% for m in members %}. The reserved params key is never overwritten.

  1. The whole loaded document is also exposed under a data key, so a

template can reach it explicitly with {{ data.heading }} or iterate {% for m in data.members %}. This is the only access path when the file's top level is an array or scalar rather than an object.

The project-wide page.json context, route params, and per-route meta_* fields are all still present; the loader only adds to the context.

Path safety

The data path is resolved strictly under backend/. Any .. or . segment (or an empty name) is rejected, so a route can never read a file outside the project's backend directory. A missing file or malformed JSON is treated as "no data" — the page still renders, with the loaded values simply absent, rather than crashing the server.

Iterating loaded data

With data/team.json shaped like:

{
  "heading": "Meet the team",
  "members": [
    { "name": "Óli", "role": "Framework lead" },
    { "name": "Ada", "role": "Template engineer" }
  ]
}

the team template renders the list with the engine's {% for %} tag and HTML-escaped {{ }} interpolation:

<h1>{{ heading }}</h1>
<section class="features">
  {% for m in members %}
  <article class="feature">
    <h3>{{ m.name }}</h3>
    <p>{{ m.role }}</p>
  </article>
  {% endfor %}
</section>

No handler, no controller — the route declaration is the whole wiring.

Live demo

The site itself ships a working example: the /team route loads backend/data/team.json and renders it through the team template.

Where this is heading

Naming a static JSON file is the declarative entry point. The same data field is the seam for richer loaders: server load functions that compute the context per request, and collection-backed loaders that pull rows from the framework's built-in store. The template contract — "the route supplies the context, the page just renders it" — stays the same as loaders grow.