S
Claude Code logoClaude CodeFull-stack TypeScript

Alex Tern's Full-Stack Claude Rig

Alex Tern@alextern
96.0Overall score

A battle-tested Claude Code setup for shipping a full-stack TypeScript app solo: an Opus planner and reviewer wrap a Sonnet implementer, Postgres and Playwright MCP let it verify both data and UI, and four hooks keep autonomous sessions safe and green. Documented end to end in a deep walkthrough so you can copy the whole thing.

96.0Score
2.5kVotes
6Components
3hUpdated

Install this build

Export
terminal
npx setuproll add alextern-fullstack-claude-rig

Components

Model

  • Claude Opus 4.8 (plan + review)
  • Claude Sonnet 4.6 (implement)

MCP servers

  • github
  • filesystem
  • postgres
  • playwright
  • sentry
  • context7

Subagents

  • planner
  • implementer
  • reviewer
  • tester
  • db-migrator

Hooks

  • PreToolUse: secret-scan (block on match)
  • PostToolUse: prettier + eslint --fix
  • PostToolUse: typecheck changed files
  • Stop: run affected tests

Rules

  • Plan before editing more than 3 files
  • Never commit with failing tests or types
  • Reuse existing utils before adding a dependency
  • One reviewable commit per logical change
  • Read the relevant code before proposing an edit

Slash commands

  • /plan
  • /ship
  • /review-pr
  • /migrate
Setup

How I built my Claude Code setup and how I actually work with it

A year of tuning one Claude Code rig for a full-stack TypeScript SaaS: the exact CLAUDE.md, settings.json, .mcp.json, subagents, hooks and slash commands, plus the day-to-day loop I run them through. Steal the whole thing.

Alex Tern16 min read2026-06-19

I ship a full-stack TypeScript SaaS on my own. For the first few months with Claude Code I treated it like a faster autocomplete: open the terminal, describe a feature, accept the diff, fix whatever it broke. It was quicker than typing, but only a little, because I spent the saved time cleaning up hallucinated imports, untested handlers and edits that quietly violated my own conventions.

The thing that changed everything was realizing the model was never the bottleneck. The bottleneck was context and guardrails. Claude does not know my commands, my architecture rules, or which files matter, unless I tell it once and let it read that every turn. It does not check its own work unless I wire something to make it. Once I built that scaffolding, the same model went from a clever intern to something I can hand a whole feature and mostly watch.

This is the entire setup, copy-paste honest. Every config below is what actually sits in my repo. I will walk through the project layout, the CLAUDE.md, the settings, the MCP servers, the subagents, the hooks, the slash commands, and then the loop I run them through on a normal day.

What you need to follow along
Claude Code installed and authenticated, a repo you control, and ten minutes. Nothing here is paid or private. The MCP servers are public packages, and the hooks are plain shell scripts.

The project and where the config lives

The app is a pnpm monorepo: a Next.js 16 web app, a Fastify API, a Drizzle/Postgres package, and a shared UI package. Claude Code configuration is not scattered around. It all lives under one .claude directory plus two files at the repo root, so the whole setup is version-controlled and travels with the project.

project layout (config files only)
orbit/
  CLAUDE.md                # project memory, read on every session
  .mcp.json                # MCP servers, shared with the team
  .claude/
    settings.json          # permissions, env, hooks (committed)
    settings.local.json    # my personal overrides (gitignored)
    agents/
      planner.md
      implementer.md
      reviewer.md
      tester.md
      db-migrator.md
    commands/
      plan.md
      ship.md
      review-pr.md
      migrate.md
    hooks/
      secret-scan.sh
      format.sh
      typecheck-changed.sh
  apps/web   apps/api   packages/db   packages/ui

The single highest-leverage file is CLAUDE.md. Claude Code loads it into context automatically at the start of every session, so it is the cheapest possible way to make every response better. Mine is deliberately short and specific. It tells Claude the real commands (so it stops inventing npm test when the repo uses pnpm test), the architecture boundaries it must not cross, and the workflow I expect. I do not put essays in here; I put the things it would otherwise get wrong.

CLAUDE.md
# Project: Orbit (full-stack TypeScript SaaS)

## What this is
Orbit is a Next.js 16 (App Router) front end, a Fastify API, and Postgres
via Drizzle. Monorepo with pnpm workspaces: apps/web, apps/api, packages/db,
packages/ui. Deploys to Fly.io. Solo-maintained.

