Ramonda

Rendering modes

Not every page wants the same treatment. A marketing page is the same for everyone, so it should be baked once and served as a file. An account page depends on who is asking, so it must render per request. A page that changes slowly can be cached and refreshed on a timer. Ramonda lets you choose per route — and refuses to bake anything that would leak one visitor's data to another.

Three modes:

modewhen it rendersfor
staticat buildpages that are the same for everyone
dynamicper requestpages that depend on the request (a signed-in user, a cookie)
ISRat build, then re-baked on a timerpages that change slowly and have no per-user data

Two files: the route table, and how it renders

The setup is split in two on purpose — so server-only code never reaches the browser.

The route table is shared by the client and the server. It is just paths and what to show:

// src/routes.tsx  (client + server)
export const routes = createRoutes({
  "/": <Home />,
  "/about": <About />,
  "/account": <Account />,
  "*": <NotFound />,
});
export const { Router, RouteOutlet, Navigator, Link, route } = createRouter(routes);

The rendering config is server-only. It says how each route renders, and it lives in a server/ folder so nothing in it can end up in the client bundle:

// server/routes.ts  (SERVER ONLY)
import { defineServer } from "@ramonda/router/server";
import { routes } from "../src/routes";

export const server = defineServer(routes, {
  "/":        { prerender: true },   // static
  "/about":   { revalidate: 60 },    // ISR — re-baked every 60s
  "/account": {},                    // dynamic (the default)
});

@ramonda/router/server is a separate entry that cannot be imported from client code — a browser build that tries fails, so your loaders, secrets and database calls stay on the server. Keep everything under server/.

The two files can't drift

defineServer's config is exhaustive: every route in the table must have an entry. That is the safety net for a team — add a route and the config stops type-checking until you say how it renders, and an entry for a route that doesn't exist is a type error too.

defineServer(routes, {
  "/": { prerender: true },
  // ✗ type error: "/about" and "/account" are missing
});

defineServer(routes, {
  "/": {}, "/about": {}, "/account": {},
  "/typo": {},   // ✗ type error: "/typo" is not a route
});

So the route table is the single source of truth, and both createRouter (which types your <Link href>) and defineServer (which types this config) are checked against it. You never maintain two lists by hand. (The "*" fallback is not a real destination, so it needs no entry.)

Default is dynamic

A route with no entry — a bare {} — renders per request. That is the safe default: the worst case is "slower", never "one user's page served to another". You opt a route into baking with prerender: true; you never have to remember to opt out.

A project that is static by nature (a docs site) can flip the default:

defineServer(routes, { /* … */ }, { defaultMode: "static" });

Now unmarked routes are baked, and you mark the few dynamic ones with prerender: false.

The build refuses to bake a per-request page

This is the rule the whole design protects: a baked page must never contain per-request data. You don't have to audit every component to be sure — the build proves it. When it prerenders a route, the request is poisoned: any read of a cookie, a header, or a per-request value (see reading the request) throws, and the build fails, naming the route and what it read:

✗ /account — reads the request (cookies.get("session")); cannot be prerendered.

So a page that reads the request simply can't be marked prerender — the guard enforces it. Mark it dynamic (the default) and it renders per request instead.

A route with a :param — the build has to be told which pages exist

A route table is a set of PATTERNS, and only some of them are pages. /guide/state is one page; /guide/:slug is one route and however many guides there are. So a parameterised route marked prerender needs its paths, and they come from your data:

import { routePlan } from "@ramonda/router/server";

const GUIDES = ["state", "effects"];

const staticPaths = (): string[] => routePlan(server, GUIDES.map((slug) => `/guide/${slug}`)).static;
// → ["/", "/guide/state", "/guide/effects", "/signup"]

plan.static holds paths, never patterns. plan.needsData names the parameterised routes the paths were for, so a build can report what it was asked to bake.

Marked prerender with nothing supplied, the build stops:

[Ramonda] `/guide/:slug` is marked for prerender and takes :slug, so a build cannot know which
pages exist. Pass them: routePlan(server, items.map((item) => `/guide/${item.slug}`)).
Or drop `prerender` and let it render per request.

It stops rather than skipping the route, for the same reason a per-request read stops it: a config that says prerender and a build that quietly does not is how a site ships missing half its pages while every page it did emit looks perfectly correct.

