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:
- You're migrating an existing site and need to preserve URLs.
- A content type has a parent and you want the parent's slug in the URL (
/courses/{parent.slug}/{slug}.html). - You want a flat layout that drops the section prefix (
/{slug}.htmlfor top-level pages). - The URL needs to include a non-slug field (
/case-studies/{category}/{slug}.html).
Skip it when:
- You're building from scratch and the default
/<type>/<slug>/index.htmlis fine. - You don't care about the URL shape and a directory-style URL is acceptable.
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:
- If
tokenisslug, usefields.slug ?? passedSlug. - If
tokenstarts withparent., look up that field onparentFields(which can benull). - Otherwise look it up on
fields. - 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. - 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:
- Spaces become
%20. /,?,#,&,:,=,+all get encoded.- A user with slug
a/bdoes not end up at/a/b/— they end up at/a%2Fb/.
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:
- making the relevant field
unique: trueon the type definition, or - choosing a more discriminating pattern (
/{parent.slug}/{category}/{filename}.html).
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
- Template language reference — what to do with
{{ url }}in a template - HTTP API —
/api/types— set / changeroutePattern - Redirects — pair a pattern change with old-URL redirects
packages/server/src/build/compile.ts—resolveRoutePatternandcontentPageUrlPath(authoritative source)