## Commands you should use (not invent)
- Install: `pnpm install`
- Dev (all): `pnpm dev`  | web only: `pnpm --filter web dev`
- Typecheck: `pnpm typecheck`  (runs tsc -b across the workspace)
- Lint/format: `pnpm lint` and `pnpm format`
- Unit tests: `pnpm test`  | one file: `pnpm test path/to/file.test.ts`
- E2E: `pnpm e2e` (Playwright)  | migrations: `pnpm db:migrate`

## Architecture rules
- The web app NEVER imports from apps/api. Shared types live in packages/db.
- All DB access goes through packages/db (Drizzle). No raw SQL in handlers.
- API routes validate input with zod at the boundary, then call a service.
- React: server components by default. "use client" only when it needs state.

## Conventions
- TypeScript strict. No `any`, no `as` casts to silence the compiler.
- Reuse helpers in packages/ui and packages/db before adding a dependency.
- Errors: throw typed AppError, never bare strings. Log via the logger, not console.
- Tests live next to the file as *.test.ts. Every new service needs one.

## Workflow expectations
- Read the files you are about to change before proposing edits.
- For anything touching more than ~3 files, write a short plan first and
  wait for me to approve it.
- Keep commits small and reviewable: one logical change each.
- Never commit if `pnpm typecheck` or `pnpm test` is failing.

## Gotchas
- Drizzle migrations are generated, never hand-edited. Use `pnpm db:generate`.
- The Sentry DSN and DATABASE_URL come from env, never hardcode them.
- Fly secrets are managed separately; do not touch fly.toml without asking.
Keep CLAUDE.md lean
Every line here is read on every turn, so bloat costs tokens and dilutes attention. I prune it monthly. If Claude keeps making the same mistake, that is a signal to add one precise line, not a paragraph. If a line never seems to change behavior, I delete it.

settings.json: permissions, env and hooks

