Most tutorials show you how to build a to-do app. This guide shows you how a senior developer starts a real project — the decisions before writing a single line of business logic, the tools they pick, and why.
The mindset shift
Juniors ask: "what framework should I use?"
Seniors ask: "what problems am I solving, and what's the minimum complexity that solves them?"
The 2026 stack
| Layer | Choice | Why |
|---|---|---|
| Framework | Next.js 15 (App Router) | SSR + RSC + API routes in one |
| Language | TypeScript (strict) | Catches bugs before runtime |
| Styling | Tailwind CSS v4 | Fastest UI iteration |
| Components | shadcn/ui | Copy-paste, you own the code |
| Database | PostgreSQL via Neon | Serverless, free tier, branching |
| ORM | Drizzle | Type-safe, SQL-like, fast |
| Auth | Clerk | Best DX for most projects |
| Validation | Zod | Runtime + compile-time safety |
| Forms | React Hook Form + Zod | Performance + validation |
| Linting/Format | Biome | ESLint + Prettier in one, 100x faster |
| Env vars | t3-env | Type-safe env variables |
| AI assistant | Claude Code | Best for full-stack tasks |
Step 1 — Initialize correctly
npx create-next-app@latest my-app \
--typescript \
--tailwind \
--eslint \
--app \
--src-dir \
--import-alias "@/*"
Enable strict TypeScript immediately
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"exactOptionalPropertyTypes": true
}
}
Enable strict mode before writing any code. Enabling it after 10,000 lines is a nightmare.
noUncheckedIndexedAccess forces you to handle array[0] being potentially undefined. This prevents an entire class of runtime bugs.
Step 2 — Replace ESLint + Prettier with Biome
Biome does what ESLint and Prettier do, in one tool, 100x faster.
npm install --save-dev @biomejs/biome
npx biome init
npm uninstall eslint eslint-config-next
rm .eslintrc.json
biome.json:
{
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
"organizeImports": { "enabled": true },
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"noUnusedVariables": "error",
"noUnusedImports": "error"
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"trailingCommas": "es5",
"semicolons": "asNeeded"
}
}
}
Step 3 — Folder structure that scales
src/
├── app/
│ ├── (auth)/ # Route group — auth pages
│ ├── (dashboard)/ # Route group — protected pages
│ └── api/
├── components/
│ ├── ui/ # shadcn/ui components
│ └── [feature]/ # Feature-specific components
├── lib/
│ ├── auth.ts
│ ├── db.ts
│ ├── env.ts
│ └── utils.ts
├── db/
│ ├── schema.ts
│ └── migrations/
├── hooks/
├── types/
└── actions/ # Server Actions
Step 4 — Type-safe environment variables
npm install @t3-oss/env-nextjs zod
// src/lib/env.ts
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
CLERK_SECRET_KEY: z.string().min(1),
NODE_ENV: z.enum(['development', 'test', 'production']),
},
client: {
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().min(1),
NEXT_PUBLIC_APP_URL: z.string().url(),
},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
},
})
If a required env var is missing, the build fails — not a silent runtime crash at 3 AM.
Step 5 — Database with Drizzle + Neon
npm install drizzle-orm @neondatabase/serverless @paralleldrive/cuid2
npm install --save-dev drizzle-kit
// src/db/schema.ts
import { pgTable, text, timestamp, boolean } from 'drizzle-orm/pg-core'
import { createId } from '@paralleldrive/cuid2'
export const users = pgTable('users', {
id: text('id').primaryKey().$defaultFn(() => createId()),
clerkId: text('clerk_id').unique().notNull(),
email: text('email').notNull(),
plan: text('plan').default('free').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().$onUpdate(() => new Date()).notNull(),
deletedAt: timestamp('deleted_at'), // soft delete — never hard delete users
})
Always use cuid2 for IDs, never auto-increment integers. cuid2 is URL-safe, doesn't expose your record count, and works across distributed systems.
Step 6 — Server Actions for mutations
Forget API routes for simple mutations.
// src/actions/projects.ts
'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { requireAuth } from '@/lib/auth'
import { db } from '@/lib/db'
import { projects } from '@/db/schema'
const schema = z.object({
name: z.string().min(1).max(50),
isPublic: z.boolean().default(false),
})
export async function createProject(input: unknown) {
const user = await requireAuth()
const parsed = schema.safeParse(input)
if (!parsed.success) return { error: parsed.error.errors[0]?.message }
const [project] = await db
.insert(projects)
.values({ ...parsed.data, ownerId: user.id })
.returning()
revalidatePath('/dashboard')
return { data: project }
}
Always validate in Server Actions — they're public endpoints anyone can call.
Step 7 — Using Claude Code the right way
npm install -g @anthropic-ai/claude-code
claude
Works great:
- "Add a description column to the projects table and generate the migration"
- "Write a Server Action that deletes a project, checking the user owns it"
- "Add rate limiting to /api/generate using Upstash"
Creates problems:
- "Build me a complete project management app" (too vague)
- Copying generated code without reading it
Create a CLAUDE.md at the root:
# Project CLAUDE.md
## Conventions
- Use cuid2 for all IDs, never auto-increment
- Soft-delete users (deletedAt), never hard delete
- Server Actions for mutations, not API routes
- Import env from @/lib/env, never process.env
- requireAuth() at the top of every protected action
- Return { data } or { error }, never throw
Claude Code reads this file and follows your conventions automatically.
What seniors skip (and you should too)
- No unit tests at the start — your schema changes constantly. Test when stable.
- No Redux/Zustand at the start — React state + Server Components handle 90% of cases.
- No microservices — start with a monolith, split when you have a real reason.
- No Docker in development — run the dev server directly.
- No CI/CD on day one — Vercel auto-deploys. Add GitHub Actions when you need custom steps.
Common junior mistakes
| Mistake | Fix |
|---|---|
process.env.X everywhere |
t3-env — fails at build time if missing |
| Auto-increment integer IDs | cuid2 — URL-safe, no sequential exposure |
| Hard-delete users |
deletedAt soft-delete |
any type everywhere |
Strict TS — fix errors as you go |
| Deploy first, migrate after | Migrate first, always |
Full guide with Drizzle setup, Clerk auth config, and the complete 30-minute checklist:
https://stacknotice.com/blog/fullstack-project-setup-senior-2026
Top comments (0)