Schemas in Practice

Data Schemas covers how to write a schema — fields, types, sections. This guide covers everything after that: where the schema file lives, how a second project uses it, and whether any of it requires an account.

The short version: a schema has to be on disk where the build can find it, always. Registering it with the Uniweb registry is a separate thing you do for a separate reason. Most confusion in this area comes from treating those as one decision.


Two questions, not one

When you reference @acme/person from a section type's meta.js, two independent questions apply:

QuestionAnswer decidesWhen it matters
Where does the build find the definition?whether uniweb build succeedsevery single build, always
Is the schema registered?whether authors can create records of this type in the Uniweb apponly when you work with app authors

They don't substitute for each other. Registering a schema does not put it where the build can find it, and having it on disk does not register it.

That first question has no network in it at all. Schema references resolve from local files — a folder in your foundation, a folder you point at, or an installed package. If you never touch the second question, you never need an account, and your site builds and deploys to any static host.


@acme/person is not an npm package

This trips people up, and the shape is genuinely misleading: a schema ref looks exactly like an npm scoped package.

They are different namespaces that happen to share a syntax. A schema ref's @scope is an organization in the Uniweb registry. An npm scope is an organization on npm. Nothing connects them.

The clearest proof is the standard schemas:

# You write this ref:
data: { people: '@std/person' }

@std is an org in the Uniweb registry. But the file it resolves to ships in an npm package named @uniweb/schemas. The ref scope and the package scope aren't even spelled the same.

For every other org, the two coincide by convention — @acme/person looks for a package named @acme/schemas — but that's a default lookup rule, not an identity. And the moment you use a routed directory (below), npm leaves the picture entirely while the ref stays @acme/person.

So: @acme in a ref names who owns the schema, not how it's delivered. Delivery is a separate choice you make.


The namespaces

RefWho owns itWhere the build looks
@/productthis foundation<foundation>/schemas/product.{js,json,yml,yaml}
@std/personthe Uniweb standardsthe @uniweb/schemas package
@acme/personan org — yours or someone else'sa routed directory, else an @acme/schemas package
@uniweb/person—rejected; @uniweb is reserved

Worth noticing: your own org and a third party's org resolve identically. There is no build-time distinction between @acme/person when you're an Acme member and when you aren't. The difference only appears when you register — membership over a scope is what authorizes registering into it.

@/ is the odd one out in a useful way. It means "whoever ends up owning this," so the same foundation can be registered under any org without editing a single ref. Registering resolves @/product into @acme/product at submit time.


Three ways to put a schema where the build can find it

1. In the foundation itself — @/

A schemas/ folder at the foundation root, beside main.js:

foundation/
├── main.js
├── schemas/
│   └── product.yml        →  referenced as '@/product'
└── sections/

No configuration. This is the right default for a schema that belongs to one foundation.

2. A folder anywhere — routed with schemas.config.js

Point a scope at a directory of schema files. No package, no install, no npm:

// foundation/schemas.config.js
export default {
  '@acme': '../shared/acme-schemas',
  '@brand': process.env.BRAND_SCHEMAS,   // unset → falls back to the package rule
}

@acme/person then resolves to ../shared/acme-schemas/person.yml. Relative paths resolve against the foundation root.

Because it's plain JS, the directory can be anything you can compute — which makes this the natural fit for a shared git repo. A submodule, a sibling clone, or a folder in the same monorepo all work the same way:

workspace/
├── acme-schemas/          # a plain git repo — no npm, no package.json needed
│   └── person.yml
└── foundation/
    └── schemas.config.js  # { '@acme': '../acme-schemas' }

Two things worth knowing, neither obvious from the config shape:

  • A routed scope has no fallback. If @acme is routed and person.yml isn't in that directory, the build fails — it will not then try the @acme/schemas package. That's deliberate: silently loading a different definition of person is worse than a clear error. To fill one gap, use a per-schema override (below) rather than relying on a fallback.
  • A scope value is a directory; the schema name is appended to it. For a single file, use a per-schema key instead.

Overriding one schema — a per-schema key

A key can also name a single schema and point it at an exact file. This wins over the scope directory for that one name, so you can keep a whole scope routed to a shared catalog while swapping in one local definition:

// foundation/schemas.config.js
export default {
  '@acme': '../shared/acme-schemas',        // the catalog, for every @acme/*
  '@acme/person': './schemas/acme-person.yml', // …except person, which is local here
}

@acme/person resolves to the local file; @acme/project, @acme/event, and the rest still come from the shared folder. This is the clean alternative to the workarounds it replaces — symlinking a file into the shared folder, or forking the whole scope to change one schema. Precedence is most-specific-first: a per-schema file beats the scope directory, which beats the @acme/schemas package.

