AkurAI Build
Menu

Public repository / master

AkurAI-Framework / site/content/docs/ssg.md

Latest change e342c90be739365f3071b9ba372896531f98f412 - feat(cli): static generation (SSG) + incremental static regeneration (ISR) by Ólafur Búi Ólafsson

# Static generation (SSG & ISR)

By default every page is rendered server-side on each request (dynamic SSR). For
pages that don't change per request, you can opt into **static generation**: the
HTML is rendered once, cached, and served from the cache. Two route fields turn
it on, and they compose.

This is purely opt-in. A route that declares neither field renders exactly as it
does today — fresh on every request, with the usual `Cache-Control: no-cache`.

## Route configuration

Static generation is configured per route in `backend/routes.json`:

```json
{
  "routes": [
    { "path": "/",        "template": "index" },
    { "path": "/about",   "template": "about", "prerender": true },
    { "path": "/pricing", "template": "pricing", "revalidate": 300 },
    { "path": "/blog",    "template": "blog", "prerender": true, "revalidate": 600 }
  ]
}
```

| Field | Type | Meaning |
|-------|------|---------|
| `prerender` | bool | Pure SSG. Render once, cache, serve the cached HTML thereafter. Warmed eagerly at startup. |
| `revalidate` | integer (seconds) | ISR. Serve cached HTML; once it is older than the TTL, the next request regenerates it in the background. |

- `/about` — **pure SSG**: rendered at startup, served from cache, never re-rendered until the next restart.
- `/pricing` — **ISR (lazy)**: rendered on first request, then refreshed in the background once the cached copy is older than 300 s.
- `/blog` — **both**: warmed at startup (so the first request is instant) *and* kept current by the 600 s TTL.

`revalidate` must be a non-negative whole number; negative or fractional values
are ignored (the route stays pure-SSG if `prerender` is set, otherwise dynamic).

## Caching model

Generated HTML is persisted in a dedicated B+tree at `data/ssg.db`, keyed by the
route path. Each entry stores the rendered bytes alongside the wall-clock second
it was generated. Because it's the same crash-safe storage engine the rest of the
framework uses, the cache file survives process restarts.

Only a successful `200` render is ever cached. If a template errors (or otherwise
doesn't return `200`), the live response is returned uncached, so a broken page is
never frozen into the cache.

## ISR semantics: stale-while-revalidate

The implemented ISR model is **stale-while-revalidate**:

1. A request for an ISR route whose cache entry is **fresh** (`age < revalidate`)
   gets the cached bytes immediately.
2. A request whose entry is **stale** (`age >= revalidate`) *also* gets the
   cached (stale) bytes immediately — there is no request-time latency penalty —
   and the server kicks off a **background re-render** on a separate thread.
3. The freshly rendered HTML replaces the cache entry with a new timestamp.
   Subsequent requests serve the updated copy.

Concurrent stale requests for the same path **coalesce**: only one background
regeneration runs at a time per route (no thundering herd). If a background
re-render fails, the previous (stale) entry is left in place rather than dropped.

This means an ISR page can serve one stale response right at the TTL boundary
before the refreshed copy lands — the standard ISR trade-off in exchange for
never blocking a request on rendering.

## Regeneration & invalidation triggers

There are two triggers, and that's the whole story:

- **Restart (rebuild).** On `serve`/`dev` startup the SSG cache is cleared and
  every `prerender` route is re-rendered and stored. This is the invalidation
  mechanism: change a template or its data, restart, and the prerendered pages
  rebuild from scratch. Routes removed from `routes.json` don't leave stale HTML
  behind, because the cache is cleared first.
- **TTL (ISR).** Routes with `revalidate` regenerate themselves on the next
  request after their TTL elapses, as described above — no restart needed.

A pure-`prerender` route (no `revalidate`) is cached **indefinitely** between
restarts: it only changes when the binary regenerates it at the next startup.

## Cache-Control headers

SSG responses set a browser cache header scoped to the route; the existing
`no-cache`-on-dynamic-HTML behaviour is unchanged.

| Route kind | `Cache-Control` |
|------------|-----------------|
| Dynamic (no SSG fields) | `no-cache` (unchanged) |
| Pure `prerender` | `no-cache` — the browser revalidates, but the server answers from cache |
| `revalidate: N` (ISR) | `public, max-age=N` — matches the regeneration window |

Pure prerender keeps `no-cache` so a browser never silently holds a copy the
server has since rebuilt; the server-side cache still does the heavy lifting.

## Limitations

- **Pattern routes aren't startup-warmed.** A `:param`/`*wildcard` route has no
  single concrete URL, so it can't be prerendered at startup. (An ISR pattern
  route still works lazily: each distinct request path gets its own cache entry,
  keyed by the full path.)
- **No per-entry manual purge.** Invalidation is whole-cache (restart) or
  time-based (ISR TTL). There is no HTTP endpoint to evict a single path.
- **Time-based, second granularity.** Freshness uses whole seconds of wall-clock
  time; a backwards clock jump can momentarily extend perceived freshness.
- **One stale response at the boundary.** By design, the request that trips the
  TTL receives the stale copy while the refresh happens in the background.