inmake DOCS
Docs / Server / Web routing

Web routing

How published pages, slugs, collection routes and translations resolve.

WebSite (single store) and WebPreviewServer (multi-tenant) turn a URL path into a (collection, docId) and render its page. WebPreviewServer resolves the store id from the request in one of two ways:

  • path prefix — pathPrefix: '/preview/demo' serves /preview/demo/{sid}/**; matchPath(req) returns {sid, path} and the store-relative path is passed to handle(req, res, sid, path). Pages are rendered with basePath = '{pathPrefix}/{sid}', which rebases root-absolute links (href/src/action starting with /, plus {{menu}} hrefs) onto the prefix (rebaseHtml in src/web/web_renderer.ts) and exposes it to page JS as window.DROP_BASE. A bare {pathPrefix}/{sid} 301s to the slashed form. (The admin console used this for its /preview/{env}/{sid} store previews until 2026-09-04; its previews now render in the browser — see "Page composition (compose.ts)" below.)
  • wildcard hostname — baseDomains: ['example.com'] serves {sid}.example.com via matchHost(req) (matchProjectHost in src/web/host_match.ts); needs wildcard DNS. WebSiteOptions.basePath stays unset, links stay root-absolute.

The homepage has one address. / renders the homepage: a staticRoutes['/'] entry, else setup/web.index (homeRefOf in compose.ts). A slug hit on that same document answers 301 to the locale's root (/, or /en in path mode), keeping the query. The hit can be a flat {slug}, a routed {segment}/{slug}, or a routed collection's bare segment whose route.index is the homepage. resolveSiteTarget decides it, so the fleet and the admin's Studio renderer behave the same. The POST routes (forms, load-more, variant swaps) treat that redirect as "no page", like any other redirect.

Tokens and the read seam#

WebSiteOptions.accessToken is the token the site's SDK session runs on (masterToken survives as a deprecated alias). What that token must grant depends on reads:

  • reads: 'admin' (default) — site metadata (setup docs, elements, schemas, templates, slugs, files metadata, data-doc fallbacks) is read through /admin/read|list, so the token must be a master or store-scoped admin token.
  • reads: 'public' — the same reads go through POST /public/web (the server-side allowlisted site-code surface), so an ordinary {public: true, process: true} token renders the whole site. This is the tenant hosting server's mode (drop-core-tenant); pair a store-scoped token with headerStore: sid.

The seam is SiteReader (src/web/site_reader.ts): AdminSiteReader / PublicSiteReader behind one get/list contract (the public reader maps every registry the router reads — slug/{key}, routes/{c}/slugs/{key}, routes/{c}/routes/{cat}/slugs/{key} and their listings — onto /public/web kind: slug | slugs, and a path redirect redirects/{id} onto kind: redirect). Data documents themselves always read through the public content API regardless of reads.

WebPreviewServerOptions.siteOptions injects per-store WebSiteOptions overrides when a site is (re)built (per-store credentials, reads, hookKey, …) — with it set, the shared masterToken becomes optional. WebPreviewServer.site(sid) / .evict(sid) are public so wrappers (the tenant server's hook ingress and credential-drift handling) can reach and rebuild a store's WebSite.

Three route kinds (matchRoute in src/web/routes.ts, shared with the static-site example):

kind path resolves to
static / and any routes entry a predefined document — / is the setup doc's index reference; extra routes are developer-defined in code (WebSiteOptions.routes: {'/about': {collection, document}})
reference /ref/{collection}/{id} that data document directly (reserved /ref/ prefix)
slug any other path the store's slug registry (store/{sid}/slug/{key}) → its document. One document per URL key holding a per-locale map (.dev/plans/slug-registry-locale-map.md): locales[l] = the slot {path, moved?, alias?} of locale l, redirect (locale-neutral) / redirects[l] (per locale). One read, one rule — core-lib lookupSlug, shared with Studio and the backend: redirect → redirects[serve] → locales[serve] → same-language locales (en_GB → en_US: slot or redirect) → default / other locales / default (first slot, then first redirect). The top-level compat mirror (path / locale / moved / alias) is never read — it exists for pre-map vendored builds. Collection routes: a two-segment /{segment}/{slug} serves a document of a collection whose setup/data row has route.enabled — segment is the serve locale's collection slug (else the key), slug an entry of that collection's route registry (store/{sid}/routes/{collection}/slugs/{id}, same id shape and lookup — never the root registry, so the same slug may exist in several collections and a document's seo.scope decides whether it lives at the root, under its collection route, or both); the bare /{segment} serves the row's route.index page when no document owns that key (documents win the flat key space). The flat /{slug} keeps serving; the routed URL is emitted as <link rel="canonical">. Category routes (.dev/plans/nested-category-routes.md): /{segment}/{category}/{slug} reads the leaf category's registry store/{sid}/routes/{collection}/routes/{category}/slugs/{key} (planRouteTail — the chain must name routable categories of the row in the serve locale); on a document miss /{segment}/{category} serves the category's index page (a document owning that key at that level wins). Aliases (slugs[l].aliases of the collection or a category) answer 301 to the same path with the primary segments, keeping the language prefix (url-admin.md U13). A segment of another store locale resolves there: path mode 301s to the right-locale URL, cookie mode serves the document in the serve locale (map §4.3). More than 2 + MAX_CATEGORY_DEPTH segments never route. Rule + helpers: collection_routes.ts in core-lib. Old addresses (U14): whatever none of the above answers is looked up ONCE in store/{sid}/redirects/{pathRedirectId(path)} — the full request path (before the locale split; any shape) canonicalized and encoded (core-lib web/path_redirect.ts) — and answers 301 / 302 to the stored target; a live page, a registry redirect or a routed address always wins
new WebSite({ /* … */, routes: {'/about': {collection: 'pages', document: 'about'}} });

