By default StaticOwl puts each content type at a predictable section path: /blog/<slug>/index.html for blog_post, /docs/<slug>/index.html for doc, etc. That's fine for new sites. Migrations usually need something different — a large site coming from another platform can have tens of thousands of URLs under a layout like /<course>/<filename>.html that it can't afford to break for SEO. So content types accept a routePattern that pins the exact URL shape.

This document is the reference: pattern syntax, when each placeholder fires, encoding rules, locale interaction, and the fallback behavior when routePattern isn't set.


When to set a routePattern

Set one when:

Skip it when:


Syntax

/{slug}/index.html
/{parent.slug}/{slug}.html
/courses/{parent.slug}/{topic}/{slug}.html
/{category}/quiz-{slug}.html

Placeholders

Placeholder Source Notes
{slug} The item's slug (e.g. the value of its slug field, or its id slug). The most common placeholder. Falls back to the passed slug when the item has no explicit slug field.
{<fieldName>} Any field on the item. {topic}, {filename}, {category} — whatever field name your type defines. Missing fields resolve to empty string (not an error).
{parent.slug} The slug of the parent item (the item this one is parent-related to). Only meaningful for types with a parent relationship in their schema. Empty if no parent.
{parent.<fieldName>} Any field on the parent item. {parent.title}, {parent.category}. Same rules as item fields.
Anything else Literal text. /courses/, .html, -quiz-, … — the resolver writes it through unchanged.

What the resolver does, exactly

resolveRoutePattern(pattern, slug, fields, parentFields)
  → /<resolved>/path.html

For every {token} in the pattern:

  1. If token is slug, use fields.slug ?? passedSlug.
  2. If token starts with parent., look up that field on parentFields (which can be null).
  3. Otherwise look it up on fields.
  4. The result is encodeURIComponent'd so unsafe characters (spaces, ?, #, &, …) become %xx. A / in a field value is also encoded — it cannot escape its path segment.
  5. Anything between placeholders is literal text and passes through unchanged.

The resolver guarantees a leading /. If your pattern starts with {slug}.html, you get /some-slug.html — no need to remember.

If a referenced field is missing, the placeholder collapses to empty string and the rest of the pattern keeps going. /{parent.slug}/{missing}.html with no parent and no missing field resolves to //.html. That's a footgun; either gate it on parent being present at the content-type level, or pick a default.


Setting it

Via POST /api/types when creating a new content type:

POST /api/types
X-Site-Id: site:acme

{
  "name": "lesson",
  "label": "Lesson",
  "fields": [
    { "name": "title",    "type": "string", "required": true },
    { "name": "filename", "type": "string", "required": true, "unique": true },
    { "name": "body",     "type": "markdown" }
  ],
  "relationships": [
    { "name": "course", "edgeLabel": "BELONGS_TO_COURSE", "target": "course",
      "kind": "many-to-one", "required": true }
  ],
  "routePattern": "/{parent.slug}/{filename}.html"
}

Or via PUT /api/types/<name> to add it to an existing type. The pattern takes effect on the next build; existing artifacts at the old URLs stay until rebuilt or pruned. If the URL shape change is structural enough to break inbound links, pair it with a redirect — see api.md → Redirects.

The MCP tool surface mirrors the API: types_update accepts routePattern (and types_create accepts it at create time).


Examples

Flat top-level pages

routePattern: "/{slug}.html"

Item { slug: "about" }/about.html Item { slug: "pricing" }/pricing.html

Section + slug (the default, made explicit)

routePattern: "/blog/{slug}/index.html"

Item { slug: "launch" }/blog/launch/index.html

Parent-prefixed (course / lesson pattern)

routePattern: "/{parent.slug}/{filename}.html"

Item { filename: "intro" } whose parent has { slug: "algebra-101" }/algebra-101/intro.html

Literal segments around placeholders

routePattern: "/{parent.slug}/quiz-{topic}.html"

Item { topic: "arrays" } whose parent has { slug: "cs101" }/cs101/quiz-arrays.html

Field-driven category

routePattern: "/case-studies/{category}/{slug}.html"

Item { category: "finance", slug: "regional-bank" }/case-studies/finance/regional-bank.html


Locales

Locale prefixing is layered on top of the resolved pattern:

routePattern:  "/{parent.slug}/{filename}.html"
defaultLocale: "en"
locale:        "fr"
→ /fr/cs101/intro.html

defaultLocale: "en"
locale:        "en"     ← the default locale is NOT prefixed
→ /cs101/intro.html

The pattern itself shouldn't include the locale segment — let the build layer it.


Fallback behavior

When a content type has no routePattern (or it's an empty / whitespace-only string), URLs fall back to the legacy section map:

Content type Default URL
blog_post /blog/<slug>/index.html
doc /docs/<slug>/index.html
landing_page /<slug>/index.html
(anything else) /<typename>/<slug>/index.html

The fallback is what every site got before 2026-06-14. New sites that don't set routePattern keep getting it; setting routePattern opts in to the explicit form.


Encoding safety

encodeURIComponent is applied to every resolved value. That means:

This is intentional. A field with / in its value cannot escape its path segment and redirect content into an unrelated section. If you need a multi-segment URL, model it with multiple placeholders ({section}/{slug}) — not by stuffing slashes into one field.


Build-time validation

The compiler computes the resolved URL for every item at build time. If two items resolve to the same URL — say two lessons with the same filename field but different parents that happen to have the same slug — the build aborts with an explicit collision error pointing at both items. Fix by:

The same collision check applies when a routePattern-customized type and a default-section type would produce the same URL.


Performance

resolveRoutePattern runs in well under 0.05 ms per call (10 k iterations < 500 ms on a t4g.small). It's not a build hot path; you can use it freely.


See also

Related docs

Resolved at build time via {% similar %} — cosine similarity over embeddings, not tag overlap. Zero arguments.

Content modeling & field types
65% match
Zero to deployed page — the 5-minute quickstart
63% match
Template language reference
62% match