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 in src/. The foundation package's name is just src — 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. Use uniweb add project to create one in a step, or --project on add foundation / add site for 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
  - site

src 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: src

The 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:

LayoutFolderPackage name
Single foundationsrc/src (folder name = package name)
Co-located, multi-foundationmarketing/src/, docs/src/, …marketing-src, docs-src, … (the -src suffix disambiguates them within one workspace, since pnpm requires unique package names)
Segregated, multi-foundationfoundations/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
  - site

A 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
  - site

The 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 dev builds 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, run uniweb build and reload.
  • Production: uniweb build builds extensions before sites, and each site's build copies the extension's built files into its own dist/, under effects/.

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

SituationLayout
One foundation, one siteSingle
Registering a portable foundationSegregated (site is the dev harness)
Multiple sites sharing a foundationSegregated
Multiple independent brandsCo-located
Several products, each a foundation with several sitesProjects with several sites
Adding specialized section typesExtensions as siblings
Extensions shared across sitesExtensions 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 uniweb for the best experience — then uniweb works everywhere, and inside a project it runs the project's own copy. Keep it current: create and clone start a project on the CLI that runs them, so a global install stops them when a newer release is out. Without a global install, use npx uniweb@latest create to start a project and pnpm uniweb inside one (after pnpm 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 dev

The 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 blog

The 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 install

This 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.yaml

Adding 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 install

This 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 install

This 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: ../../../docs

This 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