Files
stanza/apps/web/content/docs/concepts.mdx
T
jake 678b5bc1af feat: add ui-shadcn-base, ui-shadcn-radix, and ui-tailwind modules; rename styling category to ui
- Rename `styling` → `ui` across the entire codebase (CATEGORIES array, docs, AGENTS.md, CLI tests, resolver, template tests, codemod runner); `ui` is `home: package` so shadcn installs into `packages/ui/` as a workspace dep rather than wiring directly into the app shell
- Add `ui-shadcn-base` (Tailwind v4 / CSS-first, no Radix peer) and `ui-shadcn-radix` (Tailwind v4 + Radix primitives) with full per-framework template sets for Next.js and TanStack Start: `components.json`, `globals.css`, `postcss.config.mjs`, `theme-provider.tsx`, shared `Button` + `utils.ts`; light/dark logos inlined from svgl
- Rename `styling-tailwind` → `ui-tailwind` to match the new category id; update its module definition to `category: "ui"` and `home: package`
- Add four new codemod builtins with tests: `add-array-entry-in-call` (inserts an element into a named array argument of any `CallExpression`), `replace-import` (rewrites a specifier in an existing import declaration), `set-html-attributes` (sets attributes on an HTML/JSX element by tag name or selector), `set-tsconfig-paths` (merges `compilerOptions` entries into a `tsconfig.json` AST node); register all four in the builtins index and export from `@stanza/codemods`
- Add `wrap-root-layout` builtin tests
- Update registry.mdx, concepts.mdx, cli.mdx, getting-started.mdx, agents.mdx, index.mdx, AGENTS.md, README.md, TODO.md, and SKILL.md to reflect the category rename and new modules
2026-05-25 15:26:08 -04:00

6.6 KiB

title, description
title description
Concepts The mental model behind Stanza — apps, categories, vendoring, peers, and the manifest.

A handful of ideas explain how Stanza decides what to write and where. Once they click, the CLI's behavior is predictable.

Apps

A Stanza project contains one or more apps — each a buildable thing under apps/<dir>/ with its own framework and tests. An app is declared in stanza.json as:

{ "id": "web", "dir": "apps/web", "kind": "web" }
  • id — short, stable handle. Doubles as the workspace-package suffix (@<your-app>/web) and as the value you pass to --app=<id> in the CLI.
  • dir — repo-relative directory the app lives in.
  • kind — web or native. Lets Stanza reject obviously-wrong installs (e.g. installing Next.js into a kind: "native" app).

stanza init scaffolds a single web app today ({ id: "web", dir: "apps/web", kind: "web" }). Multi-app projects — a web app and an Expo native app sharing a packages/db/, for example — are on the roadmap, and the schema is already shaped for them. App-home module records carry an apps: ["<id>"] field so add/remove know which app to target; package-home modules ship their core code once and route shims into the apps that consume them.

Categories

Every module belongs to exactly one category. A category has two independent properties that govern how its modules behave:

  • Cardinality — one (single-choice) or many (coexisting).
  • Home — where a module's output lands: app, repo, or package.
Category Cardinality Home
framework one app
ui one package
db one package
orm one package
auth one package
tooling one repo
testing many app

A one category holds at most one module — adding a second fails until you remove the first. For app-home categories that limit is per app: a project with two apps could pick next for the web app and expo for the native one. A many category lets modules coexist (e.g. Vitest and Playwright side by side).

Home decides placement:

  • app modules wire a specific app's shell (in apps/web/, say). They install into the app(s) named in the module record's apps field.
  • repo modules write config at the monorepo root.
  • package modules install into their own internal workspace package under packages/<dir>/, named @<your-app>/<dir>, which every consuming app depends on via workspace:*. db and orm share a single packages/db/ so the ORM client sits next to the schema it queries.
**`framework` is one category, not many.** A module's `appKind` (`web` or `native`) pins it to a platform — Next is `appKind: "web"`, a future Expo module would be `appKind: "native"`. Splitting the category per platform would force every peer-aware category (ui, testing, …) to split too, which would be a worse design. See the [registry](/docs/registry) for the roadmap. **Planned:** more categories are on the roadmap — `api`, `ai`, `payments`, `email` (single-choice) and `deploy` (coexisting). They aren't shipping yet, but the taxonomy is designed to absorb them without breaking existing projects. See the [module registry](/docs/registry) for the full roadmap.

Vendoring

Stanza copies a module's code into your repo verbatim. There's no shared runtime package — the generated files are ordinary source you can read, edit, and commit. That means upgrades never silently change your app's behavior, and you're never locked into Stanza's abstractions.

Peers and adapters

Modules declare peers — the other categories they care about. When you add a module, Stanza reads your current selections and picks the adapter that matches. Better Auth, for instance, peers on both framework and orm, so adding it to a Next + Drizzle app produces different wiring than a TanStack Start + Prisma app. You pick the module; Stanza picks the right variant.

Peers are scoped to the app being targeted: in a multi-app project, the ui module's adapters peer-match against the active app's framework. That's how the same Tailwind module can ship a postcss.config.mjs for a Next.js web app while a future NativeWind module ships tailwind.config.js for an Expo native app.

The manifest (stanza.json)

The stanza.json file at your repo root is the source of truth for what's installed:

{
  "$schema": "https://stanza.tools/schema.json",
  "version": "0.3",
  "projectShape": "monorepo",
  "packageManager": "pnpm",
  "name": "acme",
  "apps": [{ "id": "web", "dir": "apps/web", "kind": "web" }],
  "modules": {
    "framework": [{ "id": "next", "version": "0.1.0", "adapter": "default", "apps": ["web"] }],
    "db": [{ "id": "postgres", "version": "0.1.0", "adapter": "default" }],
    "auth": [
      { "id": "better-auth", "version": "0.1.0", "adapter": "next+drizzle", "apps": ["web"] }
    ],
    "tooling": [{ "id": "oxlint-oxfmt", "version": "0.1.0", "adapter": "default" }]
  },
  "regions": {
    /* … */
  }
}

add, remove, and list all read it. Treat it as generated state: don't hand-edit it unless you're repairing something deliberately.

A module record's apps field tells Stanza which app(s) the install targets:

  • Required for home: "app" modules (framework, testing). One record per app.
  • Optional for home: "package" modules. Omitted means "ship app-scoped shims into every app"; an explicit list restricts to those apps. The package itself is always written once under packages/<dir>/.
  • Forbidden for home: "repo" modules — they're project-wide.

Regions

A region is a claim on a file (or a section of one) by a specific module. This is how stanza remove knows exactly what to delete and what to leave alone: it sweeps only the regions the removed module owns. Because regions key on the module, two modules can safely write disjoint parts of the same file — Vitest owning the test script and Playwright owning test:e2e, for example, without conflict. Per-app writes live at distinct paths (apps/web/page.tsx vs apps/native/app/(tabs)/index.tsx), so they never collide either.

Open registry

A registry is just static JSON: an index plus one file per module, each carrying its templates, dependencies, env vars, and codemod invocations. The CLI ships with a default registry, but you can point it anywhere — a URL or a local path — with STANZA_REGISTRY. That's the hook for self-hosting your own modules or pinning a fixture in CI.