Tutorials
|stacknotice.com
20 min left|
0%
|4,000 words
Tutorials

How Senior Devs Start a Full-Stack Project in 2026

The exact stack, folder structure, tooling, and decisions seniors use to start a full-stack project in 2026 — and what juniors get wrong from day one.

May 26, 202620 min read
Share:
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 they make before writing a single line of business logic, the tools they pick, and why.

If you follow this guide, you'll have a full-stack Next.js project that's ready to scale, easy to maintain, and set up the way teams at real companies do it.


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 decisions in this guide are opinionated and intentional. Every choice has a reason. When you understand the reasons, you can adapt the stack to your own constraints.


The 2026 stack

LayerChoiceWhy
FrameworkNext.js 15 (App Router)SSR + RSC + API routes in one
LanguageTypeScript (strict)Catches bugs before runtime
StylingTailwind CSS v4Fastest UI iteration
Componentsshadcn/uiCopy-paste, you own the code
DatabasePostgreSQL via NeonServerless, free tier, branching
ORMDrizzleType-safe, SQL-like, fast
AuthClerkBest DX for most projects
ValidationZodRuntime + compile-time safety
FormsReact Hook Form + ZodPerformance + validation
Linting/FormatBiomeESLint + Prettier in one, 100x faster
Env varst3-envType-safe env variables
AI assistantClaude CodeBest for full-stack tasks

This isn't the only valid stack. It's the one with the best tradeoffs for a project that needs to ship fast and scale later.


Step 1 — Initialize the project correctly

npx create-next-app@latest my-app \
  --typescript \
  --tailwind \
  --eslint \
  --app \
  --src-dir \
  --import-alias "@/*"
 
cd my-app

The --src-dir flag puts everything in src/ — this keeps the root clean and separates app code from config files.

Enable strict TypeScript immediately

Open tsconfig.json and make sure these are set:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "exactOptionalPropertyTypes": true
  }
}

noUncheckedIndexedAccess is the one most people miss. It forces you to handle array[0] being potentially undefined. This prevents an entire class of runtime bugs.

Enable strict mode before writing any code

Enabling strict TypeScript after you have 10,000 lines of code is a nightmare. Do it on day one — it costs nothing upfront and saves hours of debugging later.


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

Remove ESLint (it came with create-next-app):

npm uninstall eslint eslint-config-next
rm .eslintrc.json

Configure 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"
    }
  }
}

Add scripts to package.json:

{
  "scripts": {
    "lint": "biome lint ./src",
    "format": "biome format --write ./src",
    "check": "biome check --write ./src"
  }
}

Step 3 — Folder structure that scales

Most beginners use a flat structure that becomes unmanageable at 20+ files. Seniors use feature-based organization.

src/
├── app/                    # Next.js App Router
│   ├── (auth)/            # Route group — auth pages
│   │   ├── sign-in/
│   │   └── sign-up/
│   ├── (dashboard)/       # Route group — protected pages
│   │   ├── layout.tsx     # Protected layout with auth check
│   │   └── dashboard/
│   ├── api/               # API routes
│   │   ├── webhooks/
│   │   └── [...]/
│   ├── layout.tsx
│   └── page.tsx
├── components/
│   ├── ui/                # shadcn/ui components (auto-generated)
│   └── [feature]/         # Feature-specific components
├── lib/
│   ├── auth.ts            # Auth config and helpers
│   ├── db.ts              # Database client
│   ├── env.ts             # Type-safe env vars
│   └── utils.ts           # Shared utilities
├── db/
│   ├── schema.ts          # Drizzle schema (all tables)
│   └── migrations/        # Generated migration files
├── hooks/                 # Custom React hooks
├── types/                 # Shared TypeScript types
└── actions/               # Server Actions

Route groups with (parentheses) let you apply different layouts to different sections without affecting the URL. The auth group gets an unauthenticated layout, the dashboard group gets the sidebar layout — both live at the root URL level.