All three feed the same render pipeline and the same render-cache key (cacheKey(locale, collection, docId)), so a /ref/… URL and a slug that point at the same document share one cached page and one hook-invalidation entry.

Per-visitor locale (cookie mode, the default). Pages render in the store's default (first) locale unless the request carries a drop_locale cookie naming another configured store locale (renderPath(path, locale?); the server layer parses the cookie, strict xx_XX shape only, and stamps Vary: Cookie on page HTML and slug redirects). A store's language-switch UI writes the cookie and reloads (a language-switcher element does exactly that). A slug owned by another locale (a cross-locale lookup hit) still serves its document in the serve locale, and the locale-keyed render cache keeps the variants apart. Unknown or unconfigured values fall back to the default locale. An external server only has to pass the cookie value as renderPath's second argument — the same call serves path mode too.

Language in the URL (path mode). With setup/web.localeUrl = {mode: 'path', codes?} (core-lib web/locale_url.ts: localeUrlContextOf, localeUrlCodes, splitLocalePath, localePrefixOf, withLocalePrefix) the first URL segment names the locale: /o-nas is the default locale, /en/about another (codes derived cs_CZ → cs, a later same-language locale takes the region form en-gb; the admin persists the full codes map). The store needs ≥ 2 locales that all derive a code, else it silently stays in cookie mode. renderPath reads the prefix itself — no separate API; the cookie is ignored — and answers:

  • /{defaultCode}/x → 301 /x; an upper-case prefix (/EN/x) → 301 to the lower-case one (mount prefix included in preview);
  • a cross-locale slug or collection segment → 301 to the document's URL in the right locale: a prefixed request tries its own locale first, then the owner; an unprefixed request (typically an old cookie-era link, /about = an en slug) tries the owner first (/en/about). A redirect that would land on the requested URL serves the page instead;
  • otherwise the page, with WebPageResult.localeMode: 'path' — the server sends no vary: cookie on pages or redirects.

