DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

How Senior Devs Start a Full-Stack Project in 2026

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 "@/*"
Enter fullscreen mode Exit fullscreen mode

Enable strict TypeScript immediately

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "exactOptionalPropertyTypes": true
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Step 4 — Type-safe environment variables

npm install @t3-oss/env-nextjs zod
Enter fullscreen mode Exit fullscreen mode
// 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,
  },
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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
})
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)