A per-schema value is a file (the extension is optional — ./schemas/acme-person finds the .yml). @/ and @uniweb are never routable — put your own schemas in the foundation's schemas/, and use @std for the standards.

3. An installed package — @acme/schemas

The convention lookup: @acme/person → the @acme/schemas package, resolved from the foundation's node_modules. A pnpm workspace package, a private npm package, and a public one all behave identically.

Resolving from the foundation (rather than from the build tool) means each foundation can pin its own version of a shared catalog.

Choosing between them

Routed directoryPackage
Setupone config linea package + a dependency entry
Versioningwhatever git gives youreal semver, pinned per foundation
Sharing across machinespath must exist on each onepnpm install
Good fora team's shared repo, active co-developmenta stable catalog many projects consume

Do you need to register?

Registering submits your schemas to the Uniweb registry so they become content types authors can create records against.

You're doing thisRegister?
Static site, content in markdown and YAML files, deployed to GitHub Pages or any static hostNo
Running uniweb validate against your file-based dataNo
Sharing schemas across your own foundationsNo — put them on disk (above)
Content authors creating and editing records in the Uniweb appYes
Syncing site content to Uniweb hostingYes

What registering does not do, and this is the part worth internalizing: it does not make a schema resolvable for someone else's build. If a teammate's foundation binds @acme/person, registration doesn't help them — they still need the file on disk, via a routed directory or a package. Registration and delivery are genuinely separate pipes.

This is a deliberate consequence of the framework being usable with no backend: builds don't reach the network, so a registry can't be a build dependency. The cost is that a shared catalog needs both — registered for the app, distributed for the build.


Four scenarios

Solo project, static host

You write schemas in foundation/schemas/, reference them as @/product, keep records in records/, and deploy the built site anywhere. Standards like @std/person work too — add @uniweb/schemas as a foundation dependency. No account, no registration, no network.

An agency catalog reused across client projects

Keep one catalog, reference it from every foundation. Either delivery works; pick by how the projects are laid out.

If clients live in one workspace, a package is tidy:

workspace/
├── acme-schemas/          # { "name": "@acme/schemas" }
│   └── schemas/person.yml
├── client-a/foundation/   # depends on @acme/schemas
└── client-b/foundation/   # depends on @acme/schemas

If they're separate clones, route a directory instead — each foundation's schemas.config.js points at wherever that machine keeps the catalog repo, with the path from an env var if it varies.

Either way you fix person once and every project picks it up. Nothing here is registered yet, and nothing needs to be.

Bringing app authors into an existing project

Now the schemas need to exist as content types. Register the catalog under your org:

uniweb login
cd acme-schemas
uniweb register --scope @acme

Your foundations are unchanged — they still resolve @acme/person from disk exactly as before. You've added a capability, not migrated anything. Re-registering an unchanged schema is a no-op, so this is safe to re-run.

Consuming someone else's schemas

You need their definitions on disk. Two paths, and it's their choice which they offer:

  • They publish an npm package named @theirorg/schemas — install it, reference @theirorg/person.
  • They publish a public git repo — clone or submodule it, route the scope at that folder. No npm involved.

If they've only registered with the Uniweb registry and published nothing, you can't build against their schemas today. That's a real gap, not a configuration you're missing.


Publishing a catalog for others

A schemas catalog is recognized two ways, and you can use whichever fits:

A package that exports schemas — the same contract @uniweb/schemas uses:

// index.js
export const schemas = { person, project }
export const getSchema = (name) => schemas[name]
export const getSchemaNames = () => Object.keys(schemas)

A folder of schema files — no index, no exports, and no package.json required:

acme-schemas/
└── schemas/
    ├── person.yml
    └── project.yml

Either can be registered on its own, with no foundation involved:

cd acme-schemas
uniweb register --scope @acme

Set "uniweb": { "scope": "@acme" } in package.json to drop the flag on later runs. A bare folder with no package.json can't record it, so pass --scope each time. Preview the exact submission without authenticating using --dry-run.


When it fails

MessageCauseFix
'@acme/schemas' is not installed in this foundationref resolves by the package rule; nothing routed, nothing installedinstall the package, or route @acme in schemas.config.js
Data schema '@acme/person' not found in the directory '@acme' is aliased to (…)scope routed, file missing thereadd the file, fix the path, or override just that one with a '@acme/person' key
Data schema '@acme/person' is aliased to '…', but no schema file exists thereper-schema key points at a missing filefix the path; the value is a file, not a directory
'@uniweb' is reserved and cannot be aliasedtried to route @uniweb in schemas.config.jsuse @std for the standards
'@uniweb' is the reserved platform system namespaceused @uniweb/person as a ref for a standarduse @std/person
Data schema '@/product' not found. Expected one of: schemas/product.js, …@/ ref with no matching fileadd schemas/product.yml at the foundation root

See Also