Getting Started

Technical details

How the monorepo is organised, what each package owns, and the commands you will use most.

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.

ToolOwned by
Vite+the workspace root: formatting, linting, task running
Nuxt 4each application, separately
Nuxt UI and Pinialayer-base, so every layer above it gets them
Nuxt Contentlayer-site. layer-blog and layer-docs add sections
Better Authlayer-auth
Polarlayer-payments
Nuxt Email Rendererlayer-email
Drizzle ORMapps/web: schema, migrations, Postgres
Varlockevery application, and every layer that reads a variable
Vitesteach package, run through Vite+
Cloudflare Workerseach 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.

PackageDirectoryOwns
@app/siteapps/sitethe public website, blog, documentation, legal pages and contact form
@app/webapps/webthe 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:

apps/site/nuxt.config.ts
export default defineNuxtConfig({
  extends: ['@app/layer-site', '@app/layer-blog', '@app/layer-docs'],
})
apps/web/nuxt.config.ts
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 unitthe owning packages/layer-* packageauthentication, payments or email
affects only one deployablethat application under apps/a product-only page in apps/web/app/pages/
adds a feature that needs a boundary of its owna new packages/layer-* packagenotifications, even if only one application will ever show it

Commands

CommandPurpose
pnpm dev:sitedevelopment server for the public website
pnpm dev:webdevelopment server for the product application
vp checkrun Oxfmt and Oxlint across the workspace. --fix to repair
pnpm testrun every test suite
pnpm typechecktype-check every package
pnpm buildbuild every package
pnpm readythe 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:

apps/web/.env.schema
# @import(../../.env.project.schema)
# @import(./node_modules/@app/layer-auth/.env.schema)
# @import(./node_modules/@app/layer-payments/.env.schema)
FileHolds
.env.schemanames, types, requiredness and sensitivity. No values
.env.<context>the non-secret values for one Runtime Context, plus references to secrets
.env.infisicalthe binding to the secret store for that application
.env.testinert fakes, so a clean checkout runs the tests with no credentials
.env.localpersonal 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.

Copyright © 2026