The .claude/settings.json file is what makes long, mostly-autonomous sessions safe to leave running. It has three jobs: decide which tool calls run without asking me (permissions), set a bit of environment, and register hooks. The permission rules use matcher strings like Bash(pnpm test:*) and Edit(apps/**), so I can pre-approve the safe, repetitive things and force a prompt only on the genuinely risky ones like pushing or migrating.

.claude/settings.json
{
  "permissions": {
    "defaultMode": "acceptEdits",
    "allow": [
      "Read(**)",
      "Edit(apps/**)",
      "Edit(packages/**)",
      "Bash(pnpm test:*)",
      "Bash(pnpm typecheck)",
      "Bash(pnpm lint)",
      "Bash(pnpm db:generate)",
      "Bash(git status)",
      "Bash(git diff:*)"
    ],
    "ask": [
      "Bash(git push:*)",
      "Bash(pnpm db:migrate)"
    ],
    "deny": [
      "Read(.env)",
      "Read(.env.*)",
      "Bash(rm -rf:*)",
      "Edit(fly.toml)"
    ]
  },
  "env": {
    "NODE_ENV": "development",
    "BASH_DEFAULT_TIMEOUT_MS": "120000"
  },
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": ".claude/hooks/secret-scan.sh" }]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/format.sh" },
          { "type": "command", "command": ".claude/hooks/typecheck-changed.sh" }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [{ "type": "command", "command": "pnpm test --silent --changed" }]
      }
    ]
  }
}
  • allow covers everything I never want to be interrupted for: reads, edits inside the app and package folders, and the read-only or idempotent commands (test, typecheck, lint).
  • ask is the small set of actions with real consequences. Pushing to git and running a real migration always stop and wait for me.
  • deny is a hard wall. It can never read the env files, never run a recursive delete, and never touch the deploy config. Deny wins over allow.
defaultMode is a knob, not a default
I run with acceptEdits so file edits apply without a prompt, but bash and the ask/deny lists still gate the dangerous parts. I do not use a fully bypassing mode on this repo; the few seconds saved are not worth an unsupervised git push.

.mcp.json: giving Claude real tools

MCP (the Model Context Protocol) is how Claude Code reaches outside the text in your repo. Without it, the model can read and edit files and run shell commands. With it, it can query my actual database schema, drive a real browser, read GitHub issues, pull live Sentry errors, and fetch current library docs. This is the difference between a model guessing what a table looks like and one that ran the query.

.mcp.json
{
  "mcpServers": {
    "filesystem": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "."]
    },
    "github": {
      "type": "http",
      "url": "https://api.githubcopilot.com/mcp/"
    },
    "postgres": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-postgres"],
      "env": { "DATABASE_URL": "${DATABASE_URL}" }
    },
    "playwright": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "@playwright/mcp@latest", "--browser", "chromium"]
    },
    "sentry": {
      "type": "http",
      "url": "https://mcp.sentry.dev/mcp"
    },
    "context7": {
      "type": "http",
      "url": "https://mcp.context7.com/mcp"
    }
  }
}
  • filesystem and github are the baseline: scoped file access and read/write to issues and pull requests so I can say "start on the bug in issue 214".
  • postgres lets the model inspect the live schema and run read queries before it writes a handler. It stops a whole class of "that column does not exist" errors.
  • playwright gives it a browser. After a UI change it can actually click through the flow and confirm the thing renders, instead of declaring victory blind.
  • sentry and context7 round it out: real production errors to triage, and up-to-date docs so it stops citing an API that changed two versions ago.
Add MCP servers one at a time
Every server adds tool definitions to the context window. I added these gradually and dropped the ones I never used. Six is about my ceiling. If a server's tools never get called over a week, it is just tax.
punkpeye/awesome-mcp-serversThe curated list I pull new MCP servers from. Production-ready and experimental servers for files, databases, browsers and APIs.github.com60k+The Best MCP Servers for Developers in 2026Builder.io's roundup with setup notes for GitHub, Context7, Figma, Linear and more. Good for deciding what is worth the context cost.builder.io
Claude Code Best Practices - The Ultimate Guide26:11
Claude Code Best Practices - The Ultimate Guide· IndyDevDan

Subagents: a small team instead of one overloaded model

A subagent is a separate Claude with its own system prompt, its own tool allowlist, and crucially its own context window. They live as Markdown files in .claude/agents/ with YAML frontmatter. The point is not to be fancy; it is to keep the main conversation clean. When my reviewer reads twenty files to check a diff, all that noise stays in the reviewer's context, and only its short verdict comes back to me. The main thread does not get buried.

  • planner (Opus): reads the relevant code and writes a step-by-step plan with the files it will touch. No edits. I approve or correct the plan first.
  • implementer (Sonnet): executes one approved plan step at a time. Fast and cheap, because the hard thinking already happened in planning and review.
  • reviewer (Opus): reads the diff against the task and the rules, runs typecheck and affected tests, and reports concrete fixes. It never rewrites code itself.
  • tester (Sonnet): writes and runs tests for new code, and chases down failures, reporting only the failing specs back to me.
  • db-migrator (Opus): the only agent allowed near migrations. It generates a Drizzle migration, dry-runs it, and shows the SQL diff before anything applies.

Here is the actual reviewer definition. The frontmatter restricts its tools and pins it to Opus; the body is its system prompt.

.claude/agents/reviewer.md
---
name: reviewer
description: >
  Reviews a diff for correctness bugs, missing tests and rule violations.
  Use after the implementer finishes a change, before it is committed.
tools: Read, Grep, Glob, Bash
model: opus
---

You are a senior reviewer for the Orbit codebase. You receive a diff and the
task it was meant to accomplish. Your job is to find problems, not to praise.

Check, in priority order:
1. Correctness: does the code do what the task asked? Trace the data flow.
2. Rule compliance (see CLAUDE.md): no raw SQL in handlers, zod at the
   boundary, no `any`, server components by default.
3. Tests: does every new service/handler have a test? Do they actually
   assert behavior rather than just run?
4. Blast radius: any change that crosses a package boundary it should not.

Run `pnpm typecheck` and the affected tests yourself before judging.
Report findings as a short list: file:line, severity (block/warn/nit),
and the concrete fix. If it is clean, say so in one line. Do not rewrite
the code; hand the fixes back to the implementer.
Match the model to the job
Planning and reviewing are reasoning-heavy, so they get Opus. Implementing an already-approved plan is mostly mechanical, so it gets Sonnet. This split is where most of my cost savings come from without losing quality on the decisions that matter.
Create custom subagents - Claude Code DocsThe official reference for the agent file format: frontmatter fields, tool allowlists and isolated context windows.code.claude.com
How to Build Claude Subagents Better Than 99% of People22:40
How to Build Claude Subagents Better Than 99% of People· IndyDevDan

Hooks: automation that does not depend on the model remembering

Rules in CLAUDE.md are suggestions the model usually follows. Hooks are shell commands the harness runs deterministically at specific lifecycle events, whether or not the model feels like it. That distinction is everything for safety. I never want formatting or a secret scan to be optional, so they are hooks, not rules.

  • PreToolUse on Bash runs a secret scanner that blocks any command that looks like it leaks a key or reads the env file. It can hard-stop the tool call before it ever runs.
  • PostToolUse on edits runs prettier plus eslint --fix and then typechecks the changed files, so the model is always working against formatted, type-correct code.
  • Stop runs the affected test suite when a turn ends, so a session never quietly finishes in a red state.

The hook reads the tool call as JSON on stdin and signals a block by exiting with code 2; its stderr is fed back to the model so it knows why it was stopped. Here is the real secret-scan hook.

.claude/hooks/secret-scan.sh
#!/usr/bin/env bash
# .claude/hooks/secret-scan.sh
# PreToolUse hook on Bash. Reads the tool call as JSON on stdin.
# Exit 2 => block the command and show stderr back to the model.
set -euo pipefail

INPUT=$(cat)
COMMAND=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty')

# Block obvious secret leaks and destructive resets.
if printf '%s' "$COMMAND" | grep -qiE '(api[_-]?key|secret|password|BEGIN [A-Z]+ PRIVATE KEY)'; then
  echo "secret-scan: refusing command that looks like it handles a secret" >&2
  exit 2
fi

# Never let it print the env file.
if printf '%s' "$COMMAND" | grep -qE 'cat[[:space:]]+.*\.env'; then
  echo "secret-scan: reading .env is denied" >&2
  exit 2
fi

exit 0
Exit code 2 blocks, anything else does not
This is the part people get wrong. A PreToolUse hook only blocks the action on exit code 2. Exit 0 lets it proceed; any other non-zero is treated as a non-blocking error and the session continues. Test your blocking hooks before you trust them.
disler/claude-code-hooks-masteryReference repo covering every hook lifecycle event with prompt-level control and security examples. Where I learned the exit-code contract.github.com3k+
Claude Code Hooks explained in 5 minutes5:42
Claude Code Hooks explained in 5 minutes· IndyDevDan

Slash commands: my repeated prompts, saved

Anything I type more than twice becomes a slash command. They are Markdown files in .claude/commands/, and the body is just a prompt with placeholders: $ARGUMENTS for the whole argument string, $1 and friends for positional ones. The frontmatter can pin an allowed-tools list so the command only runs what it needs.

  • /plan hands the task to the planner subagent and waits for my approval.
  • /ship runs the full green-gate (typecheck, lint, changed tests) then stages and commits.
  • /review-pr pulls a PR via the github MCP and runs the reviewer over its diff.
  • /migrate routes to the db-migrator subagent for a safe, dry-run-first schema change.

This is /ship. It is the command I run dozens of times a day, and it is why I have not committed a broken build in months.

.claude/commands/ship.md
---
description: Run the full green-gate, then stage and commit the change
argument-hint: "[commit message]"
allowed-tools: Bash(pnpm typecheck), Bash(pnpm lint), Bash(pnpm test:*), Bash(git add:*), Bash(git commit:*)
---

You are finishing a unit of work. Do this in order and stop at the first failure:

1. Run `pnpm typecheck`. If it fails, fix the types and rerun.
2. Run `pnpm lint`. Fix lint errors (not warnings) and rerun.
3. Run `pnpm test --changed`. All must pass.
4. Show me `git diff --stat`, then stage the relevant files and commit
   with this message: $ARGUMENTS

Never use `git add -A`. Only stage files you actually changed for this task.

How I actually work with it, step by step

All of that config exists to support one loop, and it is the same loop Anthropic recommends: explore, plan, code, commit. The trick is that each phase has the right tool and the right model behind it, so I am never asking one overloaded context to do all four at once.

1. Explore and plan (read-only)

I start every non-trivial task with /plan. The planner reads the relevant files and the issue, then comes back with a numbered plan and the exact files it intends to touch. I almost always tweak it. Catching a wrong assumption here, before a single line is written, is worth more than any amount of fast coding later.

claude · orbit (planning)
$/plan add a soft-delete flag to projects and exclude them everywhere
→ planner subagent (opus), read-only
Read packages/db/schema/projects.ts, apps/api/projects/*, apps/web/projects/*
Plan:
1. Add deletedAt timestamp column to projects (packages/db)
2. Generate migration with pnpm db:generate (NOT applied yet)
3. Add notDeleted() helper + filter in the projects service
4. Update list/detail handlers to use it; soft-delete in DELETE route
5. Tests: deleted projects are hidden from list + return 404 on detail
Touches 6 files across packages/db and apps/api. Approve?
$approve, but also hide them from the web sidebar count
Updated plan: +1 step for apps/web/sidebar count query. Ready.
$

2. Implement one step at a time

Once the plan is approved, the implementer works through it. Because the plan already decided the hard parts, this is the cheap, fast phase on Sonnet. The PostToolUse hooks format and typecheck each edit as it lands, so I see clean diffs, not a pile of unformatted code I have to mentally compile.

claude · orbit
You
go ahead with the approved plan
Agent
Step 1: added deletedAt to projects schema. Step 2: ran pnpm db:generate, migration 0042_soft_delete created (not applied).
Agent
Step 3: added notDeleted() to packages/db and filtered the projects service. [PostToolUse] prettier + eslint --fix applied. [PostToolUse] typecheck: clean.
Agent
Step 4: DELETE /projects/:id now sets deletedAt instead of removing the row; list + detail use notDeleted(). Ready for tests.
The implementer applying step 3, with the format + typecheck hooks firing after each edit.

3. Test and review before commit

I do not let it commit on its own judgment. The tester writes the specs the plan called for, and then the reviewer reads the whole diff against the task and the rules. The reviewer runs typecheck and the affected tests itself, so its verdict is grounded, not vibes. Only when it comes back clean do I run /ship.

claude · orbit (review)
$/review-pr current diff
→ reviewer subagent (opus): reads diff, runs typecheck + affected tests
pnpm typecheck … clean
pnpm test --changed … 14 passed
Findings:
block apps/api/projects/list.ts:31 count query still includes deleted rows
warn packages/db/projects.ts:18 notDeleted() not exported from index
nit test name 'works' is vague
$fix the block + warn, leave the nit
implementer: count query now uses notDeleted(); helper exported. Re-review clean.
$
claude · orbit (ship)
$/ship feat(projects): soft-delete with global exclusion
pnpm typecheck … clean
pnpm lint … clean
pnpm test --changed … 15 passed
git diff --stat: 8 files changed, 142 insertions(+), 11 deletions(-)
Staged 8 files (no -A). Committed: feat(projects): soft-delete with global exclusion
[Stop hook] pnpm test --changed: green
$
PR #218 · Soft-delete projects
✓ feat(projects): soft-delete with global exclusion
✓ 8 files changed · 15 tests passing · types clean
⏸ pnpm db:migrate waiting for approval (ask permission)
→ db-migrator: dry-run shows 1 ALTER TABLE, 0 data loss. Apply? (y/N)
The migration runs through /migrate (dry-run first, then ask-gated apply), never automatically.

Was it worth it? Before and after

I tracked a month of feature work before this setup and a comparable month after. The headline is not raw speed; it is that far less of what the model produced needed me to fix it, which is what actually saves time.

MetricAd-hoc (model only)This build
Median feature, idea to merged PR~3.5 hrs~1.4 hrs
Diffs needing manual rework~55%~12%
Broken builds committed per week3 to 40
Avg model cost per feature$0.90 (all Opus)$0.48 (split)
Tests written without me askingrarelyevery service

The setup did not make the model smarter. It made the model's output trustworthy enough that I stopped re-reading every line, and that is the whole win.

my own retro notes, month two

Steal this setup

Everything above is in this build. Adding it drops the CLAUDE.md, settings.json, .mcp.json, the five subagents, the three hooks and the four slash commands into your repo, where you can trim them to your stack. Start by editing CLAUDE.md to your real commands and architecture, then keep the hooks; those two changes alone get you most of the value.

zsh · your repo
$npx setuproll add alextern-fullstack-claude-rig
✓ wrote CLAUDE.md, .mcp.json, .claude/settings.json
✓ wrote 5 subagents, 3 hooks, 4 slash commands
next: edit CLAUDE.md with your real commands, then run `claude` and try /plan
$
Do not copy it whole and walk away
This rig fits a pnpm + Next.js + Postgres monorepo. The shape transfers; the specifics do not. Swap the commands, drop MCP servers you will not use, and add one rule each time the model repeats a mistake. A setup you tuned beats a setup you inherited.
Claude Code Best PracticesAnthropic's canonical post on the explore/plan/code/commit loop, CLAUDE.md tuning and multi-agent patterns. The backbone of this whole approach.anthropic.comhesreallyhim/awesome-claude-codeThe curated list of skills, hooks, slash commands and subagents to raid for more ideas once the basics are in place.github.com23k+

4 Reviews

Your rating
Sign in to post
Mara Vist@maravist·2h ago

Copied this into a Turborepo and trimmed two MCP servers. The planner then reviewer loop alone cut my rework in half. The secret scan hook caught a real leak on day one.

Alex Tern@alextern·1h ago

Glad the hook earned its keep. If you dropped the postgres server, swap the db-migrator agent for a plain migration command so /migrate still works.

Theo Kerns@theok·6h ago

Solid writeup. One note: exit code 2 for blocking hooks tripped me up until I reread the callout. Worth saying twice.

Priya N@priyacodes·1d ago

The model split for cost is the part I underrated. Moved implement onto Sonnet and my monthly spend dropped without any quality hit I could measure.