A revalidate route with a :param needs no paths, and that is the difference. Nothing is baked at build; the cache fills as pages are asked for, and each one is a page of its own — /products/7 and /products/9 are cached separately under the one route. So the build is told nothing, and plan.needsData names such a route only for a build that wants to warm some of them itself.

What it does need is a limit, because one route is now as many pages as there are items:

import { createIsrCache, fileStore, routePlan } from "@ramonda/router/server";

const isr = createIsrCache({
  plan: routePlan(server),
  store: fileStore({ dir: "dist/isr" }),
  render: bakePath,
  maxPages: 500, // required when an ISR route takes a `:param`
});

createIsrCache refuses to start without it for those routes, and refuses it when no route has a param — a number that bounds nothing is a number somebody will trust. Past the limit the page nobody has asked for longest is dropped.

Least recently asked for, not fewest hits, and the intuitive rule is the wrong one here: hit counts accumulate, so a product that was popular last week keeps its ten thousand while one that went viral an hour ago has three — and a brand new page always has the fewest, so it would always be the first thrown out. Recency adapts by itself. The count is per process, so two instances over one directory each bound their own view.

The build and the server

  • Build (routePlan(server, paths) gives the split): each static/ISR route is rendered with the request poisoned and written to a file. A route that reads the request fails the build.
  • Server, per request: a static route serves its baked file; an ISR route serves the cache and refreshes it in the background when it is older than revalidate; a dynamic route renders fresh with the real request.

The scaffolded SSR app (npm create ramonda, choose SSR) wires all of this for you — a routed app with the three modes and the build guard already set up.

Where ISR pages are kept

An ISR page is baked once and served to everyone until it is rebaked, so where you keep it decides whether two visitors see the same thing. createIsrCache owns the timing; you hand it a store:

import { createIsrCache, fileStore, routePlan } from "@ramonda/router/server";

const isr = createIsrCache({
  plan: routePlan(server),
  store: fileStore({ dir: "dist/isr" }),
  render: bakePath, // your shared render — the same one the build uses
});

The server then asks it for every request:

// `undefined` means "not an ISR route" — fall through to static or dynamic.
const page = await isr.serve(path);
if (page) return sendHtml(res, page.html, page.mode);

page.mode is isr-hit (fresh), isr-stale (the old copy, with a rebake already running behind it), or isr-cold (nothing cached, so this request waited for the render).

Choosing a store

keeps pagesuse it when
memoryStore()in this processone instance, or local development
fileStore({ dir })in a directorya restart must not empty the cache, or instances share a volume
your ownwherever you likeinstances share nothing but a Redis or a database

A store is three small methods, which is the whole point:

const redisStore = {
  async get(key) {
    const raw = await redis.get(`isr:${key}`);
    return raw ? JSON.parse(raw) : undefined;
  },
  async set(key, entry) {
    await redis.set(`isr:${key}`, JSON.stringify(entry));
  },
  async delete(key) {
    await redis.del(`isr:${key}`);
  },
};

delete is not optional. A route with a :param fills the cache as pages are asked for, so maxPages has to be able to drop one — a store without it fails at the first eviction, and that failure is reported rather than raised, so the cache would grow instead of stopping.

A store may lose an entry at any time — eviction, expiry, a cleared directory. That is not an error: a missing entry is a cold render, which is always correct and only slower.

What the cache promises, and what it does not

  • One rebake at a time, per instance. Ten requests arriving while a stale page is rebaking start one render, not ten. Across instances each rebakes at most once per window, so two can still bake the same page at the same moment — wasted work, never a wrong answer.
  • A failed background rebake keeps serving the stale page. An old page is a smaller problem than no page. A failed cold render throws, because there is nothing else to send.
  • An eviction cannot be called off. A store is three unconditional methods, so a delete already travelling still removes whatever is under that key when it arrives — including a page a rebake wrote in the meantime. It takes a page going stale in the same moment it is being evicted, and it costs that page and one later render; the cache notices the key points at nothing and drops it.
  • A deploy must clear the cache. Pages in it were rendered by the bundle you just replaced, so serving one afterwards hands the browser old markup for a new client bundle. The scaffolded app clears dist/isr in its prerender step, which runs on every build.

Hosting

  • static files can be served from anywhere — a CDN, a static host, no server at all.
  • ISR needs a running server to revalidate (a server-side cache, not pure CDN).
  • dynamic needs a server that can render (Node today, since the render builds real DOM nodes).

Splitting a site this way lets you host the static majority anywhere and run a server only for the routes that truly need the request.

Next