An opinionated CLI that scaffolds a TanStack + Drizzle pnpm monorepo. Composable, idempotent.
pnpm dlx create-four-appprimary action: run command
or: npx create-four-app
02
choices
declared stack
Drizzle, not Prisma.
Type-safe SQL with no runtime client. Migrations are plain TypeScript files; the schema is the source of truth.
choice 01
Oxlint, not ESLint.
Same rules, written in Rust. About 100× faster on cold starts, no plugin tax, no config bikeshedding.
choice 02
Vitest browser mode, not Jest + jsdom.
Tests run in real browsers via Playwright. No emulated DOM, no surprises between CI and production.
choice 03
TanStack Start, not Next.js.
Isomorphic loaders, type-safe routing, readable SSR. No framework runtime owns your code.
choice 04
pnpm workspaces, not npm or yarn.
Strict isolation, fast installs, content-addressable store. Designed for monorepos from day one.
choice 05
03
configure
compose modules
project-name
kebab-case, no spaces
framework
core modules
db
worker
requires db
auth providers
requires db
ops modules
config.json
{
"projectName": "my-app",
"framework": "tanstack-start",
"modules": {}
}command
create-four-app my-app --non-interactive --json-config config.json04
modules
table
Eleven modules. Each is file-additive, never edits arbitrary user code. Click any row to see what an installer drops in.
files added:
- apps/web/src/styles/globals.css
- apps/web/vite.config.ts (plugin registration)
files added:
- apps/web/src/components/ui/button.tsx
- apps/web/src/components/ui/input.tsx
- apps/web/src/lib/utils.ts
files added:
- apps/web/src/i18n/index.ts
- apps/web/src/i18n/locales/en.json
- apps/web/src/providers/fragments/i18n.tsx
files added:
- apps/web/src/stores/counter.ts
- apps/web/src/stores/index.ts
files added:
- apps/web/package.json (dependency additions)
- apps/web/src/providers/fragments/query.tsx
files added:
- packages/db/package.json
- packages/db/src/client.ts
- packages/db/src/schema/index.ts
- packages/db/drizzle.config.ts
- packages/config/env/fragments/db.ts
files added:
- apps/worker/package.json
- apps/worker/src/index.ts
- apps/worker/src/jobs/example.ts
files added:
- apps/web/src/server/auth/index.ts
- apps/web/src/server/auth/providers/email-password.ts
- packages/db/src/schema/auth.ts
- apps/web/src/providers/fragments/auth.tsx
files added:
- Dockerfile
- docker-compose.yml
- .dockerignore
files added:
- wrangler.jsonc
- apps/web/src/server/hyperdrive.ts
- packages/config/env/fragments/wrangler.ts
files added:
- .github/workflows/ci.yml
05
seams
composition
Installers never edit your code. They drop files into glob patterns the runtime reads at startup. Layer modules in any order; the seams resolve.
packages/db/src/schema/**/*.ts- Drizzle schema files. New auth tables, app tables, anything land here.
packages/config/env/fragments/*.ts- Zod env fragments. Each module drops one; the root config composes them.
apps/web/src/server/auth/providers/*.ts- better-auth providers. Add a file, the runtime picks it up.
apps/web/src/providers/fragments/*.tsx- React context providers, ordered. Lower order = outer wrapper.
06
tree
generated structure
What lands on disk after running every module against a fresh project. Real output, not a sketch.
my-app/
├── apps/
│ ├── web/
│ │ ├── src/
│ │ │ ├── components/ui/
│ │ │ │ ├── button.tsx
│ │ │ │ └── input.tsx
│ │ │ ├── i18n/
│ │ │ │ ├── locales/en.json
│ │ │ │ └── index.ts
│ │ │ ├── providers/
│ │ │ │ ├── fragments/
│ │ │ │ │ ├── auth.tsx
│ │ │ │ │ ├── i18n.tsx
│ │ │ │ │ ├── query.tsx
│ │ │ │ │ └── zustand.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── routes/
│ │ │ │ ├── __root.tsx
│ │ │ │ └── index.tsx
│ │ │ ├── server/
│ │ │ │ ├── auth/
│ │ │ │ │ ├── providers/
│ │ │ │ │ │ ├── email-password.ts
│ │ │ │ │ │ └── github.ts
│ │ │ │ │ └── index.ts
│ │ │ │ └── hyperdrive.ts
│ │ │ ├── stores/counter.ts
│ │ │ └── styles/globals.css
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ ├── vite.config.ts
│ │ └── vitest.config.ts
│ └── worker/
│ ├── src/
│ │ ├── jobs/example.ts
│ │ └── index.ts
│ └── package.json
├── packages/
│ ├── config/
│ │ ├── env/
│ │ │ ├── fragments/
│ │ │ │ ├── db.ts
│ │ │ │ └── wrangler.ts
│ │ │ └── index.ts
│ │ └── tsconfig.base.json
│ └── db/
│ ├── src/
│ │ ├── client.ts
│ │ └── schema/
│ │ ├── auth.ts
│ │ └── index.ts
│ ├── drizzle.config.ts
│ └── package.json
├── .github/workflows/ci.yml
├── .four.json
├── Dockerfile
├── docker-compose.yml
├── package.json
├── pnpm-workspace.yaml
└── wrangler.jsonc07
add
subcommand
Every module is also reachable through add. The CLI walks up five directory levels to find .four.json, so you can layer modules from anywhere inside the project.
Same installer code as init. Same prompts when a module has options. No hidden state.
cd my-app
create-four-app add tailwind
create-four-app add db # select dialect in prompt
create-four-app add auth # select providers in prompt08
idempotency
contract
init(all modules) === init(base) + add(each module)
The order in which modules are added does not change the resulting tree. Verified by the test suite on every commit.