Site Configuration

The site.yml file in your site root defines global settings, page ordering, language support, and feature toggles.

Quick Start

A minimal site.yml:

name: My Site

That's all you need to get started. Everything else has sensible defaults.

Full Reference

# Identity
name: My Site
description: A brief description for SEO
preview: /images/card.png            # The site's card image in the Uniweb apps (optional)
keywords: [components, react, cms]   # Default meta keywords (pages can override)
seo:                                 # Site-level social card + SEO defaults
  baseUrl: https://my-site.com       # Turns on sitemap.xml, robots.txt, absolute llms.txt links
  image: /og-default.png             # Default Open Graph / social-sharing image
  ogTitle: My Site

# Page Ordering
pages: [home, about, ...]            # Inclusive order (first is homepage, ... = rest)
pages: [home, about, docs]           # Strict order (unlisted hidden from nav)
index: home                          # Or just name the homepage

# Languages
defaultLanguage: en
languages: [en, es, fr]              # Or '*' to auto-discover from locales/

# Code — the foundation and its extensions
foundation: '@acme/marketing@1.2.3'  # Or a workspace package name, or a full URL
extensions:                          # Secondary foundations (optional)
  - '@acme/effects@0.3.1'

# Layout — cascades to every page; folder.yml and page.yml override per field
layout:
  name: DocsLayout
  hide: [right]

# Services — search, forms, tracking, an assistant, accounts: one entry each
services:
  search:                            # on, with options
    exclude: { routes: [/legal] }
  submit: https://forms.example.com/f/abc   # a provider you bring
  backend: true                      # ask your host for the site's own backend
$devBackend: ./mock/api.js           # what answers `backend` in `uniweb dev` (never published)

# Build Options
build:
  prerender: true                    # Generate static HTML

# Data
query: config                        # The site's fetch — see Site-Level Data Fetching

# Queries — or in queries.yml
queries:
  articles:
    schema: '@/article'
    sort: date desc

# Placeholders — values written once, referenced from any page as {name}
placeholders:
  product: Uniweb
  vendor:
    email: billing@acme.example

# Custom Content Paths (optional, for external content)
paths:
  pages: ../docs/pages             # Default: pages/
  layout: ../docs/layout           # Default: layout/
  records: ../content              # Default: records/

Identity

name: My Site
description: Build modern websites with components
tags: [business, landing-page]
preview: /images/card.png
FieldTypeDescription
namestringSite name (used in <title>, metadata), and the title on the site's card in the Uniweb apps
descriptionstringDefault meta description, and the line under the title on the site's card
tagslistWhat the site is for, as standard ids — the site tags. Apps use them to filter and label sites, such as in a template picker. An id that is not on the list is kept, and simply has no label.
previewstringThe image on the site's card in the Uniweb apps — useful for a template site that wants a picture of its own. A URL, or a site-root path to an image in your project, which uniweb push uploads like any other image. Leave it unset and the app generates one, or compose one from your site with uniweb snapshot.

Code: Foundation and Extensions

A site is content; the code that renders it comes from two declarations, which take the same kinds of value because an extension is a foundation.

foundation: '@acme/marketing@1.2.3'   # the primary — required
extensions:                            # secondary foundations — optional
  - '@acme/effects@0.3.1'

You don't declare a runtime version. A site ships no JavaScript — content, config, data and assets only — so nothing in it links against the runtime and nothing in it breaks when the runtime moves. The host serves a runtime compatible with whatever foundation the site loads.

foundation

The component system your pages are composed from. Four accepted shapes:

ShapeExampleMeaning
workspace package namesrca foundation in this repo (follow the file: dep to its folder)
versioned catalog ref@acme/marketing@1.2.3published; loaded by the host
full URLhttps://cdn.example.com/entry.jsloaded from that URL
object form{ url: 'https://…' }same, with an explicit CSS URL if needed

A versionless @org/name is an error rather than a shorthand — the build asks for a version. Foundations are never npm packages; don't npm install one.

extensions

Secondary foundations that contribute additional section types. Each entry accepts the same shapes as foundation:, and the primary wins on a name collision.

extensions:
  - '@acme/effects@0.3.1'              # catalog ref  — recommended
  - effects                            # a workspace package name (local development)
  - https://cdn.example.com/e/entry.js # an absolute URL

A site-relative URL (/effects/entry.js) only works where the site serves its own files — uniweb export and uniweb deploy --host=<adapter>. A site published to Uniweb hosting ships no JS, so nothing serves that path; uniweb publish rejects it and points you at the catalog-ref form. Register the extension (uniweb register in its directory) and reference it like any other foundation.

When you reference a workspace-local extension, uniweb publish brings it along the same way it does the primary: it releases the extension if its code changed or isn't registered yet, and pins the released @scope/name@version on the published site.


Layout

Set the layout for the whole site, and adjust it per branch or per page.

layout: DocsLayout          # the shorthand — just the layout name
layout:                     # or the expanded form
  name: DocsLayout
  hide: [right]             # areas of the layout to leave out
  params:                   # layout-specific settings
    sidebarWidth: wide

hide names layout areas — header, footer, right, or whatever your foundation declares — and turning one off leaves its content alone. Re-enable it later and everything is still there.

The cascade

The same layout: block works in three places, and the nearest one wins per field:

whereapplies to
site.ymlevery page
folder.ymlthat folder and everything under it
page.ymlthat page

Because it merges field by field, a page that sets only hide keeps the name it inherited:

# site.yml
layout: { name: DocsLayout, hide: [right] }

# pages/pricing/page.yml — full width, still DocsLayout
layout: { hide: [] }

That last line is worth noticing: hide: [] is "hide nothing", which is how a page opts out of an inherited hide. An absent hide inherits; an empty one overrides.

See page configuration for the page-level form.


SEO & Social Sharing

Site-level metadata for the homepage's social card and search — and the defaults every page inherits. These mirror the page-level seo: / keywords: in page.yml, hoisted to the site root.

keywords: [components, react, cms]   # Default keywords (pages can override)
seo:
  baseUrl: https://acme.com          # Where the site lives — turns on sitemap.xml + robots.txt
  image: /og-default.png             # Default Open Graph / social-card image
  ogTitle: Acme — Build with Components
  ogDescription: The component content platform.
  noindex: false                     # Set true to keep the whole site out of search
  robots:                            # robots.txt contents
    disallow: [/private]
FieldTypeDescription
keywordsstring[]Default meta keywords; a page's own keywords override
seo.baseUrlstringThe site's public origin. Setting it turns on sitemap.xml and robots.txt, and makes llms.txt links absolute — all three are skipped or root-relative without it, because none of them can be written without knowing where the site lives. uniweb doctor warns when it is unset
seo.robotsobjectrobots.txt contents — disallow, allow, crawlDelay, contentSignals. Only emitted alongside seo.baseUrl
seo.imagestringDefault Open Graph / social-sharing image (the site's social card)
seo.ogTitlestringDefault social title; a page's own title or seo.ogTitle wins
seo.ogDescriptionstringDefault social description; a page's description or seo.ogDescription wins
seo.noindexbooleanKeep the entire site out of search engines (cascades to every page)

sitemap.xml and robots.txt are static-host artifacts: uniweb export and uniweb deploy --host <adapter> write them into dist/. A site published to a backend does not get them from the build — what that host serves is the host's.

Cascade: site-level seo and keywords are defaults. Each page overrides any field it sets — the page wins, the site fills the gaps. The social image is the field most worth setting once at the site level.

These render into the static HTML <head> (Open Graph, Twitter Card, canonical, robots) for the homepage and every page, so crawlers and social unfurlers see them without running JavaScript. For arbitrary tags beyond these, use Custom Head Injection.


Page Ordering

Control the order of top-level pages and designate your homepage.

pages: [home, about, ...]

The ... wildcard means "all remaining pages here." Pages before ... appear first in that order; pages after ... appear last. Everything else fills the middle.

  • First item becomes the homepage (route /)
  • Other items get their folder name as route (/about, /docs, /pricing)
  • ... expands to all pages not explicitly listed, in their natural order
# home first, contact last, everything else in between
pages: [home, ..., contact]

# home first, about second, rest after
pages: [home, about, ...]

# rest first, legal last
pages: [..., legal]

Strict Order

pages: [home, about, docs]

Without ..., only listed pages appear in navigation. Unlisted pages are still built and reachable by URL, but suppressed from every nav menu (equivalent to hideIn: ['*'] — a nav-only exclusion, not hidden, which would drop them from the published site).

Use this when you want precise control over what appears in the nav — for example, a landing page with only a few pages in the header.

Just Set the Homepage

index: home

Only specify which page is the homepage. Other pages are auto-discovered and sorted by their order property.

Auto-Discovery (Default)

Omit pages, index, and order to auto-discover all pages. They're sorted by the order property in each page's page.yml, and the lowest order becomes the homepage.

From the Pages Directory

pages: and index: can also live in the config of the pages directory itself — pages/folder.yml, or pages/page.yml — where they order the top level the way a folder's config orders its children. This is the place for content that travels: a docs repository that carries its own order keeps it whether it is mounted below the root or used as the whole pages directory (Custom Content Paths).

site.yml wins. Its pages: replaces theirs, and a homepage it names — with either key — replaces the one they name, so a site.yml index: decides the homepage even when the pages directory lists an order.

Only these two keys are read there. The top level has no container page, so a title or an SEO block there describes nothing, and the site's layout is site.yml's layout:.

In folder mode — a folder.yml in pages/ — nothing is promoted to / unless one of these names it.


Content Mode

By default, .md files in a folder are sections of a single page (page mode). By placing a folder.yml in a directory, you switch it to folder mode — where each .md file becomes its own page.

Config fileModeFolder is....md files are...
page.ymlpage modeA pageSections of that page (default)
folder.ymlfolder modeA containerIndividual child pages

Folder Mode

Ideal for documentation sites where each file is a standalone article:

pages/docs/
├── folder.yml               # Activates folder mode
├── getting-started.md       # → /docs/getting-started
├── configuration.md         # → /docs/configuration
└── advanced/
    ├── folder.yml
    ├── plugins.md           # → /docs/advanced/plugins
    └── themes.md            # → /docs/advanced/themes

Page titles come from the H1 heading in each markdown file. Frontmatter remains section configuration (type:, background:, etc.).

To activate folder mode for the entire site, place a folder.yml in the pages/ directory itself. Its pages: and index: then order the top level and name the homepage — see From the Pages Directory.

Mode Cascade

The mode set by folder.yml or page.yml cascades to descendant folders:

  1. folder.yml in a directory → folder mode for that folder and all descendants
  2. page.yml in a directory → page mode for that folder and all descendants
  3. Neither → inherit from parent (default: page mode)

A single folder.yml at the top of a docs tree applies folder mode to the entire tree. A subfolder can override back to page mode with a page.yml.

folder.yml

The configuration file for container folders in folder mode. Analogous to page.yml but signals that .md files are pages, not sections:

# folder.yml
title: Documentation
description: API reference and guides
pages: [getting-started, configuration, ...]
label: Docs
layout:
  left: true
FieldTypeDescription
titlestringContainer title (for navigation, breadcrumbs)
descriptionstringMeta description
pagesarrayChild page ordering with ... wildcard support
indexstringIn the pages directory's own folder.yml only: which top-level page is the homepage, unless site.yml names one. Deeper in the tree it has no effect — only the top level has a homepage
labelstringShort navigation label
hiddenbooleanHide from navigation
layoutobjectLayout panel overrides
seoobjectSEO overrides
idstringStable ID for page: links

Ordering in Folder Mode

Child pages are ordered by:

  1. pages: array in folder.yml with ... wildcard support (same semantics as site-level)
  2. Numeric file prefix (1-intro.md before 2-setup.md)
  3. Alphabetical by filename
# folder.yml
title: Documentation
pages: [getting-started, configuration, ...]

Languages

Enable multi-language support.

defaultLanguage: en
languages: [en, es, fr]

Options

OptionTypeDescription
defaultLanguagestringPrimary language (no URL prefix). Defaults to the first entry in languages, or en
languagesarraySupported languages — the working set you author in
publishLanguagesarrayWhich declared languages ship in a published build. Absent = all of them

Language Formats

# Codes — each language is named from its code
languages: [en, es, fr]

# Auto-discover from locales/ folder
languages: '*'

A language is named by its code: a switcher shows Español for es, from a built-in table, and any other code in its own language where the platform knows it (ca is Català). A { code, label } entry is an error — a label cannot travel when a site is pushed to a backend.

Draft Languages (publishLanguages)

languages is your working set; publishLanguages declares which of them a published build actually ships. A declared language you're still translating stays fully previewable in uniweb dev but is excluded from production output until you add it to the list:

languages: [en, fr, de]        # working on all three
publishLanguages: [en, fr]     # de is still a draft — dev-only

Rules:

  • Absent field — every declared language is published (the default, and the behavior of all existing sites).
  • Published builds exclude unlisted languages everywhere a visitor could see them: no dist/{locale}/ output, no locale-switcher entry, no sitemap or hreflang references. To visitors, a draft language is indistinguishable from an undeclared one.
  • uniweb dev ignores the list — drafts render normally so you can work on them, the same way hidden pages stay previewable in dev.
  • The default language must be published. A publishLanguages that excludes the effective default (or an empty list) fails the build with a clear error.
  • Codes not in languages are kept but inert. If you remove a language from languages while it's listed in publishLanguages, the entry stays (with a build warning) — re-declaring the language later restores it as published without touching the list again.
  • uniweb i18n commands keep operating on the full declared set — drafts are exactly what you're translating.

Translation Workflow

Translations are extracted and managed through a hash-based system:

uniweb i18n extract    # Extract translatable strings
uniweb i18n sync       # Detect changes
uniweb i18n status     # Check coverage

This generates locales/manifest.json with all translatable content, and you provide translations in locales/{locale}.json keyed by content hash.

Generated Routes

PageDefault LocaleOther Locales
Home//es/, /fr/
About/about/es/about, /fr/about

See Internationalization for the full guide.

Advanced i18n Options

Less common settings remain under the i18n: key:

i18n:
  routeTranslations: ...
  localesDir: translations         # Default: locales

Site Services

Search, form submissions, tracking, an assistant, accounts and live records are services, and a site configures all of them in one place — services:, one entry per service:

services:
  search:                                   # on, with options
    exclude: { routes: [/legal] }
  submit: https://forms.example.com/f/abc   # a provider you bring
  tracking: { consent: required }
  backend: true                             # ask your host for the site's own backend
  records: true                             # ask your host to deliver your records live

Each entry is one of:

valuemeans
trueon — your host's, where it offers the service; on a static site, the built-in provider if there is one (search has an index)
falseoff
an address — a string, or endpoint: in a mapon, answered by a provider you bring — on a host, your host is asked to leave its own off
a mapon, with options; enabled: false inside turns it off and keeps them

Anything else stops the build and the push, naming the entry and what to write — an empty entry, a number, a list. ⚠️ yes, no, on and off are words here, not switches: YAML reads them as text, and text is an address. Write true or false.

  • On a site your host publishes, a service is off unless services: asks for it. List the services your site uses; the official templates list theirs.
  • An entry says everything about its service. An option you remove from it is removed from your site on the next push. enabled is the switch, never an option.
  • records delivers your records live to your published pages. A query whose records have a data schema is answered by it on a host that offers it; without it, those pages show no records there. A static build reads records from the files it writes, and ignores the entry. Pushing and pulling records works either way.

On a site you push or publish, services is also what you ask your host for. uniweb push and uniweb publish send each service site.yml lists, as it says it, and switch off one you have removed from the file. A service your site has that this copy has never seen — one turned on in the Uniweb app since your last pull, say — is left as it is. If a service you changed was also changed on your site since your last pull, the push stops and names it: run uniweb pull, then push again. uniweb publish also says when your pages show records and services does not ask for records. uniweb pull writes the services your site has into services — one that is off, with no options, only where site.yml already names it.

Everything but a credential is public. An entry's options reach the built site — except backend's settings, which only your host reads. A credential (apiKey, token, secret, …) is never sent or published: set it in the app, where secrets are kept.

The services are documented one by one below. For how a component asks for one, and what a host's answer means, see Site Services.

The top-level search:, submit:, assistant:, tracking: and api: keys are retired. The build stops on them and says where each one moves. So does api under services:, which is backend now, and $devApi:, which is $devBackend:.


services.search — built-in full-text search. It is on by default; write it to turn it off, to set an option, or to ask your host for it on a site you publish.

services:
  search: true       # on
services:
  search: false      # off

Full Options

services:
  search:
    include:
      pages: true
      sections: true
      headings: true
      paragraphs: true
    exclude:
      routes: [/admin, /draft]
      components: [CodeBlock]

A search server of your own is an address — provider: endpoint with endpoint: — and on a host it asks your host to leave its own search off. See Site Search for details.


Agent-Readable Artifacts

Every build emits an index and per-page markdown that an AI agent can read directly. Free and on by default — this block only turns things off or narrows them.

agents:
  index: true            # /llms.txt — an annotated index of the site
  markdown: true         # /{route}.md — each page as clean markdown
  branchIndexes: true    # /docs/llms.txt for branches large enough to want one
  branchMinPages: 5      # how large is large enough
  exclude: [/internal]   # keep a branch out of both (cascades to its subtree)

agents: false            # or turn the whole capability off in one word

exclude takes route path prefixes and excludes the whole branch. The leading slash is optional and a trailing one is ignored, so /internal, internal and /internal/ are the same. Matching is by whole segment — /internal excludes /internal/pay and does not exclude /internally-facing. Wildcards are not supported: /internal* matches nothing and silently excludes nothing.

seo.noindex pages, hidden pages, _-prefixed drafts, parametric page templates and knowledge pages are excluded already.

Expected origins

If the site runs an agent, declare where its requests are expected to come from. This suits a site that exists to serve someone else's app — a chat endpoint your web app calls — rather than its own visitors.

agents:
  expectedOrigins:
    - https://app.example.com
    - https://partner.example.org

It is a declaration, not a gate. You are telling the host where you expect callers from; a host that offers an agent may then decline requests whose browser-sent Origin is not among them, before spending anything on an answer. Where it is honoured:

not declaredno check at all — nothing happens unless you ask for it
your own site's originalways accepted, without listing it
a request with no Originaccepted — see below
matchingexact, per entry; no wildcards

⛔ This is not a spend limit and cannot be made into one. Origin is set by the browser and enforced by the browser — anything that is not a browser sends whatever it likes, or nothing at all. It stops casual misuse and third-party embedding, such as someone dropping your endpoint into their own page. It does not stop anyone determined to run up your bill. Do not size a budget on it.

A request with no Origin is accepted deliberately, because there are two shapes of caller and this sees only one: your app's JavaScript sends Origin and is covered; your app's server never sends one, and refusing it would break the integration this partly exists for. Server-to-server callers are authenticated by arrangement with your host.

Never put a shared secret in site.yml. Everything in it is delivered to every visitor as part of the site payload.

Unknown keys in agents: are forwarded to your host as opaque data and simply never acted on, so a typo is silent. uniweb doctor flags them.


Accounts

A site can have its own backend — the backend service: accounts, per-visitor data, content its members create. On a site you publish, ask your host for one:

services:
  backend: true

Its address comes from your host. Components never see it: they ask @uniweb/api — isBackendEnabled() is false on a site without the service — so a foundation works unchanged on a site with it and on a site without it. A backend you run yourself is an address — backend: https://your-backend.example/_api under services: — and asks your host to leave its own off.

This is the reference for the key. For how to build one — sessions, per-visitor data, what the site does without one, and where a demo fixture belongs — see Sites with Accounts.

Developing against it: $devBackend

Building an app against a live service is slow, and it puts a shared database behind your experiments. Name a local handler instead:

services:
  backend: true              # in production, your host's
$devBackend: ./mock/api.js   # in `uniweb dev`, this answers it

./mock/api.js default-exports a function that takes a Request and returns a Response:

export default (request) => Response.json({ entities: [], matched: 0 })

In uniweb dev the dev server answers the site's backend service with it, at an address of its own — your site never names one, so nothing in your foundation knows which backend it is talking to. Because it is same-origin, sign-in cookies behave exactly as they will in production.

Anything can sit behind it: a hand-written stub, recorded fixtures, or your real service running as a function. If you are building against Uniweb's own backend service, @uniweb/api ships one that speaks it:

// mock/api.js
import { createMockBackend } from '@uniweb/api/mock'
export default createMockBackend({ seed }).fetch

$devBackend is never published. Keys beginning with $ are local to your working copy and are stripped from the built site, so a visitor cannot reach your local handler and a build cannot ship it by accident. Delete the line and the site still works — it simply has no backend service in development, and the features that need one disappear rather than break.

The conference template is a worked example — a programme that reads as a static site, and becomes an editable app for the people running the event:

uniweb create my-event --template conference

Form Submissions

services.submit — where this site's forms send their submissions.

services:
  submit: true                               # your host's form handling
services:
  submit: https://forms.example.com/intake   # a form service you bring

A form's destination comes from the first of these that applies:

  1. Your host's — where your host handles submissions, that is where they go. submit: true asks for it on a site you publish.
  2. An address of your own — for a static site, or a host that does not handle submissions. On a host that does, it asks your host to leave its own off, so yours answers.
  3. Neither — there is nowhere to send a visitor's answers, so a component draws no form, or shows contact details the site already carries.

Full Options

services:
  submit: /forms                             # an address on your own site
services:
  submit:
    endpoint: /forms                         # the same, as a map

A relative endpoint resolves against the site's base:, so one spelling works whether the site is served from the root or from a subdirectory. An absolute URL is used as written.

See Receiving Form Submissions for the component side.


Tracking

Where this site's usage events go. One destination, one stream: page views and anything a component reports share it.

services:
  tracking: https://collector.example.com/events

Off unless you set it. With no destination configured, nothing is collected and nothing is sent — components that report events simply do nothing.

Full Options

services:
  tracking: /collect                            # an address — a path on your own site
services:
  tracking:
    endpoint: https://collector.example.com/events   # the same, as a map
    consent: required                           # hold everything until the visitor agrees
    scripts:                                    # a vendor's own script — see below
      - https://vendor.example.com/tag.js

A relative endpoint resolves against the site's base:, the same way a form's and search's do. An absolute URL is used as written.

On a site you publish, tracking: true asks your host to collect for you, and the address comes from the host. An endpoint of your own means you collect yourself, and asks your host to leave its collector off.

The endpoint is yours to provide. Events are sent in the format below, so the destination has to be something that accepts it — your own collector, a function you deploy, or an endpoint your host offers. A third-party analytics product's public API expects that product's own format and will not understand these events. Some accept the request and discard the contents, which looks exactly like success.

What your collector receives

One POST per flush, batched, with Content-Type: application/json:

{
  "events": [
    { "event": "page_view", "visit": "b3f1…", "path": "/about", "first_of_load": true, "referrer": "https://news.example/post" },
    { "event": "page_view", "visit": "b3f1…", "path": "/pricing", "referrer": "https://news.example/post" },
    { "event": "video_milestone", "visit": "b3f1…", "path": "/about", "section": "Hero", "milestone": 50 }
  ]
}

Reply with any 2xx. The response body is ignored and a failed send is never retried — the last flush of a visit goes out as the page unloads, where there is nothing left to retry with.

Every event carries event, visit and path. Components add their own fields beyond that, and the framework constrains none of them.

visit is an opaque value generated when the page loads, so your collector can tell that a set of events came from the same page load and reconstruct what someone did in what order.

The events

Three events are sent automatically, with no component involvement.

page_view goes out on every route change, including the first:

  • path is site-relative — a site deployed at /docs/ reports /about.
  • referrer is the external page the visitor arrived from, if any. Same-site navigation is not a referral and is not reported.
  • utm_source, utm_medium, utm_campaign ride along when the visitor arrived with them.

first_of_load appears on the one page_view that opened the document, and on no other. A site is a single-page app: after the first load, moving between pages sends more page_view events without loading anything, and those do not carry it. It is sent only when true.

It exists so a collector that stores nothing per-event can still answer questions about the page load. With it, "which page did this load start on" is a single field on a single event — no need to remember which visit values you have already seen. ⚠️ It says first view of this page load, never this visitor's first ever view: nothing in this system survives the document, so that second question cannot be asked at all.

continues appears on a page_view when the visitor was already on your site and something loaded a fresh page rather than navigating within the app — switching language is the usual cause. It is sent only when true.

It exists because that case and a genuine first arrival otherwise look identical: a visitor arriving from elsewhere carries a referrer, and a visitor coming from one of your own pages has that referrer dropped (your site should not appear as its own top referrer) — leaving both with nothing. Without this field, a "landing pages" report counts every language switch as somebody arriving on the translated page.

⚠️ It says only this page load continues a visit — never which one, and nothing links the two. And a site that sends no-referrer suppresses the signal it is derived from, so the field will not appear there.

⛔ continues describes the page load, not the individual view — so it is repeated on every page_view of that load. A site is a single-page app: after the first load, moving between pages sends more page_view events without loading anything, and each one carries the same continues the load started with. Counting every view that lacks continues therefore counts one arrival again for every page the visitor then reads.

To build a landing-pages report: count a page_view that carries first_of_load and does not carry continues. That needs no grouping and no stored state — the two fields on one event answer it.

⚠️ Three different situations leave continues absent and you cannot tell them apart: a genuine arrival, a site sending no-referrer, and a site still running a framework release from before the field existed. Requiring first_of_load keeps the third one safe: a site too old to send it is left out of the report entirely, which undercounts, rather than counted as an arrival on every page — an undercount you can describe, instead of a number that grows on its own as sites update.

Referrer and campaign values are read once, when the page first loads, and attached to each view of that visit — they exist in the URL only on arrival. So a per-view count of utm_source tells you views by visitors who arrived via X, never views that carried X. Same numbers, different sentence.

outbound_click goes out when a visitor follows a link that leaves your site. It carries hostname — the destination host and nothing else. The path and query string never leave the page, so a link someone shares with a search term or a token in it cannot deposit that into your analytics.

section_view goes out the first time a section is at least half visible, once per section per page view. It is off unless a page asks for it with trackSections: true in that page's page.yml, because every section of every page is a volume of data most sites have no question for.

Everything else is sent by components. Two ship with the framework's component kit, so they are worth recognising if you build a dashboard:

eventsent byfields
page_viewthe runtime, automaticallypath, first_of_load?, continues?, referrer?, utm_*?
outbound_clickthe runtime, automaticallyhostname
section_viewthe runtime, on pages with trackSectionssection, section_id
video_milestone<Media> playing a videomilestone, src
read_depthuseReadingDepth() in a long sectiondepth — 25, 50, 75, 100

Any other event name is one a component chose; there is no list of permitted names. See the AGENTS.md guide in your project for the component side.

Choosing what is sent (emit)

A site sends page_view, outbound_click and section_view unless it says otherwise — or, where your host supplies the collector, whatever that host declares it collects (see Where your host supplies the collector, below):

services:
  tracking:
    endpoint: https://collector.example.com/events
    emit: standard

emit stands on its own. Where your host supplies the collector you declare only what to send, and the address comes from the host:

services:
  tracking:
    emit: minimal

The two are read key by key, so naming emit alone overrides nothing else the host declared. An endpoint: of your own means you collect yourself, and asks your host to leave its collector off.

valuesends
minimalpage_view
standardpage_view, outbound_click, section_view — the default when the collector is your own
alleverything the framework emits, including events added in later releases
a listexactly those event names, e.g. [page_view, section_view]

standard and all select the same events today. They differ the moment a new automatic event ships: all picks it up, standard does not, so a framework release never grows what a site sends.

Where your host supplies the collector

Naming emit is how you decide, and it always wins. Saying nothing is different depending on who supplies the address:

your sitesaying nothing about emit means
sends to its own endpoint:standard — the curated set above. A later framework release never grows it
uses a host-supplied collectorwhatever that host declares it collects

The second case is the one to know about. A site on a host has no address of its own — the arrangement is that the host does analytics for it — so leaving emit unset means whatever my host offers, and the set grows if your host starts collecting something new. That is your host's decision, announced by them, and it is the same event that changes your bill or your quota.

If you would rather pin it, name it. emit: standard gives you exactly the three events above and nothing a host adds later; emit: minimal gives you one; a list gives you precisely what you write. Any of those beats the host's default, including narrowing to less than the host offers.

emit does not limit what components send. block.track() and useTracker() are unaffected — it governs only the events the framework emits on its own. A host that supplies your collector may narrow the list further if it will not store an event; it can never widen it beyond what you asked for.

Batching (flushIntervalMs)

Events are batched and sent on a timer — every 5 seconds by default. A page view is sent immediately rather than waiting, and whatever is still queued is flushed when the page is hidden or closed.

services:
  tracking:
    endpoint: https://collector.example.com/events
    flushIntervalMs: 30000      # milliseconds

Raising it means fewer, larger requests. The cost is tail loss: events from the last few seconds of a visit are more likely to be missed if the browser is closed abruptly, since the flush on hide is best-effort.

A host that supplies your collector may set this for you, and your own value overrides theirs — so this is mainly for a site pointing at a collector it chose, where there is no host to set it.

⚠️ The unit is milliseconds, and the name says so on purpose. 30 here is thirty milliseconds, not thirty seconds — which would flush about thirty times a second. A value that is not a positive number is ignored and the default is used.

Third-party scripts

If you also use a vendor's own analytics or tag manager, name its script and the framework loads it:

services:
  tracking:
    scripts:
      - https://vendor.example.com/tag.js

⭐ This works on every host, including Uniweb Cloud — the runtime loads the script, so nothing depends on who builds your pages. That is the practical difference from pasting the same tag into head.html, which only applies where the framework builds them. If you are declaring a vendor's tracking script, this is the place to put it.

You can add it alongside an endpoint of your own or on its own — the two are independent:

services:
  tracking:
    endpoint: /collect        # optional — your own collector
    consent: required         # optional — gates both
    scripts:
      - https://vendor.example.com/tag.js

The vendor's script measures its own way, with its own storage, and reports to the vendor. Nothing is shared or translated between it and the events above.

What the framework guarantees about the load:

  • Once per page load, as an async script in <head>.
  • Only after consent, when consent: required is set — the script is not merely suppressed, it is never fetched.
  • Never inside an iframe, so composing a site in an editing preview does not register as traffic.
  • Never during prerendering — loading a script is a browser-only step.

A relative path resolves against the site's base:, the same as the endpoint.

Each entry is a URL, and nothing else — there is no field for inline script. Get it from your vendor's own install instructions.

Check that install snippet before relying on this. Some vendors give you a single <script src="…"> and nothing more, which is exactly what this loads. Others also run a few inline lines to configure themselves, and those lines have nowhere to go here — for that, use head.html, noting which hosts apply it.

What is and isn't stored

Nothing is stored on the visitor's device. No cookie, no local storage — the visit key lives in memory and is gone on refresh, in a new tab, or tomorrow. It identifies one page load, not a person: it cannot be linked to another visit, another tab, or another site. There is no visitor id, no session that spans days, and no fingerprint.

That is a trade, not only a property, and it is worth knowing before you compare numbers with another tool. Counting unique people, sessions, returning visitors or bounce rate all require storing an identifier on the device that outlives the page — which is how analytics products reporting those numbers get them. This stores nothing, so it counts page views and events and stops there.

consent: required holds every event until a visitor answers. Nothing leaves the browser before then; agreeing sends what was held, declining discards it.

Without it, tracking starts as soon as the page loads. Declaring a destination is treated as your decision to track — the framework does not assume which laws apply to your site. The section above states what is and isn't stored, by this and by the alternatives, so that the decision is yours to make on the facts.

A consent banner is an ordinary component. Because tracking now comes from the site itself rather than a third-party script, browser blockers and consent tools cannot intercept it — so if you need consent, use this option and a component that sets it.


Build Options

Configure the production build.

build:
  prerender: true
  splitContent: auto

Options

OptionDefaultDescription
prerendertrueGenerate static HTML for all pages (SSG)
splitContentautoSplit page content into separate files for lazy loading

When prerender: true:

  • All pages are rendered to HTML at build time
  • JavaScript hydrates for interactivity
  • Fast initial load, SEO-friendly
  • Pages work without JavaScript

When prerender: false:

  • Single index.html with client-side rendering
  • Pages render in the browser
  • Smaller initial bundle

Split Content

By default, all page content is embedded in every HTML file. For small sites this is fine — JSON compresses well. For content-heavy sites (large documentation, university websites), the payload grows to several megabytes, duplicated across every prerendered page.

splitContent separates page content into individual files (_pages/*.json) that are fetched on demand as the user navigates. Only the current page's content is embedded in each HTML file — other pages load lazily.

ValueBehavior
autoSplit when total content exceeds 100KB (default)
trueAlways split
falseNever split — all content inline in every HTML file

With auto, a 5-page marketing site stays bundled (fast, no extra requests), while a 50-page docs site splits automatically.

Once a page's content is loaded, it stays cached for the rest of the session — returning to a visited page is instant with no additional fetch.

When view transitions are active, the content fetch is hidden behind the transition animation — the user never sees a loading state.


Site-Level Data Fetching

The site's own fetch, for data that belongs to the site rather than to one page:

query: config               # same as fetch: { query: config }

The site is the root page. Its fetch reaches what a page's fetch would if the site were the parent of the pages directly under pages/: the layout areas (header, footer, sidebars) and the sections of the top-level pages, the homepage included. A section there receives the data when its component declares the key — config, or a key of the query's schema. A page further down, such as /docs/setup, does not receive it; a section there that needs the data names the query itself.

query: takes a query name or a list of names; anything more — as, where, sort, limit, prerender — is fetch:. Write one or the other, and not data:, the shorthand's former name. A site-level fetch cannot carry current:, which belongs to a section of a parametric page. See Data Fetching for the full reference.


Queries

Named questions over the site's records. The map can live here, under queries:, or in queries.yml at the site root; when both declare a query, queries.yml wins, key by key.

queries:
  articles:
    schema: '@/article'
    sort: date desc
    where:
      featured: true

  team:
    schema: '@/person'
    sort: order asc

A page or section names a query — query: articles — and never the file a build generates from it. Every key a query can carry — schema, scope, where, sort, limit, excerpt, queryable, and url: for an external query — is in Queries.

A query declares no link. Every record it delivers carries $route, the URL of the parametric page that shows it — /blog/my-post for a pages/blog/[slug]/ page whose route query is articles — and a component links a card with item.$route rather than composing the URL itself. See Parametric Pages → Linking to a record.

Removed: route: on a query, which wrote a route field into every compiled record. The build stops on it.


Placeholders

Values you write once and reference from any page.

placeholders:
  product: Uniweb
  vendor:
    organization: Acme Studios
    email: billing@acme.example

Any page can then reference them by name:

Questions? Write to {vendor.email} and we'll get back to you.

Use them for anything repeated across pages that you would otherwise have to change in a dozen files — a company name, a support address, a product name, a current season.

How they resolve

A placeholder is an ordinary variable, so nested values use dot paths ({vendor.organization}) and a record's own field of the same name always wins. On a page showing an article, {title} is that article's title even if the site declares a title placeholder — the site says what is true of every page, the record what is true of one.

Values are carried as written: strings, numbers, booleans and lists all work.

Your foundation has to resolve them

Placeholder resolution is a foundation capability, not something every site gets. A foundation opts in by declaring a content handler:

// main.js
import { createLoomHandlers } from '@uniweb/loom'

export default {
  handlers: createLoomHandlers({ vars: (data) => data?.profile?.[0] }),
  data: { profile: {} },   // the data keys `vars` reads — see Content Handlers
}

vars supplies the data-driven variables — a CV's fields, say — and placeholders sit beneath them; see Content Handlers. Without a content handler, nothing resolves {…} and your pages render the literal text {vendor.email}. The build warns when a site declares placeholders: under a foundation that has no content handler, so you find out at build time rather than on the page.

Once a foundation is wired, placeholders are the simplest thing you can write — but the same expressions can filter, sort, count and format live data. See @uniweb/loom for the full language.


Custom Content Paths

By default, site content is read from standard directories relative to the site root: pages/, layout/, and records/. You can override these locations using the paths: group in site.yml:

paths:
  pages: ../shared-content/pages
  layout: ../shared-content/layout
  records: ../shared-content/records

Paths are resolved relative to the site root. Absolute paths are also supported.

A pages directory with its own folder.yml or page.yml keeps the order and the homepage it declares there, exactly as it would mounted below the root — see From the Pages Directory.

Per-Subfolder Mounting

You can mount individual page subfolders from external locations. This is useful when some pages live in a different repository (e.g., a docs submodule) while the rest are local:

paths:
  pages/docs: ../../../docs

This makes the external docs/ directory appear as the docs subfolder under pages/. The site's own pages/ directory provides the rest of the pages. Multiple subfolder mounts are supported:

paths:
  pages/docs: ../../../docs
  pages/blog: ../../../blog-content

Giving a mounted route a layout or a title

Add a local folder for the route holding a folder.yml — folder.yml rather than page.yml, because what is mounted is a folder of pages, and that filename is also what tells the build how to read the mounted tree:

site/
├── site.yml              # paths: { pages/docs: ../../../docs }
└── pages/
    └── docs/
        └── folder.yml    # layout, title, SEO for the branch
# pages/docs/folder.yml
title: Documentation
layout: DocsLayout        # cascades to every page under /docs

The two configs layer. The mounted directory's own folder.yml supplies what your stub leaves out — its ordering, its title — and anything the stub declares wins. So a docs repository that orders its own sections keeps that order, and your site only states what it wants to differ.

If the mounted directory is empty — an unfetched git submodule, most often — the build says so rather than quietly producing a route with no pages under it. In a production build it fails outright.

Use Cases

  • Separate content repo — Content in a git submodule, maintained by a different team
  • Shared content — Multiple sites reading from the same pages or records
  • Existing docs — Point pages at an existing folder of markdown files
  • Mixed sources — Some pages local, others from external repos via per-subfolder mounting

When paths.records is set, every file in that directory's schema folders is a record — for a build and for uniweb push and pull alike — instead of records/ at the site root. The folder's organization, folder.yml, moves with it.

Editing external content in dev

Every directory reached through paths: is watched, mounts included — editing a file in a mounted docs repository rebuilds and reloads exactly as editing a local page does. Nothing to configure. Files that arrive in bulk count too, so fetching a git submodule while the server is running brings its pages in without a restart.

Changing the paths themselves still needs a restart. Watchers are set up once, from the paths: in effect at startup, so pointing a mount somewhere new — or adding one — takes hold on the next uniweb dev.


Custom Head Injection

Place a head.html file in your site root to inject HTML into <head> on every page. The file contents are inserted verbatim — no processing, no YAML wrapping.

site/
├── site.yml
├── theme.yml
├── head.html      ← optional, injected into <head>
├── pages/
└── layout/

Common uses: analytics and tag managers, error monitoring (Sentry), third-party widgets, custom meta tags, font preconnects.

head.html applies when the framework builds your pages — uniweb export, or uniweb deploy --host=<adapter>. On Uniweb Cloud (uniweb publish) the pages are generated by the host from your synced content, so head.html is not applied there. The file is still kept and synced, so nothing is lost — but a script placed in it will not run on that host.

⭐ If what you are adding is a vendor's tracking script and their snippet is a single <script src="…">, put the URL in tracking.scripts instead. Same script, same vendor, same data — but the runtime loads it, so it works on every host rather than only the ones where the framework builds your pages. Reach for head.html when the vendor's snippet also runs inline configuration lines, which have nowhere to go in a URL field.

And separately, on what to measure with. A vendor's script sets its own cookies, which is what lets it report sessions and unique users; it counts page loads, so in-app navigation needs its own handling. services.tracking loads no script and stores nothing on the visitor's device, reports every route change, and sends to an endpoint you provide — so it counts page views and events but not users or sessions. Different numbers, not better ones; many sites run both.

For social/SEO meta (Open Graph image, title, description, canonical, robots), use the structured seo: block instead — the runtime renders those into every page's <head>. Reserve head.html for everything else.

Example: Google Analytics

<!-- site/head.html -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());
  gtag('config', 'G-XXXXXXXXXX');
</script>

Replace G-XXXXXXXXXX with your Measurement ID from Google Analytics.

How it works

  • The build reads head.html and injects it before all other head content (theme CSS, SEO tags, site data).
  • In dev mode, changes to head.html trigger a page reload automatically.
  • In production, the content is baked into every pre-rendered HTML file.
  • If the file doesn't exist, nothing is injected — there's no error.

Note: For Google Fonts, you don't need to add preconnect links manually. When your theme.yml includes font imports, the build injects <link rel="preconnect"> tags automatically. See Site Theming → Typography.


Complete Example

# site.yml

# Identity
name: Acme Corp
description: Building the future of widgets

# Structure
pages: [home, products, about, ..., contact]

# Languages
defaultLanguage: en
languages: [en, es]

# Layout — cascades to every page; folder.yml and page.yml override per field
layout:
  name: DocsLayout
  hide: [right]

# Services
services:
  search: true

# Build
build:
  prerender: true

# The site's fetch — layout areas and top-level pages
query: config

# Queries
queries:
  articles:
    schema: '@/article'
    sort: date desc

  products:
    schema: '@/product'
    sort: name asc

See Also