Technical details
Two Nuxt 4 applications and seven Nuxt layers share one pnpm workspace, and
Vite+ runs the workspace tasks. There is no root Nuxt application. Nothing
scans packages/ or orders it for you. Each application names the layers it wants.
Who owns what
The Introduction lists the features. This table says which package to open when you want to change one.
| Tool | Owned by |
|---|---|
| Vite+ | the workspace root: formatting, linting, task running |
| Nuxt 4 | each application, separately |
| Nuxt UI and Pinia | layer-base, so every layer above it gets them |
| Nuxt Content | layer-site. layer-blog and layer-docs add sections |
| Better Auth | layer-auth |
| Polar | layer-payments |
| Nuxt Email Renderer | layer-email |
| Drizzle ORM | apps/web: schema, migrations, Postgres |
| Varlock | every application, and every layer that reads a variable |
| Vitest | each package, run through Vite+ |
| Cloudflare Workers | each application, deployed separately |
Repository structure
apps/
site/ # public website: landing, legal, contact form, blog, docs
web/ # signed-in product: dashboard, account settings, billing, /auth, /admin
packages/
layer-base/ # UI primitives, composables, shared utils and schemas
layer-email/ # transactional email templates and sending utilities
layer-site/ # website shell, content foundation, contact feature
layer-blog/ # blog content section and pages
layer-docs/ # documentation content section and presentation
layer-payments/ # Polar products, orders, subscriptions and webhooks
layer-auth/ # Better Auth wiring, account settings, user administration
tooling/ # repository tooling only; no application may depend on it
infra/ # OpenTofu roots for the long-lived Cloudflare resources
scripts/ # workspace-level scripts only
docs/ # architecture decision records and agent-facing notes
package.json # root commands and package manager metadata
pnpm-workspace.yaml # workspace packages and the shared dependency catalog
vite.config.ts # Vite+ formatting, linting and staged-file tasks
The root owns workspace orchestration. Application code belongs to an application. Feature code belongs to the layer that owns it.
The two applications
Each application has its own package.json, nuxt.config.ts, environment schema and test setup.
They run and deploy independently.
| Package | Directory | Owns |
|---|---|---|
@app/site | apps/site | the public website, blog, documentation, legal pages and contact form |
@app/web | apps/web | the signed-in product, /auth, /admin, billing, the database, APIs and cron work |
apps/web keeps its pages in apps/web/app/pages. The product surface belongs to the application,
not to a layer of its own.
An application extends only the layers it uses directly:
export default defineNuxtConfig({
extends: ['@app/layer-site', '@app/layer-blog', '@app/layer-docs'],
})
export default defineNuxtConfig({
extends: ['@app/layer-auth', '@app/layer-payments'],
})
layer-base and layer-email are missing from both lists on purpose. The layers that need them
declare them, so Nuxt pulls them in transitively.
How the layers fit together
Every layer is a workspace package with its own code, config, modules, tests and exports. You should be able to drop a layer into another Nuxt project without copying files out of this repository.
layer-site -> layer-base, layer-email
layer-blog -> layer-site
layer-docs -> layer-site
layer-payments -> layer-base, layer-email
layer-auth -> layer-base, layer-email, layer-payments
The arrows are extends relationships. Each one is written twice: as a workspace:* dependency in
package.json, and as an entry in nuxt.config.ts. The manifest is what the workspace tooling
reads. extends is what sets Nuxt's layer resolution order.
Ownership rules:
- A layer declares the Nuxt modules its own code needs.
- Its CSS, runtime defaults, route rules, auto-import directories and type templates live with it.
- So do its components, composables, stores, server handlers, schemas and email templates.
- Cross-layer imports go through the package name and an exported subpath, like
@app/layer-base/shared/utils/constants. - Inside a layer, use relative imports. Never a self-referencing alias.
A feature layer may depend on the base layer. Sibling layers must not reach into each other through relative paths.
Where should a change live?
| If the change... | Edit... | Example |
|---|---|---|
| belongs to a feature you may want to move or reuse as a unit | the owning packages/layer-* package | authentication, payments or email |
| affects only one deployable | that application under apps/ | a product-only page in apps/web/app/pages/ |
| adds a feature that needs a boundary of its own | a new packages/layer-* package | notifications, even if only one application will ever show it |
Commands
| Command | Purpose |
|---|---|
pnpm dev:site | development server for the public website |
pnpm dev:web | development server for the product application |
vp check | run Oxfmt and Oxlint across the workspace. --fix to repair |
pnpm test | run every test suite |
pnpm typecheck | type-check every package |
pnpm build | build every package |
pnpm ready | the full local gate: check, test, type-check, then build |
There is no root dev command on purpose. It would have to pick one of the two applications for
you.
vp check is not a replacement for nuxt typecheck. Type checking runs as each application's own
task, because it needs Nuxt's generated types and auto-imports. Ask for one package by name:
vp run @app/site#typecheck
vp run @app/web#build
Git hooks live in .vite-hooks/. Before a commit they scan the staged diff for resolved secrets,
then run the staged tasks from vite.config.ts. The commit-message hook runs commitlint.
Environment configuration
Varlock resolves every variable. Layers declare what their code reads,
applications supply the values, and APP_RUNTIME_CONTEXT decides which values load.
Each application owns an .env.schema. It declares that application's variables, imports the schema
of every layer it composes, and imports your project's identity from the repository root:
# @import(../../.env.project.schema)
# @import(./node_modules/@app/layer-auth/.env.schema)
# @import(./node_modules/@app/layer-payments/.env.schema)
| File | Holds |
|---|---|
.env.schema | names, types, requiredness and sensitivity. No values |
.env.<context> | the non-secret values for one Runtime Context, plus references to secrets |
.env.infisical | the binding to the secret store for that application |
.env.test | inert fakes, so a clean checkout runs the tests with no credentials |
.env.local | personal overrides, never committed |
local is the default and has no value file. It loads .env.development instead. Set
APP_RUNTIME_CONTEXT before you run anything that deploys, or you overwrite the development
deployment.
Load Varlock before you read ENV:
import 'varlock/auto-load'
import { ENV } from 'varlock/env'
varlock/auto-load covers nuxt.config.ts too, so Nuxt commands stay unwrapped. You never need
varlock run -- in front of them.
Two files sit at the repository root, and they are not the same thing. .env.schema is for
repository tooling; varlock resolves from the working directory and does not walk up, so nothing
under apps/ or packages/ can read it. .env.project.schema beside it holds your project's
identity, and both applications reach it by the import above rather than by resolution.
Project identity is what it declares and what derives from it.
Deployment shape
apps/site and apps/web deploy as separate Cloudflare Workers, and GitHub Actions is what deploys
them. Only apps/web has a database. The deploy workflow applies its migrations. A plain build
never does.
Read Deploy pipeline for what each event deploys. Infrastructure covers the resources behind the bindings, and Database covers migration policy.
