Project Structures
A Uniweb project is a pnpm workspace. Building with Uniweb introduces the two-package setup — foundation and site, side by side. That's the starting point for most projects. But as a workspace grows — a second site sharing the same foundation, an extension adding specialized section types, multiple brands under one repo — the layout matters.
This guide covers the workspace structures we've found work well, when to use each, and the concrete wiring that connects the pieces.
The naming convention. A site is pure content; a foundation is the site's source code. That's why a single-foundation workspace puts content in
site/and code insrc/. The foundation package'snameis justsrc— folder and package name match. See Folder name vs package name below for the multi-foundation cases where they need to differ.
Terminology note: "Workspace" means the top-level directory created by
uniweb create— the pnpm monorepo root. In co-located layouts, each subdirectory (e.g.,marketing/,docs/) is called a project — a self-contained group of a foundation and its sites. Useuniweb add projectto create one in a step, or--projectonadd foundation/add sitefor a project's foundation (<project>/src/) or its one site (<project>/site/). A project with several sites keeps them in<project>/sites/— see Projects with several sites.
How the Pieces Connect
Every Uniweb workspace uses three wiring mechanisms. Understanding them makes all the recipes below predictable.
1. Workspace packages
pnpm-workspace.yaml declares which directories are packages. The default globs cover only the single layout:
# pnpm-workspace.yaml
packages:
- src
- sitesrc and site match sibling packages.
The entries are folder patterns, and the CLI reads them the way pnpm does: *, ? and [...] within a folder name, ** for any depth, {a,b} for alternatives, and a leading ! to exclude what the others match. A folder a wildcard finds counts when it holds a package.json. When uniweb add creates a package, it lists the folder only if no pattern covers it already, and it edits just this list — comments and the file's other settings stay as they are.
2. file: dependency
The site's package.json references its foundation as a local dependency:
{
"dependencies": {
"src": "file:../src"
}
}The key name ("src") is the foundation package's name field — what the foundation declared in its own package.json. The path is relative to the site's package.json. When packages move, this path changes — that's the main wiring adjustment between layouts.
3. foundation: in site.yml
The site needs to know which package provides the foundation. site.yml declares this:
foundation: srcThe value matches the package name from package.json. The build finds the foundation by that name: through the site's file: dependency of that name, a foundation in a folder of that name next to the site, or one in ../../foundations/<name>. It never looks in node_modules — a foundation is loaded by the site, not installed as a library.
4. Folder name vs package name
These are independent. The folder is what you see on disk; the package name is what pnpm and site.yml reference. A package's name is whatever its package.json::name says — the folder name is just a place.
The convention this guide follows:
| Layout | Folder | Package name |
|---|---|---|
| Single foundation | src/ | src (folder name = package name) |
| Co-located, multi-foundation | marketing/src/, docs/src/, … | marketing-src, docs-src, … (the -src suffix disambiguates them within one workspace, since pnpm requires unique package names) |
| Segregated, multi-foundation | foundations/marketing/, foundations/docs/, … | marketing, docs, … (the parent foundations/ is the disambiguator at the folder level; package names match the leaf folder name) |
In every layout, folder and package name are the same string — except in the co-located multi-foundation case, where pnpm's workspace-uniqueness rule forces a per-project suffix. The single-foundation case keeps the names matching: folder src/, package src.
That's the complete wiring. Workspace globs tell pnpm what's a package, file: tells the site where the foundation lives on disk, and foundation: tells the build which package it is. Everything else — directory names, nesting depth, number of packages — is convention.
Recipes
Each recipe shows a workspace layout with its wiring. Pick the one that matches your situation, or combine pieces from several.
Single (siblings)
The default when you run pnpm create uniweb. Foundation and site are siblings at the workspace root.
my-project/
├── src/ ← the foundation package
│ ├── package.json ← name: "src"
│ ├── main.js ← foundation declarations (name, vars, defaultLayout)
│ ├── styles.css
│ ├── sections/
│ ├── components/
│ └── vite.config.js
├── site/
│ ├── package.json ← "src": "file:../src"
│ ├── site.yml ← foundation: src
│ ├── pages/
│ └── vite.config.js
├── pnpm-workspace.yaml
└── package.json# pnpm-workspace.yaml
packages:
- src
- siteA site is pure content. A foundation is the site's source code — that's why the foundation package lives in src/. Folder and package name match: the folder is src/ and package.json::name is "src".
When to use it: One foundation, one site. The workspace root stays clean. Most projects start here and many never need more.
Trade-off: Adding a second foundation or site puts it next to the first at the root level. That works for two or three packages, but gets noisy beyond that.
Segregated
Foundations and sites in separate directories. Create a blank workspace and use uniweb add with paths — uniweb add foundation foundations/default — to build this layout.
my-project/
├── foundations/
│ ├── default/
│ │ ├── package.json ← name: "default"
│ │ └── sections/
│ └── blog/
│ ├── package.json ← name: "blog"
│ └── sections/
├── sites/
│ ├── main/
│ │ ├── package.json ← "default": "file:../../foundations/default"
│ │ └── site.yml ← foundation: default
│ └── marketing/
│ ├── package.json ← "default": "file:../../foundations/default"
│ └── site.yml ← foundation: default
├── pnpm-workspace.yaml
└── package.json# pnpm-workspace.yaml
packages:
- foundations/*
- sites/*uniweb add registers each folder it creates by its exact path; globs like these cover every folder under foundations/ and sites/ at once. Either works.
The file: path goes up two levels from sites/main/ to the workspace root, then down into foundations/default/.
When to use it: Multiple sites sharing a foundation, or multiple foundations with clear separation. The foundation is often the primary deliverable — registered to the Uniweb registry and delivered via CDN to sites. The site packages serve as development harnesses for testing the foundation.
Trade-off: Deeper nesting means longer file: paths. A single-foundation project doesn't benefit from the extra directories.
Build order:
Foundations must build before sites — prerender needs each foundation's built entry.js. uniweb build at the workspace root takes care of it: it builds every foundation, then every extension, then every site. The CLI writes it into the root package.json as the build script, next to dev:
{
"scripts": {
"build": "uniweb build",
"dev": "uniweb dev",
"dev:marketing": "uniweb dev marketing"
}
}With several sites, uniweb dev runs the first one, uniweb dev <site> any other, and uniweb add site adds a dev:<site> script for each site after the first.
Co-located projects
Each project is a self-contained directory with its own foundation and site. This is the pattern we used for all of our company sites.
my-workspace/
├── marketing/
│ ├── src/
│ │ └── package.json ← name: "marketing-src"
│ └── site/
│ ├── package.json ← "marketing-src": "file:../src"
│ └── site.yml ← foundation: marketing-src
├── docs/
│ ├── src/
│ │ └── package.json ← name: "docs-src"
│ └── site/
│ ├── package.json ← "docs-src": "file:../src"
│ └── site.yml ← foundation: docs-src
├── pnpm-workspace.yaml
└── package.json# pnpm-workspace.yaml
packages:
- "*/src"
- "*/site"The */src and */site globs discover every project's packages automatically. Adding a new project means creating a new directory — no workspace config changes.
When to use it: Multiple independent brands or products that share a repo for convenience but don't share foundations. Each project has its own build, its own foundation, its own site. Teams can work in their own directory without stepping on each other.
Trade-off: Foundation package names must be globally unique in the workspace — pnpm needs to distinguish them. If two projects both name their foundation "src", pnpm can't link them correctly. Use project-scoped names ("marketing-src", "docs-src") or scoped packages ("@acme/marketing").
One foundation can't be shared across projects in this layout without reaching across directory boundaries (a file:../../other/src path, which defeats the co-location purpose). If two projects need the same foundation, the segregated layout is a better fit.
Projects with several sites
Co-located projects whose foundation serves more than one site — one product or solution type per project, its sites side by side.
my-workspace/
├── memberships/
│ ├── src/
│ │ └── package.json ← name: "memberships-src"
│ └── sites/
│ ├── fitness/
│ │ ├── package.json ← "memberships-src": "file:../../src"
│ │ └── site.yml ← foundation: memberships-src
│ └── society/
├── courses/
│ ├── src/ ← name: "courses-src"
│ └── sites/
│ └── academy/
├── pnpm-workspace.yaml
└── package.json# pnpm-workspace.yaml
packages:
- "*/src"
- "*/sites/*"Two lines cover every project and every site in each, so a new project or site needs no change to the file. Listing each project's folders instead (memberships/src, memberships/sites/*, …) works the same way and names the projects in one place, at two lines each.
Grow it by naming the folders:
uniweb add foundation --project courses # courses/src/, package courses-src
uniweb add site courses/sites/academy --foundation courses-src # courses/sites/academy/With */src and */sites/* listed, both commands create the folder and leave pnpm-workspace.yaml as it is. A site's package name is the last folder of its path, and package names are workspace-wide: two projects can't both hold a site package named fitness.
Extensions nest the same way. A project's own go in memberships/extensions/<name>/, and one shared by every project goes in extensions/<name>/ at the workspace root; each site loads the nearest one with the name it asks for.
When to use it: Several products or solution types in one repository, each a foundation with several sites.
Trade-off: One folder level more than co-located projects, so a site's file: path goes up two levels (file:../../src).
Extensions as siblings
An extension adds specialized section types without modifying the primary foundation. It's a separate foundation, loaded at runtime from a URL — for an extension in the workspace, a URL on the site's own origin. The extensions template creates this layout.
my-project/
├── src/
│ ├── package.json ← name: "src"
│ └── sections/ ← Header, Hero, Features, Footer
├── extensions/
│ └── effects/
│ ├── package.json ← name: "effects"
│ └── sections/ ← ParticleHero, AnimatedCounter
├── site/
│ ├── package.json ← "src": "file:../src"
│ └── site.yml ← see below
├── pnpm-workspace.yaml
└── package.json# pnpm-workspace.yaml
packages:
- src
- extensions/effects
- siteThe extension isn't a file: dependency of the site — it's loaded by URL at runtime. site.yml declares it:
# site/site.yml
foundation: src
extensions:
- /effects/entry.js/effects/entry.js names the extension effects. A site finds it in a folder of that name — effects/ or extensions/effects/ — in the folder the site is in or a folder above it, up to the workspace root; the nearest one wins. So effects/ next to site/ works as well as extensions/effects/, and so does an extension at the workspace root for a site in sites/main/. The same folder serves the URL everywhere:
- Dev:
uniweb devbuilds the extension first if it isn't built, or if its sources changed since, and the dev server serves its build at that URL. After editing the extension while the server runs, rununiweb buildand reload. - Production:
uniweb buildbuilds extensions before sites, and each site's build copies the extension's built files into its owndist/, undereffects/.
No root script or Vite plugin is needed for either.
An extension builds like any foundation — same Vite plugin, same output. The difference is in role: it contributes section types but doesn't provide Layout or theme variables. Its main.js declares extension: true:
// extensions/effects/main.js
export default {
extension: true
}This causes the build to write role: "extension" to the extension's schema.json, and to warn if the extension declares vars or layouts (which belong to the primary foundation).
Content authors use section types from extensions the same way — type: ParticleHero in frontmatter. The runtime resolves the type from whichever foundation provides it. Primary foundation wins if both define the same name; extensions are checked in declared order.
When to use it: Adding specialized capabilities (visual effects, data visualization, interactive widgets) that don't belong in a general-purpose foundation. The extension can be developed, tested, and registered independently.
Trade-off: Extensions are always runtime-loaded, which means an additional network request on first page load.
See Extending Your Site for the broader extensibility model, including Vite plugins and head.html scripts.
Extensions in segregated layout
When extensions are shared across multiple sites, the segregated layout accommodates them naturally:
my-project/
├── foundations/
│ └── default/
│ └── package.json ← name: "default"
├── extensions/
│ ├── effects/
│ │ └── package.json ← name: "effects"
│ └── data-viz/
│ └── package.json ← name: "data-viz"
├── sites/
│ ├── marketing/
│ │ └── site.yml ← extensions: [/effects/entry.js]
│ └── analytics/
│ └── site.yml ← extensions: [/effects/entry.js, /data-viz/entry.js]
├── pnpm-workspace.yaml
└── package.json# pnpm-workspace.yaml
packages:
- foundations/*
- extensions/*
- sites/*The wiring is the same as the sibling extension recipe, organized for scale. Each site declares the extensions it needs, and finds them by the same rule: a site in sites/marketing/ looks in sites/ first, then at the workspace root, where extensions/effects/ is. Its build copies each one into its own dist/.
When to use it: Multiple extensions shared across multiple sites — typically a larger project with a team maintaining the extension library.
Trade-off: More folders for the same wiring. For a single extension used by one site, the sibling layout is less ceremony.
Choosing a Layout
| Situation | Layout |
|---|---|
| One foundation, one site | Single |
| Registering a portable foundation | Segregated (site is the dev harness) |
| Multiple sites sharing a foundation | Segregated |
| Multiple independent brands | Co-located |
| Several products, each a foundation with several sites | Projects with several sites |
| Adding specialized section types | Extensions as siblings |
| Extensions shared across sites | Extensions segregated |
These are starting points. The patterns compose — a co-located project can have a sibling extension, a segregated layout can mount external content with paths:. Start with the pattern that matches your primary need, and adjust as the project grows.
Growing a Project
Projects evolve. The uniweb add command handles scaffolding, workspace globs, and root scripts — you don't wire things manually.
Running the CLI: Install globally with
npm i -g uniwebfor the best experience — thenuniwebworks everywhere, and inside a project it runs the project's own copy. Keep it current:createandclonestart a project on the CLI that runs them, so a global install stops them when a newer release is out. Without a global install, usenpx uniweb@latest createto start a project andpnpm uniwebinside one (afterpnpm install).
Starting from blank
When you know you need a non-default layout — co-located projects, multiple foundations, or a segregated structure — start with a blank workspace and build it up:
pnpm create uniweb my-workspace --blank
cd my-workspace
# Add a co-located project
uniweb add project main
# Now install and run
pnpm install
pnpm devThe add project command creates a co-located foundation + site pair in one step. The same flow works for any layout — the CLI creates the folder you ask for, exactly as you ask:
# Co-located: grouped by project (preferred for multiple projects)
uniweb add project marketing
uniweb add project docs
# Bare names — folder is at the workspace root
uniweb add foundation marketing # → ./marketing/ (package: marketing)
uniweb add site main --foundation marketing
uniweb add site docs --foundation blog
# Segregated: pass the path explicitly with a slash
uniweb add foundation foundations/marketing # → ./foundations/marketing/
uniweb add foundation foundations/blog # → ./foundations/blog/
uniweb add site sites/main --foundation marketing
uniweb add site sites/docs --foundation blogThe CLI creates the directory, writes package.json and config files, lists the package in pnpm-workspace.yaml (unless a pattern there covers it already), wires the file: dependency from site to foundation, and updates root scripts — removing the ones it wrote for a site that no longer exists. Run pnpm install once after adding all packages. If a target folder or package name already exists, the command stops with a precise error.
Adding to an existing workspace
After pnpm install, the CLI is available as a project dependency too:
# Add a second site sharing the existing foundation
uniweb add site blog
pnpm installThis creates blog/ at the workspace root, registers it in pnpm-workspace.yaml, and wires the site to the existing foundation. The original site at site/ stays where it is. To group sites under a sites/ parent, write the path explicitly: uniweb add site sites/blog.
my-project/
├── src/ ← foundation package (name: "src")
├── site/ ← original
├── blog/ ← new, wired to the src foundation
└── pnpm-workspace.yamlAdding a named foundation works the same way: uniweb add foundation marketing creates marketing/, and uniweb add foundation foundations/marketing groups it under foundations/.
Adding a co-located project
If you started with a single layout and now need independent projects:
uniweb add project docs
pnpm installThis creates docs/src/ and docs/site/, and lists */src and */site — each only if the workspace's patterns don't cover that folder already. Package names are docs-src and docs-site. Use --from to apply template content: add project docs --from academic.
Your original src/ and site/ still work — they match their existing globs. You can keep the hybrid layout or move them into a project directory when you're ready.
For a project with several sites, add each one by its folder — uniweb add site docs/sites/guide --foundation docs-src — as in Projects with several sites. --project docs places the project's foundation (docs/src/) or its one site (docs/site/) and takes no name of its own: given one, the command stops and names the folder form instead.
Adding an extension
uniweb add extension effects --site site
pnpm installThis creates effects/, lists it in pnpm-workspace.yaml, and wires /effects/entry.js — where the site loads it from — into the specified site's site.yml. Extensions are placed like foundations: to group them, name the folder (uniweb add extension extensions/effects). The extension builds like a foundation — run uniweb build from the workspace root to build everything in order.
The URL is written only when the site will find this extension there — the folder has to be one the site looks in (see Extensions as siblings). For any other folder, the command creates the extension, leaves site.yml alone, and says where the site looks.
Extensions are always runtime-loaded (via URL, not file: dependency). uniweb dev builds the extension when it needs to and serves its build; uniweb build copies it into the site's dist/. See Extending Your Site for the broader extensibility model.
Working with External Content
A site can mount content from outside its own pages/ directory using paths: in site.yml. This is useful when content lives in a separate repository, a shared directory, or a submodule.
# site/site.yml
paths:
pages/docs: ../../../docsThis mounts the docs/ directory as if it were pages/docs/ inside the site. Pages and library files in the external directory are discovered normally.
The path is relative to the site directory. defineSiteConfig() in the site's vite.config.js reads paths: and auto-computes server.fs.allow so Vite can serve files from outside the project root — no manual server.fs.allow configuration needed.
When to use it: Documentation content shared across sites, content maintained by a separate team, or submodule-based content repositories. The Uniweb site itself uses this pattern to mount packages/docs/ into its page tree.
See Also
- Building with Uniweb — The two-package model and how CCA works
- Templates — Built-in and official templates
- Extending Your Site — Vite plugins,
head.html, and the full extensibility model - Foundation Categories — Bundled vs portable foundations and when portability matters
- Site Configuration — Full
site.ymlreference includingpaths:,extensions:, andfoundation: