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 SiteThat'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 app (optional)
favicon: /favicon.svg # The tab icon, a file in public/ (optional)
touchIcon: /touch-icon.png # The home-screen icon, a 180×180 PNG in public/ (optional)
keywords: [components, react, cms] # Default meta keywords (pages can override)
seo: # Site-level social card + SEO defaults
origin: https://my-site.com # Where it is served from: canonical URLs, sitemap.xml, 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
favicon: /favicon.svg
touchIcon: /touch-icon.png| Field | Type | Description |
|---|---|---|
name | string | Site name (used in <title>, metadata), and the title on the site's card in the Uniweb app |
description | string | Default meta description, and the line under the title on the site's card |
tags | list | What 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. |
preview | string | The image on the site's card in the Uniweb app — 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. |
favicon | string | The tab icon every page links: a site-root path to a file in public/ (/favicon.svg, /favicon.ico), or a URL. uniweb push uploads a file of the site's own. Left unset, a static build links public/favicon.svg, .ico or .png when there is one, but a site published with uniweb publish links only the one named here — so name it. |
touchIcon | string | The icon iOS puts on a home screen and Safari shows on its start page: a site-root path to a PNG in public/ (/touch-icon.png), or a URL. Neither uses an SVG favicon. Make it 180×180 with no transparency — iOS draws a transparent pixel black. uniweb push uploads a file of the site's own. Left unset, iOS asks a static host for /apple-touch-icon.png, but a site published with uniweb publish has only the one named here. Name one when favicon: is an SVG: Google Search does not list SVG among the favicon formats it supports, and it reads this link too. |
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:
| Shape | Example | Meaning |
|---|---|---|
| workspace package name | src | a foundation in this repo (follow the file: dep to its folder) |
| versioned catalog ref | @acme/marketing@1.2.3 | published; loaded by the host |
| full URL | https://cdn.example.com/entry.js | loaded 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 URLA site-relative URL (
/effects/entry.js) only works where the site serves its own files —uniweb exportanduniweb deploy --host=<adapter>. A site published to Uniweb hosting ships no JS, so nothing serves that path;uniweb publishrejects it and points you at the catalog-ref form. Register the extension (uniweb registerin 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 namelayout: # or the expanded form
name: DocsLayout
hide: [right] # areas of the layout to leave out
params: # layout-specific settings
sidebarWidth: widehide 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:
| where | applies to |
|---|---|
site.yml | every page |
folder.yml | that folder and everything under it |
page.yml | that 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:
origin: https://acme.com # Where the site is served from — see "Where the site lives"
image: /og-default.png # Default Open Graph / social-card image
ogTitle: Acme — Build with Components
ogDescription: The component content platform.
twitterHandle: '@acme' # The site's account, as twitter:site
noindex: false # Set true to keep the whole site out of search
robots: # robots.txt contents
disallow: [/private]| Field | Type | Description |
|---|---|---|
keywords | string[] | Default meta keywords; a page's own keywords override |
seo.origin | string | The origin the site is served from — a scheme and a host, https://acme.com. A site served under a path states that path as its base: (https://user.github.io with base: /repo/); an origin written with a path is refused. Not needed where the host states it — see below |
seo.robots | object | robots.txt contents — disallow, allow, crawlDelay, contentSignals, additionalSitemaps |
seo.image | string | Default Open Graph / social-sharing image (the site's social card). Link previews draw it at 1.91:1, so a 1200×630 JPEG or PNG fills one whole; most draw no SVG. uniweb doctor checks it is there, and its format and size, and uniweb snapshot --share makes one from the site |
seo.ogTitle | string | The home page's social title, and the default for a page with no title. A page's own seo.ogTitle wins, and elsewhere so does its title |
seo.ogDescription | string | Default social description; a page's description or seo.ogDescription wins |
seo.twitterHandle | string | The site's X (Twitter) account, written as twitter:site |
seo.noindex | boolean | Keep the entire site out of search engines (cascades to every page) |
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. The site's description is its home page's: other pages carry their own or none, since one description on every page would describe none of them.
Every page's <head> carries its own tags — description, canonical, Open Graph, Twitter Card, robots, and hreflang alternates on a site in several languages. They are in the prerendered HTML, so crawlers and social unfurlers see them without running JavaScript, and the browser keeps them as a visitor moves between pages. These tags are the framework's: a tag of the same name in head.html is replaced. For arbitrary tags beyond these, use Custom Head Injection.
Where the site lives
A page's canonical URL, its og:url, its social image, its hreflang alternates, its sitemap entry and the links in llms.txt are absolute URLs, and every one is made on the site's address — its origin and its base path, https://acme.github.io/docs/. Who states it depends on what serves the site:
| Served by | The address comes from |
|---|---|
GitHub Pages, with the workflow uniweb add ci --host=github-pages writes | GitHub: the workflow reads where Pages serves the site — <owner>.github.io/<repo>/, or a custom domain — and hands it to the build |
| Netlify, Vercel | the production URL the host states during the build (on Vercel, when the project exposes its system environment variables) |
| a host that renders the pages itself | that host |
any other static host (uniweb export, S3, …) | seo.origin, with the site's base |
A page's URL is the one its host answers with. A built page is a <route>/index.html file, which a static host serves at <route>/, so its canonical and its sitemap entry end in /. With no address, pages state no canonical or og:url, no sitemap.xml is written, and the social image and llms.txt links are root-relative — uniweb doctor says when that is the case.
sitemap.xml and robots.txt are static-host artifacts: uniweb export and uniweb deploy --host <adapter> write them into dist/, from the pages the build wrote — each record page of a parametric page included. A crawler reads robots.txt only at a host's root, so a site served under a base path, like a GitHub project site, gets none; submit its sitemap to search engines directly. A site's own public/robots.txt or public/sitemap.xml is served as it is. A site published to a backend does not get them from the build — what that host serves is the host's.
Page Ordering
Control the order of top-level pages and designate your homepage.
Inclusive Order (Recommended)
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), less any number it starts with:2-about/is/about, ordered by its number and namedabouthere ...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: homeOnly 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 file | Mode | Folder is... | .md files are... |
|---|---|---|---|
page.yml | page mode | A page | Sections of that page (default) |
folder.yml | folder mode | A container | Individual 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/themesEach file is a page and its one section, so its frontmatter holds both: the section's configuration (type:, background:, its params) and the page's settings — the ones a page.yml gives a page:
---
type: Article
description: Set up your academy before your first student signs up.
label: Setup
seo:
ogTitle: Setting up your academy
---
# Setting upThe page's keys are title, description, label, keywords, slug, hideIn, layout, seo, knowledge and trackSections, each as in page.yml. A page's title is its title:, else the file's H1 heading, else its file name. A section param named like one of the page's keys goes under props: (props: { label: Step by step }), and the build warns where the section type declares one. id: and hidden: mean what they mean in any section file: the section's id, and a draft — which here is the whole page.
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:
folder.ymlin a directory → folder mode for that folder and all descendantspage.ymlin a directory → page mode for that folder and all descendants, where its parent is in page mode- 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. Inside it, each page is one .md file holding one section, so the build stops on a subfolder with a page.yml of its own, a parametric one included: a page of several sections goes outside the folder. A folder that holds no .md page of its own can take a page.yml in place of its folder.yml, which puts it and its subfolders in page mode.
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:
hide: [right]| Field | Type | Description |
|---|---|---|
title | string | Container title (for navigation, breadcrumbs) |
description | string | Meta description |
keywords | string[] | Meta keywords, over the site's |
pages | array | Child page ordering with ... wildcard support |
index | string | In 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 |
label | string | Short navigation label |
hidden | boolean | Hide from navigation |
layout | string or object | A layout name, or { name, hide, params }, as in page.yml — the pages below inherit it |
seo | object | SEO overrides |
id | string | Stable ID for page: links |
Ordering in Folder Mode
Child pages are ordered by:
pages:array infolder.ymlwith...wildcard support (same semantics as site-level)- Numeric file prefix (
1-intro.mdbefore2-setup.md) - Alphabetical by filename
# folder.yml
title: Documentation
pages: [getting-started, configuration, ...]Languages
Enable multi-language support.
defaultLanguage: en
languages: [en, es, fr]Options
| Option | Type | Description |
|---|---|---|
defaultLanguage | string | Primary language (no URL prefix). Defaults to the first entry in languages, or en |
languages | array | Supported languages — the working set you author in |
publishLanguages | array | Which 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-onlyRules:
- 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 orhreflangreferences. To visitors, a draft language is indistinguishable from an undeclared one. uniweb devignores the list — drafts render normally so you can work on them, the same wayhiddenpages stay previewable in dev.- The default language must be published. A
publishLanguagesthat excludes the effective default (or an empty list) fails the build with a clear error. - Codes not in
languagesare kept but inert. If you remove a language fromlanguageswhile it's listed inpublishLanguages, the entry stays (with a build warning) — re-declaring the language later restores it as published without touching the list again. uniweb i18ncommands 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 coverageThis generates locales/manifest.json with all translatable content, and you provide translations in locales/{locale}.json keyed by content hash.
Generated Routes
| Page | Default Locale | Other 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
exclude: [/docs] # branches the site doesn't translateexclude names branches the site leaves in their own language, such as a mounted repo's docs: uniweb i18n extract takes no strings from them, so init and status leave them alone. It takes routes as agents.exclude does, a whole branch per route. Their pages show the source in every language, even where the site translates the same words on its own pages. Only a translation written for one place there applies: an override naming it, or a free-form translation of its section.
Site Services
Search, form submissions, tracking, an assistant, accounts, live records and Google's tags 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
google: { id: G-XXXXXXXXXX } # ask your host to load Google's tagEach entry is one of:
| value | means |
|---|---|
true | on — your host's, where it offers the service; on a static site, the built-in provider if there is one (search has an index) |
false | off |
an address — a string, or endpoint: in a map | on, answered by a provider you bring — on a host, your host is asked to leave its own off |
| a map | on, 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.
enabledis the switch, never an option. recordsdelivers 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.googlehas your host load Google's tag for the IDs you name. A static build ignores the entry. See Google Analytics and Tag Manager.
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:andapi:keys are retired. The build stops on them and says where each one moves. So doesapiunderservices:, which isbackendnow, and$devApi:, which is$devBackend:.
Search
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 # onservices:
search: false # offFull 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. A site in several languages gets them in each: /fr/llms.txt, with each page's markdown at its address in that language (/fr/a-propos.md for /about).
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 wordexclude 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.
A page's markdown is what its author wrote. When the build prerenders it (build.prerender: true), the page's markdown also carries, after each section, the records that section shows, as JSON under the name they are fetched by (the query's, unless the fetch gives another with as:), so a price that a pricing card draws from records can be read there too. A data block the section's own markdown holds appears once, as written. Without prerendering, there is no render to read the records from, and the build says so where it reports the page markdown it wrote.
On Uniweb hosting, a page's own address also answers with its markdown when a request asks for text/markdown. A static host serves the files alone.
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.orgIt 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 declared | no check at all — nothing happens unless you ask for it |
| your own site's origin | always accepted, without listing it |
a request with no Origin | accepted — see below |
| matching | by origin: https://app.example.com/ is https://app.example.com, and a path is ignored; no wildcards. An entry that is not an http(s) URL matches nothing |
⛔ This is not a spend limit and cannot be made into one.
Originis 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: trueIts 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.
How a backend started for the site begins is blueprint.yml, beside site.yml: who may create an account, its teams, who adds entries of each type, and what visitors may read. Without one, every type starts closed, and only the account that runs the site adds entries.
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 }).fetchIt begins as a backend started for your site does, with every type closed. Hand it the site's blueprint.yml to open what your members create: see Blueprint.
$devBackendis 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 nobackendservice 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 conferenceForm Submissions
services.submit — where this site's forms send their submissions.
services:
submit: true # your host's form handlingservices:
submit: https://forms.example.com/intake # a form service you bringA form's destination comes from the first of these that applies:
- Your host's — where your host handles submissions, that is where they go.
submit: trueasks for it on a site you publish. - 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.
- Neither — there is nowhere to send a visitor's answers, so a component draws no form, or shows contact details the site already carries.
In uniweb dev the dev server is your host, so submit: true is answered there: your forms show and can be sent while you work. Each submission is printed in the terminal and kept, attachments included, in the site's .uniweb/dev-submissions/, which a scaffolded project never commits.
Full Options
services:
submit: /forms # an address on your own siteservices:
submit:
endpoint: /forms # the same, as a mapA 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/eventsOff unless you set it. With no destination configured, nothing is collected and nothing is sent — components that report events simply do nothing.
This section lists every option. What to count and why, and how to set up a campaign, are in the guides: Measuring Visits, Conversions and Engagements and Advertising.
Full Options
services:
tracking: /collect # an address — a path on your own siteservices:
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.jsA 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
The runtime sends these by itself, with no component involvement. The first three are the default for a collector of your own (standard).
page_view goes out on every route change, including the first:
pathis site-relative — a site deployed at/docs/reports/about.referreris 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_campaignride 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 in the default emit for your own endpoint, and trackSections in a page's page.yml overrides the site in either direction, since its cost grows with the length of the page.
time_on_page goes out once for each page a visitor opens, when they first leave it: for another page, another tab or app, or by closing it. It carries durationMs, the time until then. A phone can discard a page in the background without warning, so the report doesn't wait for the page to close, and a reader who switches away and comes back is counted up to the switch.
section_click goes out for a click inside a section, with the section it landed in. Neither of these two is in standard.
Everything else is sent by components. Two ship with the framework's component kit, so they are worth recognising if you build a dashboard:
| event | sent by | fields |
|---|---|---|
page_view | the runtime, automatically | path, first_of_load?, continues?, referrer?, utm_*? |
outbound_click | the runtime, automatically | hostname |
section_view | the runtime, unless emit or a page's trackSections says no | section, section_id |
time_on_page | the runtime, when selected — not in standard | durationMs |
section_click | the runtime, when selected — not in standard | section, section_id |
video_milestone | <Media> playing a video | milestone, src |
read_depth | useReadingDepth() in a long section | depth — 25, 50, 75, 100 |
conversion | see below | name, landing? |
engagement | see below | name |
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.
Conversions and engagements
conversion says a visitor did something this site counts. engagement says they did something worth knowing that is not a result in itself. Both carry name, and nothing of their own beyond it: a phone number, an email address, a link and a form's fields all stay on the page. A conversion can also carry how the visitor arrived, when you ask for it (landing, below).
Some are reported with no code in your foundation:
| event | name | when |
|---|---|---|
conversion | phone, email | a visitor taps a tel: or mailto: link |
conversion | form, or form_<formId> | a form sent through useFormSubmit is accepted by a provider of your own. Your host's submit address counts its own, so nothing is sent for those |
conversion | sign_up | a sign-up through @uniweb/api is accepted. It says someone asked, since the answer is the same for an address already taken |
engagement | sign_in | a sign-in through @uniweb/api succeeds, including on the way back from a provider |
The first row needs only the runtime. The others are code your foundation bundles, so they start when the foundation is rebuilt, and nothing in it has to change.
A link or a page counts when you say so. Nothing can tell by itself that one link is your sign-up and another your sign-in:
services:
tracking:
conversions:
- { name: signup, link: https://app.example.com/signup }
- { name: call_office, link: 'tel:+15551234' }
- { name: booked, page: /thanks }A link is a whole address, compared without its query or fragment. A page is a path of this site, in any of its languages. A name you give a phone number replaces phone for that number.
The list is public, and on a site you publish it is also a request. It is in every page's data, so anyone can read each link. A query isn't compared, so leave any token out of it. And like any tracking entry with no endpoint, it asks your host for its collector.
A component sends its own, for what only it can see. A section can let its author choose whether an act counts as a conversion, with kit's trackAction (What Counts as a Conversion):
trackAction(block, 'command_copied', params.copyConverts) // a conversion, or an engagement when the author says no
block.track('conversion', { name: 'quote_requested' }) // always a conversionKeep names short, since a collector may cut a long one, and use one name for each thing a report should count. A tap, a listed link, a listed page and an act reported with trackAction count once per page load, so a double tap is one; a reload is a new page load.
A conversion can carry how the visitor arrived. Name the query keys of the address a visitor arrives at, and each conversion carries their values as landing, for the keys that address had:
services:
tracking:
landing: [utm_source, utm_medium, utm_campaign, utm_content, utm_term]{ "event": "conversion", "visit": "b3f1…", "path": "/pricing", "name": "signup",
"landing": { "utm_source": "newsletter", "utm_campaign": "spring" } }The values are read once, when the page first loads, and kept in memory for that page load, so a conversion on a later page still carries them. Only the keys you name are read, and only a conversion carries them. A query can hold a search term or a token, so name only what your collector needs. Where your host supplies the collector it may name the keys itself; a list of your own replaces its.
What emit does here. The events with no code above are part of all, and standard leaves them out. Where your host supplies the collector they are sent if it declares them. A conversions list asks for itself, so it is sent under any emit unless your host declines the event.
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: standardemit 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: minimalThe 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.
| value | sends |
|---|---|
minimal | page_view |
standard | page_view, outbound_click, section_view — the default when the collector is your own |
all | everything the framework emits, including events added in later releases |
| a list | exactly those event names, e.g. [page_view, section_view] |
all also sends what standard leaves out, such as section_click and conversion. A new automatic event joins all and never standard, so a framework release never grows what a standard 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 site | saying 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 collector | whatever 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 page_view, outbound_click and section_view, 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 # millisecondsRaising 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, and its install is a single script, name that 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.jsThe 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.
On a site you publish, an entry with no endpoint also asks your host for its collector. To load only the script, add enabled: false beside scripts.
What the framework guarantees about the load:
- Once per page load, as an
asyncscript in<head>. - Only after consent, when
consent: requiredis 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 or for an attribute on the tag. 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, or read adata-…attribute off the tag, and those have nowhere to go here — for that, usehead.html, noting which hosts apply it.Google Analytics, Google Tag Manager and Meta's pixel are in the second group. From a URL alone,
gtag.jssends nothing andgtm.jsnever gets the page-load event its inline line pushes. Google's two have an entry of their own, below.
Google Analytics and Tag Manager
Neither can load from a scripts entry, so what you do depends on who builds your pages.
On Uniweb Cloud (uniweb publish, or publishing from the Uniweb app), name the ID and your host loads Google's tag. In the app, add it on the Google Analytics and Tag Manager card, in your site's Settings under Features. In your files, it is this entry in site.yml:
services:
google:
id: G-XXXXXXXXXX # a Measurement ID, or GTM-XXXXXXX for a Tag Manager containerFor both, id takes a list: id: [G-XXXXXXXXXX, GTM-XXXXXXX]. If your container already sends to that Measurement ID, list only the container, so pages aren't counted twice.
The card and the entry are one setting: uniweb pull writes what the card holds into site.yml, and uniweb push shows what the file says on the card.
This is free and needs no code and no head.html, so it works on any foundation, and it takes effect at your next publish. The card's switch, or enabled: false beside id, turns it off and keeps the ID.
- It is separate from
services.tracking. It asks your host for nothing else, and nothing youtrack()reaches Google. - Leave
services.tracking.scriptsunset beside it. A site's own list replaces what your host states, so Google's tag would not load.uniweb push,uniweb publishanduniweb statussay so when a file has both. - It loads the way a
scriptsentry does: once per page load, and never in an editing preview or during prerendering.uniweb devhas no host, so it does not load there either. - Google's tag sets Google's cookies on your visitors' devices. Whether that takes their consent where you operate is your decision. To hold the tag until a visitor answers, write
tracking: { enabled: false, consent: required }underservices:insite.yml, leaving outenabled: falseif you also want your host's collector, and draw the question with a component, as in Consent. With the gate on and nothing asking, the tag never loads. - A browser or extension that blocks trackers blocks Google's tag too. Brave is one. A visit from it never reaches Google, so Google's numbers can run below your visits, and checking an install from it looks like the install failed.
Anywhere else (uniweb export, uniweb deploy --host=<adapter>), no host is there to load it and the entry does nothing. Paste Google's snippet into head.html instead.
Every page a visitor moves to counts. Google Analytics picks it up by itself, directly or inside a Tag Manager container, through the browser-history setting under Enhanced measurement in your data stream's settings. A new stream has it on. For a tag that doesn't follow page changes on its own, use Tag Manager's History Change trigger, as on any site that changes pages without reloading.
To check a Tag Manager install, use Tag Assistant, which connects to a published page and shows the tags that fired. Start there if nothing seems to fire: Google doesn't report a mistyped ID as an error.
Ad platforms: X and Meta
On Uniweb Cloud, your host can send the conversions your site reports to X or Meta from its servers, so the platform learns which of its ad clicks convert. Nothing goes in your pages, and no platform tag runs. Name the platform's pixel in site.yml, and map each conversion to the event you made for it on the platform:
services:
x:
pixel: 'o1abc' # your X pixel ID
events:
signup: 'tw-o1abc-o2def' # a conversion your site reports → its event in X
meta:
pixel: '1234567890123456' # your Meta dataset (pixel) ID
events:
signup: CompleteRegistrationThen set each platform's token. A token never goes in a file:
uniweb site secret x token # asks for it, without showing what you type
uniweb site secret meta tokenX gives the token in Ads Manager's Events Manager, at Install Pixel → Manual → Conversion API → Generate access token: a token made elsewhere in the pixel setup is refused, 401. Meta gives it in Events Manager, under your dataset's Settings → Conversions API → Generate access token. The rows and the tokens take effect at your next publish.
- What is sent: a conversion from a visitor who arrived through one of the platform's ads, as their landing address shows (
twclidfor X,fbclidfor Meta). It carries the click, the visitor's IP address and browser, the page and the time.- A conversion name with no entry in
eventsis not sent. - An engagement is never sent.
- A conversion name with no entry in
- A landing address with
utm_campaign=testmarks a test click, and its conversions are never sent. So you can check a setup without the platform counting it. - If
site.ymlnames its owntracking.landing, keeptwclidandfbclidin it. Your list replaces your host's, and a conversion that carries its click names its ad itself. - Quote the pixel ID. A long one written bare is read as a number and can lose digits, so the build stops and asks you to quote it.
enabled: falsebesidepixelturns a platform off and keeps its settings.- A site copied from a template doesn't get the template's rows.
- Say it in your privacy policy. Each conversion sent gives the platform the visitor's IP address, browser and ad click.
Anywhere else (uniweb export, uniweb deploy --host=<adapter>), no host is there to send them, and a build leaves these rows out.
What is and isn't stored
Nothing is stored on the visitor's device. No cookie, no local storage — the
visitkey 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
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: autoOptions
| Option | Default | Description |
|---|---|---|
prerender | true | Generate static HTML for all pages (SSG) |
splitContent | auto | Split 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.htmlwith 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.
| Value | Behavior |
|---|---|
auto | Split when total content exceeds 100KB (default) |
true | Always split |
false | Never 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.
Navigating to a page whose content hasn't loaded yet keeps the current page on screen until it arrives, along with the data its sections read, so the user never sees a blank page. With view transitions, the swap is animated. A page starts loading as soon as a visitor rests the pointer on a link to it, focuses the link or presses it, so it is often already there when the click lands.
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 ascA 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 record's link — $route
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 aroutefield 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.exampleAny 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 page writes a placeholder under a foundation that has no content handler, so you find out at build time rather than on the page.
A foundation's components can read a site's placeholders too, as values set once for the whole site: block.website.config.placeholders holds the block as written. A shop's foundation might format every price in placeholders.currency, so an author sets the currency once rather than on each section that shows a price. Reading them this way needs no content handler. A foundation declares the placeholders its components read (Foundation Configuration → Placeholders a Foundation Reads), so an editor can show them as typed fields.
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/recordsPaths are resolved relative to the site root. Absolute paths are also supported.
paths: belongs to your project, not to the site's content: uniweb push does not send it, and uniweb pull never changes where it points. A copy made with uniweb clone keeps its pages under its own pages/ and layout/, mounted pages included.
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: ../../../docsThis makes the external docs/ directory appear as the docs subfolder under pages/. A mount is one level under pages/: pages/docs/guides is refused. The site's own pages/ directory provides the rest of the pages. Multiple subfolder mounts are supported:
paths:
pages/docs: ../../../docs
pages/blog: ../../../blog-contentGiving a mounted route a layout or a title
Add a local folder for the route holding a folder.yml. It says how this site presents the branch, and what it leaves unset comes from the mounted directory's own folder.yml:
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 /docsThe 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.
Edits made in the app
When an author changes a mounted page in the Uniweb app, uniweb pull writes the change into the mounted folder's own files, the way it writes the site's own pages: a changed paragraph in the page's file, a new page in its folder, the branch's order in the mounted folder's folder.yml. A setting of the mounted route itself goes to the file that states it — your local folder.yml where both do, since it wins — and a new one by what it is: what the content is, such as its title, description or order, in the mounted folder; how this site presents it, such as its layout or SEO, in your local one.
A pull writes into a mounted folder wherever it can merge the change with your work there: a folder your site's own repository holds, beside the site's folder or inside it; a folder in a git submodule of that repository, below; or, for a site in no repository, a folder inside the site's. Anywhere else — a clone beside the site that your repository does not record, a folder in no repository — the change is held: the app keeps the author's version, your files keep theirs, and a push leaves that page alone while its file is unchanged, and stops if you changed the file too. That takes a copy that pulled before the author's edit. A fresh clone cannot tell that edit from a change in the folder, so its push sends the folder's version. The pull and uniweb status name each held page. To have a pull write such edits, make the folder a submodule of your site's repository; to put the folder's version back over the author's, uniweb push --force.
A link one of its files writes as a file, [the guide](./guide/start.md), which works on GitHub and in an editor, stays as written: a pull changes a link only where the app changed it.
The mounted route itself, /docs, is named by its key in paths: and its local folder. Where an author renames it in the app, a pull renames both, pages/docs to pages/help; where an author removes it, a pull removes the entry and the local folder. Either way the mounted folder is left as it is.
A mount that is its own repository
A mounted folder in a repository of its own — a git submodule, or a clone beside the site — is read as it is on disk, and nothing moves it for you: a git pull of the site leaves a submodule's checkout where it was. The sync commands keep it current:
uniweb statussays whether it is at the latest of the branch it follows, whether a submodule's checkout is the commit your repository records, and whether it holds uncommitted changes;uniweb refreshtakes its latest, and commits a submodule's new pin on its own;uniweb pushanduniweb publishstop before sending one that is not current. In a terminal they offer to take its latest first, and--mounts-as-issends it as it is.
The branch a submodule follows is the one .gitmodules names for it, else its remote's default branch:
git config -f .gitmodules submodule.docs.branch stable # follow stable insteadA submodule takes the app's edits on a pull as a commit in the submodule, made with your own git identity and naming the files it changed. The pull merges that commit into the submodule's checkout and records it in your repository, and uniweb refresh takes the submodule's latest by merging, where its checkout holds commits of its own. Push the submodule before your repository — the pull says how — or a clone of the site cannot check out the commit it records. A clone beside the site, which your repository does not record, holds the app's edits instead.
Mounting records from another site
A site can hold another site's records of a type as its own: sites of one project that show the same products, the same team or the same prices. Mount the other site's folder for that type under paths:, at the path its records have:
# press/site/site.yml
paths:
records/acme/member: ../../hub/site/records/acme/memberuniweb build,uniweb devanduniweb validateread the mounted records as this site's records of@acme/member, anduniweb devrebuilds when one of their files changes.- The records stay the other site's, so they are edited there. The site's own
records/acme/member/holds no files while it is mounted: any it holds are not read, and the build says so. - With a backend,
uniweb pushadds the other site's records to this site's by reference, not as copies, so both sites show the one record. Push the site that owns them first: a push stops on a mounted record its owner hasn't pushed. - The push also needs this site's foundation to declare the type, as a section type's data key (
data: { team: '@acme/member' }) or a schema of its own: the backend refuses a query over a type none of the site's foundations renders. A query inqueries.ymlalone is not enough. - An author's edit in the app is an edit to the one record, whichever site it was made through.
uniweb pullleaves the mounted files as they are, and the owning site's pull writes the edit into them. - A key names one type:
records/<name>for the foundation's own@/<name>, orrecords/<org>/<name>. Link and file records belong to a site's own folder and are not mounted. - A record whose file the owning site deletes stays on this site until this site pushes again. The owning site's push keeps it while this site's folder places it, and a later push of the owning site deletes it.
- The owning site can be in another repository, checked out beside this one. This site's foundation then reads the type's definition another way, a
schemas.config.jsroute to the folder that holds it or a schemas package (Sharing schemas across projects). A push finds each record's id in the owning site'ssync.json, which that site's push commits, so a fresh clone of both repositories works the same. paths:belongs to the project, so a copy made withuniweb clonehas no mount: it holds the records as files of its own, the same records. To mount them again, delete those files and mount the owning site's directory at the folder they were in — for a type the clone's foundation names as its own,records/<name>.- Pointing two sites'
paths: { records: … }at one folder is not the same thing: with a backend, each site's push sends that folder's records as its own, two of each.
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 another site's records
- Existing docs — Point
pagesat 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.htmlapplies when the framework builds your pages —uniweb export, oruniweb deploy --host=<adapter>. On Uniweb Cloud (uniweb publish) the pages are generated by the host from your synced content, sohead.htmlis 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 intracking.scriptsinstead. 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 forhead.htmlwhen the vendor's snippet also runs inline configuration lines or setsdata-…attributes on the tag, 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.trackingloads 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, hreflang), use the structured
seo:block instead — the runtime renders those into every page's<head>, and replaces ahead.htmltag of the same name. Reservehead.htmlfor 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. On Uniweb Cloud this file is not applied, as above: use services.google there.
How it works
- The build reads
head.htmland injects it before all other head content (theme CSS, SEO tags, site data). - In dev mode, changes to
head.htmltrigger 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 ascSee Also
- Page Configuration — page.yml reference
- Queries — Everything a query can say
- Records —
records/, itsfolder.yml, and compiled records - Data Fetching — Naming a query from a page or section
- Site Search — Full-text search setup
- Internationalization — Multi-language support