Every generator emits prefixed links: linkPathOf (LinkSiteContext.localePrefix), link fields and stringifyLinks, {{route}} (in fragments too), composeMenu, the canonical, the consent policy link, redirect Locations and the /variant reply's href. localizeHtml (web_renderer.ts) is the safety net for links typed literally into templates: it prefixes root-absolute href / action / formaction / data-href / data-ref-base / data-form-redirect / data-page values before rebaseHtml, skipping router platform paths (/form, /m, … — /ref/{c}/{id} is a page and IS prefixed), the asset prefix, values already under a code or the mount prefix, and any tag carrying hreflang or data-drop-raw-href. POST routes stay unprefixed (/form/{locale}/…); /content, /variant and form placement strip the prefix from the browser path and resolve in the locale it names. Path-mode pages also get the bootstrap globals DROP_LOCALE_BASE ('' or /en — build page links as DROP_BASE + DROP_LOCALE_BASE + path, the kit's DropElements.pageHref), DROP_LOCALE_MODE = "path" and DROP_ALTERNATES; a cookie-mode page emits none of them and renders byte-identically to before (src/web/test_snapshots/cookie_mode.json guards that).

Alternates. Every template (and fragment) gets {{#each alternates}} — {locale, code?, hreflang, label, href, current} per store locale (alternatesOf in compose). In path mode an entry is the page's URL in that locale (a document's own slug in that slice; a locale without a slug is omitted) and the sibling documents are render deps, so a rename in one locale re-renders the others; the head gets the hreflang cluster + x-default, og:locale / og:locale:alternate, og:url and a canonical on every page, absolute over siteOriginOf (siteBaseUrl, else the first setup/web.domains host; no origin → no hreflang/og:url). In cookie mode the list is the locales on the page's one URL (a switcher sets the cookie) and the head is unchanged. Reference routing is confined to the data/ namespace readDoc already reads, behind the publish gate + a renderable template — it can't reach orders/customers/etc. Not yet routed: template-rendered collection listings (a routed collection's bare segment needs a route.index page — 404 with a TODO otherwise).

Redirects. A registry entry (root or route) that carries redirect answers 301 (302 with temporary: true) instead of rendering — and the redirect WINS over a stored path. The target is either an absolute http(s):// URL (sent verbatim) or a path on this site (about, /about, news/item?x=1), normalized to root-absolute, rebased on basePath, and carrying the request's query when it names none. Anything else — protocol-relative //host, another scheme, control characters, a target that points back at the requested path — resolves to nothing, i.e. the store's 404: one validator, parseSlugRedirect in core-lib, used by the fleet, the backend and the admin editor alike. Redirects are never render-cached (each hit is one TTL-cached slug read), never listed in a sitemap, and the interactive routes (/form, /content, /variant) treat them as "no page" (404). In path mode a site-path redirect owned by a locale — redirects[l] — is a path within that locale and gets l's prefix (SiteRoutingContext.prefixOf), not the visitor's: an en_GB visitor falling back to en_US's redirect lands on /en/…. The string redirect stays root-absolute. Former slugs (moved). When a document's slug is renamed, the content sync keeps the old key's slot as {path, moved: true}; it answers 301 to the document's CURRENT URL (movedPathOf — one cached readDoc + linkPathOf), so rename chains collapse and a later segment rename can't stale it; no slug any more → 404. moved entries are never listed in a sitemap. renderPath returns {status: 301 | 302, redirect}; WebPreviewServer sends it as a bodyless Location response.

Old addresses (path redirects). A migrated site's former URLs — any shape (/cz/kampus/urbanity-kampus-tachov/, /produkt.php?id=12, /en/about-us.html) — live in store/{sid}/redirects/{id} (PathRedirectEntity; .dev/plans/url-admin.md U14). renderPath consults them last (step 4), only when nothing routes: the full request path (before the locale split) is canonicalized and encoded into the document id (core-lib web/path_redirect.ts: normalizeRedirectPath

  • pathRedirectId), read ONCE through the TTL-cached meta read (misses cached 60 s), compared with the stored path (an id collision is a miss) and answered 301 (302 with temporary) to the stored target through parseSlugRedirect — rebased on basePath, the request's query riding along, never locale-prefixed (the target is written as the visitor lands on it, /cs/kampus/x). A live page, a registry redirect or a routed address always wins. The slug hook flagged x-drop-redirects: 1 (label redirects/{id}) evicts the one meta key.

Reserved paths#

Beyond rendered pages, the server answers a few well-known paths (all publish-gated; <loc> entries and the Sitemap: line use the request's origin so they match the host/env that served the file):

path method notes
/sitemap.xml sitemapXml(baseUrl?) The pages urlset (homepage + static routes + the pages documents). It becomes a <sitemapindex> over the sub-sitemaps below when another collection is configured in setup.sitemap.collections, and always in path mode (one entry per locale file).
/sitemap-pages.xml sitemapPagesXml(baseUrl?, locale?) The pages sub-sitemap — always on; single documents are opted out with setup.sitemap.exclude.
/sitemap-{name}.{code}.xml sitemapFileXml(path, baseUrl?) Path mode: one file per locale URL code (/sitemap-recipes.cs.xml), holding only that locale's URLs — what the index lists. sitemapFileXml dispatches every sub-sitemap path (isSitemapFilePath).
/sitemap-{collection}.xml sitemapCollectionXml(name, baseUrl?) That collection's slug URLs — so dynamic pages are in the sitemap once the collection is configured. One URL per live slot of a registry document; moved slots, redirected slots and redirect entries are skipped. A routed collection adds one listing per routable category registry (its URLs /{segment}/{category}/{key}, each locale with its own category segment). Path mode: this file and /sitemap-pages.xml list each page once per locale under its prefix (localizedUrlsetXml), as plain <loc>s — the language versions are declared by the pages' <head> hreflang links only (Google: one method is enough); cookie mode lists the default language.
/robots.txt robotsTxt(baseUrl?) From setup.robots, with the Sitemap: line appended unless the sitemap is disabled.
/favicon.ico faviconUrl() 302 to the store's icon (the raster's 32px rendition, else the SVG as a data URI); 204 when the store sets none or is unpublished.
POST /content/:locale listContent(...) Page-driven "load more" — lists a page's collection-reference field, contract derived from the placement envelope. See "Page-driven load more" below.
POST /variant/:locale variantContent(...) In-place product-variant swap — body {path, variant}; the page is routed like a render, variant must be a key of that (products) page's propagated variants registry, and the response document carries the read-time parent fallback (same envelope-security idea as /content). The reply's href is the variant page's public URL (scope + locale prefix) — what history.replaceState should show; slug stays for older kit scripts.
POST /cart/:locale cartContent(...) Storefront cart — body {action: 'set'|'get'|'delete', id?, items?, email?, promo?, origin?, note?} forwarded to process/cart/* over the site's own serving token (which carries the process node). A browser uid is dropped — the fleet authenticates no customer. No caching, served no-store; validation caps live in the core API.
POST /delivery/:locale delivery(...) The cart's delivery methods ({country?, total?, origin?} → {methods}, priced by the core's POST /public/shipping/options) or, with {points: {carrier, q?, type?, country?}}, the pickup points of an offered carrier. Served no-store; the Studio bridge answers the same route.
POST /checkout/:locale checkout(...) Checkout — a whitelisted OrderInitEntity (typically {cart}) forwarded to process/order/make; the payment-init payload passes through. delivery_method + pickup_point ride along (plans/carriers §3.6 — validated and priced by the core), and so does payment_method (the chosen row of the store's payment catalog). finalize/status/number and user are never forwarded (the fleet authenticates no customer — a visitor must not attribute the order to one). Served no-store.
POST /checkout/:locale/payment payment(...) The payment side of the checkout (.dev/plans/invoice-providers §G6). {country?, currency?, total?} → {methods: [{id, service, method?, name}]} — the methods the cart may choose (send the chosen id to /checkout as payment_method). {order} → the return step: a gateway's return URL carries no trusted outcome, so the page the buyer lands on (?o={order}) asks here — {provider, state: {status, …}} for a module gateway (the gateway is queried and the answer applied to the order), {status} for a Stripe payment (the provider's own object never reaches the browser; paymentReturnBody in compose.ts). For a module gateway the order answers with payment.service_intent.session — navigate the top-level window there from the store's own origin. Served no-store.
GET|POST|PUT|DELETE /m/{moduleId}/** moduleCall(...) An installed module's storefront leg — the request (path after /m/{id}, query, JSON body ≤ 256 KiB, the request locale, ip / origin / referer, the visitor's identity-token) is forwarded to POST /api/public/module over the site's serving token, and the module's answer is re-emitted: its status and body verbatim, of its headers only content-type, cache-control (default no-store) and location. HEAD → 405. A GET answer that says cache-control: public, max-age=N (N ≤ 3600) is kept in the store's edge cache (see "The edge response cache" below) — never for a guarded path, a request carrying an identity-token or a non-2xx; everything else is never cached. An unknown / disabled module is the core's 404. Guarded paths (the manifest's routes.guarded): the server's formGuard chain runs first — a GET answers {fields} (no-store, the /form contract, guard form key module:{id}:/{path}), a write failing verify() is 403 {error, reason}, a passing one is forwarded with the guard fields stripped and guard: 'passed'. The list comes from the core's envelopes (ModuleWebResponse.guarded, cached per store + module for 5 min); the core refuses a guarded path without guard: 'passed' (428 guard_required, the module not called), so a stale list costs one retry, never an unguarded call. m is a reserved first segment: matchRoute never treats /m/** as a slug or a collection route, and the core refuses such slugs on write (core-lib slug_rules.ts). Locale (WebSite.requestLocaleHint): path mode = the Referer's locale prefix (none = the default locale); else an x-drop-locale header naming a store locale; else the drop_locale cookie (cookie mode only). /m/** answers keep vary: cookie.
POST /_drop/v — The stats render beacon (stats_beacon.ts): answers 204 no-store, nothing else — the host's stats recorder files the call as a view event. With WebPreviewServerOptions.statsBeacon on (the tenant server sets it while it records traffic; ignored in live mode) every served 200 page carries the inline script that sends it (?p={location.pathname}&r={document.referrer}, skipped under navigator.webdriver, deferred for a prerendered page). _drop is a reserved first segment.
GET|POST|PUT|DELETE|OPTIONS /fn/{serviceId}/** serviceCall(...) A store service (.dev/plans/modules/services.md) — the /m relay (same request envelope, body ≤ 256 KiB of any JSON, same header filter, HEAD → 405, the same guard flow with form key service:{id}:/{path}, the same edge-cache rules) run by the injected WebSiteOptions.services host, else forwarded to POST /api/public/service. CORS for the service's listed origins only; vary: cookie, origin on every answer. See "Store services" below. fn is a reserved first segment like m.

Non-indexable hosts. WebPreviewServer.handle(req, res, sid, path?, serve?) takes a per-request serve object whose indexable: false marks a host that must never be indexed. That host gets a constant User-agent: * / Disallow: / robots.txt (the store's setup.robots is not consulted), 404s on every sitemap, and x-robots-tag: noindex, nofollow, noarchive on every response — pages, assets, code URLs and errors alike — with a store-configured x-robots-tag in httpHeaders dropped so it can't opt back in. It is a header and never markup, because one render cache serves every host a store answers on. The tenant server passes it for the platform subdomains; the default is true, so a self-hosted WebPreviewServer is unaffected.

404s. A path that resolves to nothing (or whose target doc/template is missing) renders the store's custom notFound doc (setup/web.notFound) at a 404 status; without one, a minimal built-in body is sent. WebPageResult.reason says why: 'unpublished' (publish gate), 'unavailable' (the publish gate could not be read at all — a core transport/auth failure, retry-worthy unlike the others), 'no_route' (nothing matched the path) or 'no_template' (routed, but the target's doc/template is missing or blank).

Maintenance page. An unpublished store whose setup/web.maintenance names a document serves that document — through its own template, with the shell / SEO / head tags but no canonical or hreflang — for every page URL (redirect slugs included), at 503 with retry-after: 3600, x-robots-tag: noindex and x-drop-maintenance: 1 (MAINTENANCE_HEADER; the tenant fleet reads it to tell this deliberate 503 from a dying store token). Path mode takes the locale from the URL prefix without redirecting. No page configured, or its doc/template missing → the built-in 404 as before (reason: 'unpublished' either way). /favicon.ico follows the page; the interactive POST endpoints, robots.txt and the sitemaps stay publish-gated.

Options worth knowing#

option default notes
live false Bypass every cache and force the repository into remote mode. A dev/preview switch, not a production mode (the admin's own previews stopped using WebSite on 2026-09-04 — they render in the browser through compose.ts).
ignorePublish false Serve the site even when setup/web.published !== true — the admin-preview switch, so editors see a store before it goes live. Applies to page rendering, the interactive POST endpoints and faviconUrl; robots.txt and the sitemaps stay publish-gated. Public hosting (the tenant server) never sets it.
feedTtlMs 4 h (FEED_TTL_MS) The one default for every hook-covered cache — meta, data, render + deps, templates, assets, the sitemap slug index. Applies only when hookKey is set (a site that can verify pushes); without it each cache keeps its legacy default (last column of the rows below). 'legacy' forces the legacy defaults, a number replaces 4 h. A per-cache option below always wins. Why 4 h is safe: every render input is covered by a push — docs/hooks-and-caching.md / the docs site's Caching & freshness.
renderTtlMs feedTtlMs (legacy 30 min) Rendered-HTML backstop. Hooks invalidate eagerly, but they are best-effort (above) — this is what catches a lost push. The deps index always takes the same TTL.
caches in-memory Bring-your-own backends: data / meta / metaFallback (last-good meta copies, read only when a core read fails — no TTL) / template / render / deps / assets. See below.
assets on Same-origin asset serving ({serve, pathPrefix, preload, maxBytes, maxObjectBytes, ttlMs, cacheControl, warmOnHook, redirectBase} — redirectBase is where the LIVE-mode 302 points, a same-origin mirror like the admin's /asset/{env} so self-hosted fonts pass CORS) — pass {serve: false} to opt out. See below.
setupTtlMs feedTtlMs (legacy 60 s) Meta docs found — public setup docs, the store doc's locales/name, elements, slug entries, the favicon doc. The setup / slug / elements events evict them.
negativeMetaTtlMs 60 s Meta lookups that found NOTHING (an unknown slug, a missing setup doc) — stays short whatever feedTtlMs says, so a lost push for a NEW slug or doc never hides it for hours.
dataTtlMs feedTtlMs (legacy 60 min) Data documents; data pushes keep them fresh in between. A missing doc is never cached.
templateTtlMs feedTtlMs (legacy 30 min) Template backstop — the templates hook event evicts eagerly (in-place edits included); a version bump still forces an immediate fetch. 0 restores the historical no-expiry behaviour.
slugIndexTtlMs feedTtlMs (legacy 60 min) The sitemap slug index (a miss lists the whole registry); the slug event evicts the affected tier.
assets.ttlMs feedTtlMs (legacy 60 min) Cached asset bytes; the files event evicts.
hookMaxAgeMs 5 min Replay window for access-seed verification — a push whose seed timestamp is older is rejected. Only meaningful with hookKey.
moduleGate — Removed 2026-10-27: a module with no runtime doc is not installed and its elements render nothing (the rollout's 'open' mode is gone). The rest of the old row for history: 'open' renders it without a runtime (the rollout default — installs not restamped yet have no doc), 'strict' renders nothing (an uninstalled module). A runtime doc with disabled: true gates in both. See "Element rendering".
siteBaseUrl — Fallback base for sitemap/robots when no request origin is available.
coreTimeoutMs — (untimed) Timeout of the site's core reads (CORE_READ_PATH: /public/{web,content,slug,setup,locale,store,proforma}, /admin/{read,list,count,aggregate}), in ms — a hung core read fails the request in seconds instead of holding it (plans/tenthousand W9 E-D7). Writes and provider calls (checkout, cart, forms, /fn, payments, shipping) are never timed: a checkout cut short may already have created the order. The tenant sets 10 s.
routes, collection, homeDoc, storageBase, hookKey, headerStore — Route table, default collection/home document, asset base, hook signing key, store header.

Template freshness. templateCache is keyed by {schemaId}/{version}. In-place template edits (no version bump — what the admin agent writes) propagate through the templates hook event when the site receives the multi-hook feed (receiveEventHook), and are TTL-bounded otherwise. WebSite.flushTemplates() is the narrow manual lever — it clears the template cache AND render+deps (cached pages embed the template). WebSite.flushAll() clears every cache of the site (meta, slug index, data, render, deps, templates, assets) — the lever that ends any staleness after a lost push; WebPreviewServer and the tenant (/-/mount {flush: true}) expose it.

Collection routes in templates ({{route}}, window.DROP_ROUTES)#

A listing card that links to an item's own page should use the {{route}} helper instead of hand-building /{{this.seo.slug}}: {{route "recipes" this.seo.slug}} → /recepty/tomato-soup when the recipes collection is routed for the serve locale, else /tomato-soup — so the helper is safe to adopt before a store flips routing on. The routed segment map ({recipes: 'recepty'}, from routedSegments in core-lib) rides the render context like translations — the page template, the shell fragments, AND every element placement / render:true component (fragmentExtras; a listing card built inside an element links to the routed URL too) — and is also exposed to page JS as window.DROP_ROUTES for cards built client-side (load-more): /${DROP_ROUTES[c] ? DROP_ROUTES[c] + '/' : ''}${slug}. Per-collection sitemaps list the routed URLs.

Categories (.dev/plans/nested-category-routes.md): a routed collection's documents can live one level deeper, under a category declared on the row (route.categories) and picked by the document's seo.category. A third argument builds that URL — {{route "support" this.seo.slug this.seo.category}} → /podpora/fakturace/vraceni-penez; a missing or unknown category keeps the 2-segment path. The category-path map ({support: {billing: 'fakturace'}}, routedCategorySegments in core-lib — the value is the full joined path, so it stays valid at any depth) rides the same render context and is exposed as window.DROP_ROUTE_CATEGORIES — emitted only when the store categorizes something, so other pages render byte-identically: DROP_ROUTE_CATEGORIES[c]?.[cat] ? `/${DROP_ROUTES[c]}/${DROP_ROUTE_CATEGORIES[c][cat]}/${slug}` : …. A category carrying the reserved parent is not routable in v1 (absent from the map; its documents keep /{segment}/{slug}).

Store translations ({{t}})#

Several positional arguments join with a dot — {{t "tag" this}} resolves tag.sale — the form for a key held in data (a product's tags / categories); there is no concat helper. Two groups are system groups (SYSTEM_TRANSLATION_GROUPS: tag, category): the admin's Tags / Categories pages own them (a category path such as recipe:summer is one flat leaf key whose value is the leaf's label), the Localizations editor hides them, and to this package they are ordinary groups. The key rules (TAXONOMY_KEY_RX, isValidTaxonomyKey, isValidCategoryPath, categoryAncestors …) are exported next to flattenGroup.

Rendered templates (pages, shell, fragments, elements alike) can reference shared per-store strings with the {{t "group.key"}} Handlebars helper — {{t "cta.buy"}}, with optional {name} placeholder interpolation from hash args: {{t "cart.count" count=items.length}}. The returned string is HTML-escaped like any mustache.

Storage is one document per group at store/{sid}/data/{locale}/translations/{group} — everything before the first dot of the public key is the doc id, everything after is the field path inside it (nested objects resolve as deeper dotted paths: product.badge.sale). Only string leaves count; underscore-prefixed keys and doc bookkeeping fields (id, ref, schema, …) are skipped; _meta is a reserved group id.

The Translations accessor (src/web/translations.ts) keeps one merged flat map per locale in memory:

  • Warm once per locale per process — a single repo.list('translations'), deliberately off the per-request render path. A failed warm serves what was known and retries on the next call.
  • Lookup fallback chain: the page's locale → the store's first locale → the key itself (a visible cta.buy in the page is a bug report; an empty string would be silent data loss).
  • Freshness via data hooks: a translations push patches/evicts the group in the in-memory map and then drops the whole render + deps cache — any rendered page may embed a translated string.
  • live mode (the admin preview) memoizes nothing — every call re-lists, consistent with live mode bypassing every cache.

The admin edits these under /store/{sid}/translations; agents learn the helper from sdk-generative-js/rules/handlebars-binding.md.