Step 4 — Type-safe environment variables

The biggest source of runtime errors in production: process.env.SOMETHING being undefined when you thought it was set.

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),
    CLERK_WEBHOOK_SECRET: z.string().min(1),
    STRIPE_SECRET_KEY: z.string().min(1).optional(),
    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,
    CLERK_WEBHOOK_SECRET: process.env.CLERK_WEBHOOK_SECRET,
    STRIPE_SECRET_KEY: process.env.STRIPE_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,
  },
})

Now import env instead of process.env everywhere:

// Before (unsafe — could be undefined at runtime)
const db = new Client({ connectionString: process.env.DATABASE_URL })
 
// After (validated at build time — fails fast if missing)
import { env } from '@/lib/env'
const db = new Client({ connectionString: env.DATABASE_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

Neon is serverless PostgreSQL with a generous free tier and database branching (like git branches for your DB).

npm install drizzle-orm @neondatabase/serverless
npm install --save-dev drizzle-kit
// src/lib/db.ts
import { neon } from '@neondatabase/serverless'
import { drizzle } from 'drizzle-orm/neon-http'
import * as schema from '@/db/schema'
import { env } from '@/lib/env'
 
const sql = neon(env.DATABASE_URL)
export const db = drizzle(sql, { schema })
// src/db/schema.ts
import { pgTable, text, timestamp, boolean } from 'drizzle-orm/pg-core'
import { createId } from '@paralleldrive/cuid2'
 
// Always use cuid2 for IDs — not auto-increment integers
// cuid2 is URL-safe, sortable, and collision-resistant
export const users = pgTable('users', {
  id: text('id').primaryKey().$defaultFn(() => createId()),
  clerkId: text('clerk_id').unique().notNull(),
  email: text('email').notNull(),
  name: text('name'),
  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
})
 
export const projects = pgTable('projects', {
  id: text('id').primaryKey().$defaultFn(() => createId()),
  name: text('name').notNull(),
  description: text('description'),
  ownerId: text('owner_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
  isPublic: boolean('is_public').default(false).notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().$onUpdate(() => new Date()).notNull(),
})
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit'
import { env } from './src/lib/env'
 
export default defineConfig({
  schema: './src/db/schema.ts',
  out: './src/db/migrations',
  dialect: 'postgresql',
  dbCredentials: { url: env.DATABASE_URL },
  strict: true,
  verbose: true,
})
npm install @paralleldrive/cuid2
npx drizzle-kit generate  # create migration files
npx drizzle-kit migrate   # apply to database
Never use drizzle-kit push in production

push applies changes directly without migration files. Use generate + migrate always — it gives you an auditable history and safe rollbacks.


Step 6 — Authentication with Clerk

npm install @clerk/nextjs
// src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
 
const isPublicRoute = createRouteMatcher([
  '/',
  '/sign-in(.*)',
  '/sign-up(.*)',
  '/api/webhooks(.*)',
  '/blog(.*)',
])
 
export default clerkMiddleware(async (auth, req) => {
  if (!isPublicRoute(req)) {
    await auth.protect()
  }
})
 
export const config = {
  matcher: ['/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jinja2|txt|xml|ico|webp|avif|jpg|jpeg|gif|svg|ttf|woff2?|mp4|mp3|ogg|pdf|zip|gz)).*)', '/(api|trpc)(.*)'],
}
// src/lib/auth.ts
import { auth, currentUser } from '@clerk/nextjs/server'
import { db } from '@/lib/db'
import { users } from '@/db/schema'
import { eq } from 'drizzle-orm'
 
export async function getAuthUser() {
  const { userId } = await auth()
  if (!userId) return null
 
  // Get from your DB, not Clerk — your DB has your business data
  const user = await db.query.users.findFirst({
    where: eq(users.clerkId, userId),
  })
 
  // Race condition: user in Clerk but not yet in your DB (webhook lag)
  if (!user) {
    const clerkUser = await currentUser()
    if (!clerkUser) return null
 
    // Create on the fly — idempotent
    const [created] = await db
      .insert(users)
      .values({
        clerkId: userId,
        email: clerkUser.primaryEmailAddress?.emailAddress ?? '',
        name: clerkUser.fullName,
      })
      .onConflictDoNothing()
      .returning()
 
    return created ?? null
  }
 
  return user
}
 
export async function requireAuth() {
  const user = await getAuthUser()
  if (!user) throw new Error('Unauthorized')
  return user
}

Step 7 — UI components with shadcn/ui

shadcn/ui isn't a component library you install — it's a collection of components you copy into your project. You own the code, you can modify anything.

npx shadcn@latest init

Choose: Default style, slate base color, CSS variables.

Add components as you need them:

npx shadcn@latest add button card input label form dialog

They land in src/components/ui/ — fully editable.


Step 8 — Forms with React Hook Form + Zod

Never use uncontrolled <form> with FormData for complex forms. React Hook Form gives you validation, error states, and loading states with minimal code.

npm install react-hook-form @hookform/resolvers zod
// Example: create project form
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
 
const createProjectSchema = z.object({
  name: z.string().min(1, 'Name is required').max(50, 'Max 50 characters'),
  description: z.string().max(200).optional(),
  isPublic: z.boolean().default(false),
})
 
type CreateProjectInput = z.infer<typeof createProjectSchema>
 
export function CreateProjectForm() {
  const form = useForm<CreateProjectInput>({
    resolver: zodResolver(createProjectSchema),
    defaultValues: { name: '', isPublic: false },
  })
 
  async function onSubmit(data: CreateProjectInput) {
    const result = await createProject(data) // Server Action
    if (result.error) {
      form.setError('name', { message: result.error })
      return
    }
    // success handling
  }
 
  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      {/* form fields */}
    </form>
  )
}

The schema validates both on the client (instant feedback) and on the server (security). Same schema, both places.


Step 9 — Server Actions for mutations

Forget API routes for simple mutations. Server Actions are TypeScript functions that run on the server, called directly from client components.

// src/actions/projects.ts
'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { db } from '@/lib/db'
import { projects } from '@/db/schema'
import { requireAuth } from '@/lib/auth'
 
const createProjectSchema = z.object({
  name: z.string().min(1).max(50),
  description: z.string().max(200).optional(),
  isPublic: z.boolean().default(false),
})
 
export async function createProject(input: unknown) {
  const user = await requireAuth()
 
  const parsed = createProjectSchema.safeParse(input)
  if (!parsed.success) {
    return { error: parsed.error.errors[0]?.message ?? 'Invalid input' }
  }
 
  try {
    const [project] = await db
      .insert(projects)
      .values({ ...parsed.data, ownerId: user.id })
      .returning()
 
    revalidatePath('/dashboard')
    return { data: project }
  } catch {
    return { error: 'Failed to create project' }
  }
}
Always validate input in Server Actions

Server Actions are public endpoints — anyone can call them with arbitrary data. Always validate with Zod, always check auth. Never trust the input.


Step 10 — Using AI (Claude Code) the right way

Claude Code is a terminal AI assistant that reads your codebase and writes code. Used correctly, it's a 3-5x speed multiplier. Used incorrectly, it generates code you don't understand and can't maintain.

The senior dev approach to AI:

# Install Claude Code
npm install -g @anthropic-ai/claude-code
claude

Do this — works great:

  • "Add a description column to the projects table and generate the migration"
  • "Write a Server Action that deletes a project by ID, checking that the user owns it"
  • "Add rate limiting to the /api/generate route using Upstash"
  • "Write the Drizzle query to get all projects for a user, ordered by updatedAt"

Don't do this — creates problems:

  • "Build me a complete project management app" (too vague, too large)
  • "Fix all the TypeScript errors" (without explaining the context)
  • Blindly copying generated code without reading it

The best pattern: tell Claude what to build, read what it writes, understand it, then move on. If you can't explain the code it wrote, you have a problem.

CLAUDE.md — your project's AI instructions:

Create a CLAUDE.md at the root of your project:

# Project — CLAUDE.md
 
## Stack
- Next.js 15 App Router
- TypeScript strict mode
- Drizzle ORM + Neon PostgreSQL
- Clerk for auth
- Zod for validation
- Biome for linting
 
## Conventions
- Use `cuid2` for all IDs, never auto-increment
- Always soft-delete users (deletedAt), never hard delete
- Server Actions for mutations, not API routes
- Import env vars from `@/lib/env`, never process.env directly
- Use `requireAuth()` at the top of every protected Server Action
- Return `{ data }` or `{ error }` from Server Actions, never throw
 
## File structure
- Components in `src/components/[feature]/`
- Server Actions in `src/actions/[feature].ts`
- DB schema in `src/db/schema.ts`

This file tells Claude Code how your project is structured. Every suggestion it makes will follow your conventions automatically.


What seniors skip (and you should too)

No unit tests at the start. Tests are valuable — but at the beginning, your schema and API shape changes constantly. Writing tests for code that's about to be rewritten wastes time. Add tests when a feature is stable.

No Redux or Zustand at the start. React's built-in state + Server Components + React Query handle 90% of state management needs. Add a global store when you hit a real problem, not before.

No microservices. Start with a monolith. Split it later when you have a real reason (team size, independent scaling). Premature microservices are the #1 cause of over-engineered MVPs.

No Docker in development. Run the dev server directly. Add Docker when you need consistent environments across a team or when deploying to a non-Vercel platform.

No CI/CD on day one. Vercel auto-deploys on push. That's your CI/CD for the first 6 months. Add GitHub Actions when you need custom steps (migrations, test gates, multi-environment).


Common junior mistakes — and the fix

MistakeFix
process.env.X everywheret3-env — fails at build time if missing
Auto-increment integer IDscuid2 — URL-safe, no sequential exposure
Hard-delete usersdeletedAt soft-delete — data is forever
Mutations in useEffectServer Actions — no race conditions
any type everywhereEnable strict TS — fix errors as you go
One giant schema.tsSplit by domain when it exceeds 200 lines
console.log debuggingpino structured logger from day one
Deploy first, migrate afterMigrate first, always — avoid schema mismatch

The 30-minute setup checklist

1
Initialize the project

npx create-next-app@latest with TypeScript, Tailwind, App Router, src dir.

2
Enable strict TypeScript

Add noUncheckedIndexedAccess, noImplicitReturns, exactOptionalPropertyTypes to tsconfig.

3
Install and configure Biome

Remove ESLint, install Biome, configure formatter and linter rules.

4
Set up t3-env

Define all env vars with Zod schemas. Never use process.env again.

5
Set up Drizzle + Neon

Create DB client, write initial schema with cuid2 IDs, run first migration.

6
Set up Clerk

Install, add middleware, create getAuthUser() and requireAuth() helpers.

7
Install shadcn/ui

Init shadcn, add the 5-6 components you know you'll need.

8
Create CLAUDE.md

Document your stack, conventions, and file structure. This is your AI instructions file.


What this gives you

After 30 minutes of setup, you have:

  • Type-safe everything — DB, env vars, API inputs, form validation
  • Authentication that just works
  • A database that won't break your app when you change the schema
  • AI assistance that follows your conventions
  • Zero config debt — Biome handles formatting automatically

The rest is business logic. That's the part you actually want to be writing.


#nextjs#typescript#fullstack#drizzle#tutorial
Share:

Enjoyed this article?

Join 2,400+ developers getting weekly insights on Claude Code, React, and AI tools.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.