Deployment Reference
Per-host recipes for deploying Uniweb sites — Vercel, Netlify, Cloudflare Pages, GitHub Pages, S3 + CloudFront, and self-hosted servers.
For the conceptual model — path 1 (you manage the content) vs path 2 (foundation as a product), free CI vs Uniweb hosting, the two-artifact framing — see Deploying. This page is the per-host deep-dive.
Build for Production
pnpm build # equivalent to `uniweb build` from the site directoryWith pre-rendering enabled (recommended), this produces a directory-per-route layout:
site/dist/
├── index.html # home page
├── 404.html # generated automatically
├── about/
│ └── index.html # /about
├── docs/
│ ├── index.html # /docs (the docs landing)
│ └── getting-started/
│ └── index.html # /docs/getting-started
├── fr/ # non-default locales nest under their code
│ ├── index.html
│ └── about/
│ └── index.html
├── assets/
│ ├── index-[hash].js
│ └── index-[hash].css
├── data/ # one JSON file per query
└── search-index.jsonThe dist/ folder is a complete static site ready to deploy. The directory-per-route pattern matters for hosts that don't auto-resolve directory indexes — see AWS S3 + CloudFront below.
Pre-rendering (SSG)
For static HTML at build time, enable pre-rendering in site.yml:
build:
prerender: trueBenefits:
- Fast initial page loads
- SEO-friendly (content in HTML)
- Works without JavaScript
- Cacheable at CDN edge
Without pre-rendering:
- Single
index.htmlwith client-side rendering - JavaScript loads and renders content
- Smaller initial bundle
Most sites should enable pre-rendering.
Deploy commands
| Command | What it does |
|---|---|
uniweb publish | Publishes to Uniweb hosting — brings the foundation along, syncs content, goes live (edge SSR, locale-aware routing, foundation propagation). Requires uniweb login. See Publishing to Uniweb hosting. |
uniweb deploy | Ships a site to a host. With nothing configured it asks where, listing only destinations it can act on, and records the answer in deploy.yml. |
uniweb deploy --host=<adapter> | Builds and uploads to a third-party static host via a built-in adapter. Adapters: github-pages, cloudflare-pages, netlify, vercel, s3-cloudfront. Each drives that host's own CLI and handles its quirks (URI rewrites, redirect helpers, cache headers, invalidation). generic-static is an export artifact shape, not a deploy target. |
uniweb add ci --host=<adapter> | Commits a GitHub Actions workflow so every push deploys, plus a per-PR preview workflow where the host supports one. |
uniweb deploy --target=<name> | Picks a target by name from deploy.yml (a sibling of site.yml). |
uniweb export | Builds dist/ for a static host but doesn't upload. Use with hosts that aren't covered by a built-in adapter, or when you let the host (Vercel, Cloudflare Pages, Netlify, etc.) run the build itself via Git integration. |
pnpm build | Equivalent to uniweb build from the site directory. Produces dist/ only — no upload, no host-specific helpers. Useful for inspecting build output during development. |
Configure the destination in deploy.yml (a sibling of site.yml):
# site/deploy.yml — safe to commit
default: production
targets:
production:
host: cloudflare-pages # a third-party adapter (s3-cloudfront, github-pages,
# vercel, netlify, generic-static), or 'uniweb' to
# delegate to `uniweb publish` (Uniweb hosting).
# adapter-specific fields below — see the per-host sections.
# For host: uniweb, `backend:` records which Uniweb backend this target's
# publishes went to — `uniweb publish` writes it. It routes nothing: Uniweb
# hosting goes to the backend you are logged in to (`uniweb login
# --server <url>`), and each publish is recorded under the target naming
# that backend (one is added when none does).
saveDeploys: true # false to stop recording deploys
# Auto-managed by uniweb deploy on success — do not edit by hand.
deploys:
production:
at: 2026-05-05T18:22:11Z
url: https://example.com
foundation: { shape: linked, ref: '@uniweb/marketing@0.4.2' }
runtime: 0.8.9The --host=<adapter> CLI flag overrides the resolved target's host for one-off deploys (no save). --target=<name> selects a target other than default:. A bare --host (no value) forces the picker even when deploy.yml has a target. With no destination configured at all, uniweb deploy asks rather than assuming one; non-interactively it exits with the list of options. To go live on Uniweb hosting, uniweb publish is the direct verb (the picker's Uniweb Cloud entry delegates to it).
Three deploy lifecycles
Static hosts come in three flavors. All are first-class, and most hosts support more than one — pick by who you want doing the building:
- CLI-push. You run
uniweb deploy --host=<adapter>locally; the CLI builds, then drives that host's own tool to upload (wrangler,netlify,vercel,aws, orgitfor GitHub Pages). State of record:deploy.yml'sdeploysblock. Auth: your machine. Every adapter exceptgeneric-staticsupports this. - Scaffolded CI.
uniweb add ci --host=<adapter>commits a GitHub Actions workflow that builds and deploys on each push, plus a per-PR preview workflow where the host has a preview concept. State of record: your repo. Auth: repository secrets. Available forgithub-pages,cloudflare-pages,netlify,vercel. - Git-driven (dashboard). You connect the repo through the host's UI; the host runs
npx uniweb builditself on each push. State of record: the host's dashboard. Auth: the host's GitHub integration. No workflow file, no secrets. Available for Vercel, Cloudflare Pages, and Netlify.
For dashboard-driven hosts, deploy.yml is still useful — it declares which adapter the build uses, so the right _redirects / .nojekyll / etc. land in dist/. The build also auto-detects the CI host (VERCEL=1, CF_PAGES=1, NETLIFY=true) and picks the matching adapter without needing --host. The deploys: block stays empty for dashboard-driven targets — the host's dashboard is the truth.
If you don't know which you want, run uniweb deploy with nothing configured; it lists the destinations and asks.
Vercel
Lifecycle: any of the three. Connecting the repo in Vercel's dashboard is the zero-config route (Vercel builds and gives you native preview deployments). uniweb deploy --host=vercel uploads a locally-built dist/ via the vercel CLI. uniweb add ci --host=vercel commits workflows that do it from Actions instead — useful when you want the build pinned to your own toolchain versions. Don't run the dashboard integration and the scaffolded workflows at the same time; pick one and delete the other.
Recommended setup
# site/deploy.yml
default: production
targets:
production:
host: vercelVercel handles directory-index resolution, asset caching, and SPA fallback natively — the framework has nothing to drop into dist/. The adapter exists so the artifact and the deploy manifest record what the user picked, and so the build's CI-context detection picks vercel automatically when VERCEL=1.
Via Git Integration
- Push your repo to GitHub/GitLab/Bitbucket
- Import in vercel.com/new
- Set root directory to
site - Deploy
Build settings (auto-detected):
- Build Command:
pnpm buildornpx uniweb build - Output Directory:
dist - Install Command:
pnpm install
Via Vercel CLI
cd site
npx vercelFollow the prompts. Vercel auto-detects the Vite configuration.
vercel.json (optional)
Most Vercel projects don't need one. Add it only when you need custom rewrites or headers:
{
"buildCommand": "cd .. && pnpm build",
"outputDirectory": "dist"
}The vercel adapter does not emit a vercel.json automatically. Hand-authored vercel.json files are left alone.
Netlify
Lifecycle: Git-driven. Same shape as Vercel — Netlify connects to your repo, runs npx uniweb build on each push, serves dist/.
# site/deploy.yml
default: production
targets:
production:
host: netlifynetlify shares Cloudflare Pages' _redirects output — both consume the same format — but is its own adapter, because the two deploy with different CLIs and different credentials. The build auto-detects NETLIFY=true and picks this adapter without needing --host in CI.
Add siteId: to the target (Site settings → General → Site ID) if you want to deploy from the CLI; it's not needed for the Git-driven path.
Via the CLI
cd site
uniweb deploy --host=netlify # builds, then uploads via the netlify CLISet NETLIFY_AUTH_TOKEN, or authenticate once with netlify login. Equivalent by hand:
npx netlify deploy --prod --dir=distVia a scaffolded workflow
uniweb add ci --host=netlifyWrites a deploy workflow plus a per-PR preview workflow that deploys to a pr-<number> alias and comments the URL. Needs NETLIFY_AUTH_TOKEN and NETLIFY_SITE_ID as repository secrets.
Via Git Integration
- Push your repo to GitHub/GitLab/Bitbucket
- Import in app.netlify.com
- Configure:
- Base directory:
site - Build command:
pnpm build - Publish directory:
site/dist
- Base directory:
netlify.toml
[build]
base = "site"
command = "pnpm build"
publish = "dist"With pre-rendering enabled (the default), no SPA-fallback redirect is needed. Each route has a real HTML file at <route>/index.html, and 404.html handles unknown paths. Adding [[redirects]] from = "/*" to = "/index.html" status = 200 would convert legitimate 404s into false 200s pointing at the home page — don't do this.
For the rare case of a non-prerendered site (single index.html, client-side routing), add the SPA-fallback redirect:
[[redirects]]
from = "/*"
to = "/index.html"
status = 200If the site declares redirect: or rewrite: directives in page.yml, the build emits a _redirects file that Netlify and Cloudflare Pages both consume — the same format works for either host.
Cloudflare Pages
Lifecycle: any of the three. CF Pages auto-resolves directory indexes, so no URI-rewrite worker is needed; the adapter's job is to emit _redirects from any redirect: / rewrite: directives in page.yml. uniweb deploy --host=cloudflare-pages uploads via wrangler (needs projectName on the target, plus CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID or a wrangler login session). uniweb add ci --host=cloudflare-pages commits a deploy workflow and a per-PR preview workflow.
# site/deploy.yml
default: production
targets:
production:
host: cloudflare-pagesThe build auto-detects CF_PAGES=1 and picks this adapter without needing --host in CI.
Via Dashboard
- Connect your repository
- Configure:
- Build command:
cd site && pnpm build - Build output directory:
site/dist
- Build command:
Via Wrangler
cd site
npx wrangler pages deploy distGitHub Pages
Lifecycle: Git-driven via GitHub Actions.
# site/deploy.yml
default: github-pages
targets:
github-pages:
host: github-pages
domain: mysite.com # optional; omit for project-pages URLThe adapter emits .nojekyll at the dist root. This is critical: without it, GitHub Pages's Jekyll processing silently strips paths whose components start with _ — including the _pages/ per-route content shards and any other underscore-prefixed directory the build produces. The site appears to deploy successfully but with parts missing.
Step-by-step recipe: Deploying to GitHub Pages. The CLI scaffolds the workflow, the CNAME (when you pass
--domain), and thedeploy.ymlentry above with one command:uniweb add ci # project pages: <user>.github.io/<repo>/ uniweb add ci --domain mysite.com # custom domain at rootThe generated workflow uses GitHub's official
actions/deploy-pagesflow (Settings → Pages → Source: GitHub Actions) and setsUNIWEB_BASEat build time sosite.ymlstays clean.
Manual deploy (without the workflow)
If you'd rather not use a CI workflow, build locally and push to a branch yourself:
cd site
UNIWEB_BASE=/<repo>/ uniweb build --host=github-pages # or UNIWEB_BASE=/ for a custom domain
npx gh-pages -d dist --dotfiles # --dotfiles preserves .nojekyllThe --dotfiles flag is required so gh-pages doesn't drop the .nojekyll file the adapter wrote. With Settings → Pages → Source set to "Deploy from a branch" → gh-pages, GitHub will serve the result. The CI flow is recommended over this for ongoing deploys, but manual is fine for one-off experiments.
AWS S3 + CloudFront
Lifecycle: CLI-push only. Unlike the other adapters, S3 + CloudFront has no Git-driven or scaffolded-CI route — uniweb deploy builds, syncs to S3, and invalidates CloudFront in one command, and that's the path.
First-time setup (S3 bucket, CloudFront distribution, OAC, IAM user, directory-index Function, custom error responses) is documented in AWS S3 + CloudFront — one-time setup. Do that walkthrough once per site, then come back here for the per-deploy reference.
# site/deploy.yml
default: production
targets:
production:
host: s3-cloudfront
bucket: your-bucket-name
distributionId: E1ABC...
region: us-east-1
profile: your-aws-profile # optional — overrides default AWS credential chaincd site && uniweb deployThe adapter:
- Emits
dist/cloudfront-function.js— the directory-index resolution rule (/about→/about/index.html), expressed in JS because that's how CloudFront accepts static routing config of this kind. You attach it once to your distribution's default cache behavior (viewer-request); subsequent deploys need nothing further. Without it, S3 REST endpoints return 404 for directory paths — the function isn't application logic or a redirect, just the static rule the host requires to resolve clean URLs to stored files. - Emits
dist/.uniweb-deploy-manifest.json— describes cache rules, invalidation patterns, and the foundation packaging shape (standalone or linked). - Runs
aws s3 syncfordist/assets/(immutable long cache) anddist/(short cache, with--delete). - Runs
aws cloudfront create-invalidationfor HTML paths and the locale/page shards.
Requires the aws CLI on PATH and standard AWS credentials. Provisioning the bucket, the distribution, the ACM cert, and uploading the CloudFront Function are one-time steps you do yourself (or via your IaC of choice).
Manual upload (without the adapter)
cd site
uniweb export
aws s3 sync dist/assets/ s3://your-bucket-name/assets/ \
--cache-control "public, max-age=31536000, immutable" --delete
aws s3 sync dist/ s3://your-bucket-name/ \
--exclude "assets/*" \
--cache-control "public, max-age=60, must-revalidate" --delete
aws cloudfront create-invalidation \
--distribution-id YOUR_DIST_ID \
--paths "/" "/*.html" "/*/index.html" "/sitemap.xml" "/robots.txt"CloudFront error responses
Configure CloudFront's custom error responses to map 404 → /404.html (status 404). The build always emits a 404.html — generated automatically with a designed 404 page if you have pages/404.md, or a generic fallback if not.
Do not map 404 → /index.html with status 200. Pre-rendered Uniweb sites have real files at every known route; legitimate 404s should return a real 404, not silently render the home page.
Docker / Self-Hosted
Nginx
FROM nginx:alpine
COPY site/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf# nginx.conf
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri.html $uri/ /index.html;
}
# Cache static assets
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}Build and run:
docker build -t my-site .
docker run -p 8080:80 my-siteNode.js (Express)
import express from 'express'
import { resolve } from 'path'
const app = express()
const dist = resolve('site/dist')
app.use(express.static(dist))
// SPA fallback
app.get('*', (req, res) => {
res.sendFile(resolve(dist, 'index.html'))
})
app.listen(3000)Environment Variables
For build-time configuration, use Vite's env variables:
# .env.production
VITE_API_URL=https://api.example.com
VITE_ANALYTICS_ID=UA-XXXXXAccess in code:
const apiUrl = import.meta.env.VITE_API_URLNote: Only variables prefixed with VITE_ are exposed to the client.
Build Optimization
Analyze Bundle
cd site
pnpm build -- --analyzeCompression
Most platforms handle compression automatically. For self-hosted:
# Pre-compress assets
gzip -k dist/assets/*.js
brotli dist/assets/*.jsConfigure your server to serve .gz or .br files when available.
Troubleshooting
404 on Page Refresh
Problem: Direct URL access returns 404 even though the site works after navigating from the home page.
With pre-rendering enabled (the recommended default), every known route has a real file at <route>/index.html. If a known route returns 404, the host probably isn't auto-resolving directory indexes. On hosts that don't (S3 REST + CloudFront with OAC, plain object stores), use uniweb deploy --host=s3-cloudfront so the adapter ships a viewer-request URI rewrite, or configure the equivalent rewrite manually.
Without pre-rendering (single index.html, client-side routing), configure SPA fallback — the host serves index.html for unknown routes. This is the only case where SPA fallback is correct; don't enable it on a pre-rendered site or legitimate 404s become false 200s.
Missing Assets
Problem: JS/CSS files not loading.
Solution: Check the base path in vite.config.js matches your deployment path.
Slow Initial Load
Problem: Large JavaScript bundle.
Solution:
- Enable pre-rendering for static HTML
- Check bundle size with
--analyze - Lazy-load heavy components
Publishing to Uniweb hosting
Go live on Uniweb hosting with a single command:
uniweb login # first time only
uniweb publish # brings the foundation along, syncs content, goes liveuniweb publish resolves which site, brings its foundation along (releasing the site's local foundation to the catalog under your @org when its code changed), syncs the content, and makes the site live. When going live needs payment (a first go-live, or a change that adds a paid feature), it opens a browser to settle it; an already-paid site just goes live.
Uniweb hosting serves the foundation as a separate file the runtime loads at startup, with version propagation moving updates forward on already-published sites without republishing. Foundations on Uniweb hosting are always cataloged (@org/name@version) — served from the catalog's CDN, licensed to the sites that use them. Whether you let uniweb publish release the foundation for you (single-site) or registered it deliberately with uniweb register (a multi-site product), it gets the same edge serving: JIT prerender, locale routing, Tier 0–3 caching, version propagation.
See Deploying → How a foundation reaches the catalog for the full picture.
Uniweb hosting requires authentication — run uniweb login first.
To make a foundation a catalog product other sites can use, see Registering a Foundation.
Registering a foundation to the registry
Foundations are runtime federated modules — not npm packages. They're not libraries for developers to import; they're vocabularies of section types that content authors compose sites from. The framework has no npm publish path for foundations and the catalog is not on npm.
The Uniweb catalog is a private, access-segregated inventory of commercial foundation products. You see only the foundations you own or are a registered editor of; clients see only the foundations licensed to their sites. Foundations are typically built by developers and agencies for paying clients; clients receive a license to use the foundation on a specific site, and that license rides with site ownership when sites are transferred.
To register a foundation as a catalog product (so it can be licensed across multiple sites, and propagation can move sites forward without redeploying):
cd foundation # the foundation's directory
uniweb login # first time only
uniweb register --scope @your-orgCatalog registrations require an @org scope. A foundation that exists for one specific site doesn't need a deliberate uniweb register step — uniweb publish brings it along, releasing it to the catalog under your @org automatically when its code changed. Reach for uniweb register directly when the foundation is a product meant for multiple sites, or when you want to register it on its own schedule independent of any one site's go-live.
Sites control their foundation update policy in site.yml:
foundation:
ref: '@your-org/foundation-name@1.4.7'
policy: auto-minor # exact | auto-patch | auto-minorSee CLI Commands and Deploying for the full register + propagation surface.
See Also
- CLI Commands — Build and deploy command options
- Site Configuration — Pre-render settings
- Templates — Project templates
- Registering a Foundation — Making a foundation a catalog product