SkillHub

afrexai-nextjs-production

v1.0.0

构建、优化和运维生产级 Next.js 应用,掌握架构、数据获取、缓存、渲染、测试、部署及可观测性等最佳实践

Sourced from ClawHub, Authored by 1kalin

Installation

Please help me install the skill `afrexai-nextjs-production` from SkillHub official store. npx skills add 1kalin/afrexai-nextjs-production

Next.js Production Engineering

Complete methodology for building, optimizing, and operating production Next.js applications. From architecture decisions to deployment strategies — everything beyond "hello world."

Quick Health Check (60 seconds)

Run through these 8 signals — score 0 (no) or 2 (yes):

Signal Check Score
🏗️ Architecture Server/Client Component boundary is intentional, not accidental /2
⚡ Performance Core Web Vitals all green (LCP <2.5s, INP <200ms, CLS <0.1) /2
🔒 Security No secrets in client bundles, CSP headers configured /2
📦 Bundle No unnecessary client JS, tree-shaking working /2
🗄️ Data Caching strategy defined (not just defaults) /2
🧪 Testing E2E + unit tests in CI, >70% coverage on critical paths /2
🚀 Deploy Preview deploys, rollback capability, monitoring /2
📊 Observability Error tracking, performance monitoring, structured logging /2

Score: /16 → 14-16 Production-ready | 10-13 Needs work | <10 Risk zone


Phase 1: Architecture Decisions

App Router vs Pages Router Decision

Default: App Router for all new projects (Next.js 13.4+).

Use Pages Router ONLY if: - Migrating existing Pages Router app (incremental adoption) - Team has zero RSC experience AND shipping deadline <2 weeks - Library dependency requires Pages Router patterns

src/
├── app/                    # App Router — routes only
│   ├── (auth)/             # Route group — shared auth layout
│   │   ├── login/page.tsx
│   │   └── register/page.tsx
│   ├── (dashboard)/        # Route group — shared dashboard layout
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── settings/page.tsx
│   ├── api/                # Route Handlers (use sparingly)
│   │   └── webhooks/
│   │       └── stripe/route.ts
│   ├── layout.tsx          # Root layout
│   ├── loading.tsx         # Root loading
│   ├── error.tsx           # Root error boundary
│   ├── not-found.tsx       # 404 page
│   └── global-error.tsx    # Global error boundary
├── components/             # Shared components
│   ├── ui/                 # Design system primitives
│   ├── forms/              # Form components
│   └── layouts/            # Layout components
├── lib/                    # Shared utilities
│   ├── db/                 # Database client & queries
│   ├── auth/               # Auth utilities
│   ├── api/                # External API clients
│   └── utils/              # Pure utility functions
├── hooks/                  # Custom React hooks (client-only)
├── actions/                # Server Actions
├── types/                  # TypeScript types
├── styles/                 # Global styles
└── config/                 # App configuration

Structure Rules

  1. Routes are thinpage.tsx imports components, doesn't contain business logic
  2. Components are reusable — never import from app/ into components/
  3. Server Actions get their own directory — organized by domain, not by page
  4. No barrel files (index.ts re-exports) — they break tree-shaking
  5. Colocation for route-specific_components/ in route folders for non-shared components

Rendering Strategy Decision Matrix

Scenario Strategy Why
Static content (blog, docs, marketing) Static (SSG) Build-time generation, CDN-cached
User-specific dashboard Dynamic Server Fresh data per request
Product listing with prices ISR (revalidate: 3600) Fresh enough, fast delivery
Real-time data (chat, stocks) Client-side + WebSocket Server can't push updates
SEO-critical + fresh data Dynamic Server + streaming Fast TTFB with Suspense
Highly interactive form/wizard Client Component Complex state management

Server vs Client Component Rules

DEFAULT: Server Component (every .tsx is server by default)

Add "use client" ONLY when you need:
✅ useState, useEffect, useRef, useContext
✅ Browser APIs (window, document, localStorage)
✅ Event handlers (onClick, onChange, onSubmit)
✅ Third-party client libraries (framer-motion, react-hook-form)

NEVER add "use client" because:
❌ You want to use async/await (Server Components support this natively)
❌ You're fetching data (fetch in Server Components, not useEffect)
❌ You're importing a server-only library
❌ "It's not working" — debug the actual issue first

The Boundary Pattern

// ✅ CORRECT: Server Component wraps Client Component
// app/dashboard/page.tsx (Server Component)
import { getUser } from '@/lib/auth'
import { DashboardClient } from './_components/dashboard-client'

export default async function DashboardPage() {
  const user = await getUser()  // Server-side data fetch
  return <DashboardClient user={user} />  // Pass as props
}

// _components/dashboard-client.tsx
'use client'
export function DashboardClient({ user }: { user: User }) {
  const [tab, setTab] = useState('overview')
  return <div>...</div>
}

Push "use client" as far down the tree as possible. The boundary should be at the leaf, not the root.


Phase 2: Data Fetching & Caching

Data Fetching Hierarchy (Prefer Top → Bottom)

  1. Server Component direct fetch — simplest, most performant
  2. Server Actions — for mutations and form submissions
  3. Route Handlers — for webhooks, external API endpoints
  4. Client-side fetch (SWR/React Query) — for real-time/polling data only

Fetch Configuration

// Static data (cached indefinitely, revalidated on deploy)
const data = await fetch('https://api.example.com/data', {
  cache: 'force-cache'  // Default in App Router
})

// Revalidate every hour
const data = await fetch('https://api.example.com/data', {
  next: { revalidate: 3600 }
})

// Always fresh (no cache)
const data = await fetch('https://api.example.com/data', {
  cache: 'no-store'
})

// Tag-based revalidation
const data = await fetch('https://api.example.com/products', {
  next: { tags: ['products'] }
})
// Then in a Server Action:
import { revalidateTag } from 'next/cache'
revalidateTag('products')

Caching Strategy by Data Type

Data Type Cache Strategy Revalidate Tags
CMS content ISR 3600s (1h) ['cms', 'posts']
Product catalog ISR 300s (5m) ['products']
User profile No cache
Pricing/inventory No cache
Static assets Force cache On deploy
Analytics/dashboards ISR 60s ['analytics']
Auth tokens No cache

Database Queries (No fetch API)

import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'

// Cache database queries with tags
const getProducts = unstable_cache(
  async (categoryId: string) => {
    return db.query.products.findMany({
      where: eq(products.categoryId, categoryId)
    })
  },
  ['products'],  // Cache key parts
  {
    revalidate: 300,
    tags: ['products']
  }
)

Parallel Data Fetching

// ✅ CORRECT: Parallel fetches
export default async function DashboardPage() {
  const [user, stats, notifications] = await Promise.all([
    getUser(),
    getStats(),
    getNotifications()
  ])
  return <Dashboard user={user} stats={stats} notifications={notifications} />
}

// ❌ WRONG: Sequential waterfall
export default async function DashboardPage() {
  const user = await getUser()
  const stats = await getStats(user.id)  // Waits for user
  const notifications = await getNotifications(user.id)  // Waits for stats
}

Streaming with Suspense

import { Suspense } from 'react'

export default async function Page() {
  return (
    <div>
      <h1>Dashboard</h1>
      {/* Fast: renders immediately */}
      <UserGreeting />

      {/* Slow: streams in when ready */}
      <Suspense fallback={<StatsSkeleton />}>
        <StatsPanel />  {/* Async Server Component */}
      </Suspense>

      <Suspense fallback={<FeedSkeleton />}>
        <ActivityFeed />
      </Suspense>
    </div>
  )
}

Phase 3: Server Actions & Mutations

Server Action Best Practices

// actions/user.ts
'use server'

import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

const updateProfileSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  bio: z.string().max(500).optional()
})

export async function updateProfile(formData: FormData) {
  // 1. Authenticate
  const session = await getSession()
  if (!session) throw new Error('Unauthorized')

  // 2. Validate
  const parsed = updateProfileSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    bio: formData.get('bio')
  })

  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors }
  }

  // 3. Authorize
  if (session.userId !== formData.get('userId')) {
    throw new Error('Forbidden')
  }

  // 4. Mutate
  await db.update(users)
    .set(parsed.data)
    .where(eq(users.id, session.userId))

  // 5. Revalidate
  revalidatePath('/profile')

  return { success: true }
}

Server Action Rules

  1. Always validate input — FormData is user input, never trust it
  2. Always check auth — Server Actions are public endpoints
  3. Always check authorization — user can only modify their own data
  4. Use Zod for validation — type-safe, composable schemas
  5. Return errors, don't throw — throwing shows error boundary, returning shows inline errors
  6. Revalidate after mutationsrevalidatePath or revalidateTag
  7. Never return sensitive data — return only what the client needs

useActionState Pattern (React 19)

'use client'
import { useActionState } from 'react'
import { updateProfile } from '@/actions/user'

export function ProfileForm({ user }: { user: User }) {
  const [state, action, pending] = useActionState(updateProfile, null)

  return (
    <form action={action}>
      <input name="name" defaultValue={user.name} />
      {state?.error?.name && <p className="text-red-500">{state.error.name}</p>}

      <button type="submit" disabled={pending}>
        {pending ? 'Saving...' : 'Save'}
      </button>

      {state?.success && <p className="text-green-500">Saved!</p>}
    </form>
  )
}

Phase 4: Authentication & Authorization

Auth Pattern Selection

Method Best For Libraries
Session-based (cookie) Traditional web apps NextAuth.js / Auth.js
JWT API-first, mobile clients jose, custom
OAuth only Social login, quick start NextAuth.js
Passkeys/WebAuthn Modern, passwordless SimpleWebAuthn
Third-party Enterprise, compliance Clerk, Auth0, Supabase Auth

Middleware Auth Pattern

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

const publicRoutes = ['/', '/login', '/register', '/api/webhooks']
const authRoutes = ['/login', '/register']

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl
  const token = request.cookies.get('session')?.value

  // Public routes — allow
  if (publicRoutes.some(route => pathname.startsWith(route))) {
    // Redirect authenticated users away from auth pages
    if (token && authRoutes.some(route => pathname.startsWith(route))) {
      return NextResponse.redirect(new URL('/dashboard', request.url))
    }
    return NextResponse.next()
  }

  // Protected routes — require auth
  if (!token) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('callbackUrl', pathname)
    return NextResponse.redirect(loginUrl)
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|public).*)']
}

Authorization Pattern

// lib/auth/permissions.ts
type Permission = 'read' | 'write' | 'admin'
type Resource = 'posts' | 'users' | 'settings'

const rolePermissions: Record<string, Record<Resource, Permission[]>> = {
  admin: {
    posts: ['read', 'write', 'admin'],
    users: ['read', 'write', 'admin'],
    settings: ['read', 'write', 'admin']
  },
  editor: {
    posts: ['read', 'write'],
    users: ['read'],
    settings: ['read']
  },
  viewer: {
    posts: ['read'],
    users: [],
    settings: []
  }
}

export function can(role: string, resource: Resource, permission: Permission): boolean {
  return rolePermissions[role]?.[resource]?.includes(permission) ?? false
}

// Usage in Server Component
export default async function AdminPage() {
  const session = await getSession()
  if (!can(session.role, 'settings', 'admin')) {
    notFound()  // Don't reveal admin pages exist
  }
  return <AdminDashboard />
}

Security Headers (next.config.ts)

const securityHeaders = [
  { key: 'X-DNS-Prefetch-Control', value: 'on' },
  { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
  { key: 'X-Frame-Options', value: 'SAMEORIGIN' },
  { key: 'X-Content-Type-Options', value: 'nosniff' },
  { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
  { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
  {
    key: 'Content-Security-Policy',
    value: `
      default-src 'self';
      script-src 'self' 'unsafe-eval' 'unsafe-inline';
      style-src 'self' 'unsafe-inline';
      img-src 'self' data: https:;
      font-src 'self';
      connect-src 'self' https://api.example.com;
      frame-ancestors 'none';
    `.replace(/n/g, '')
  }
]

Phase 5: Performance Optimization

Core Web Vitals Targets

Metric Good Needs Improvement Poor
LCP <2.5s 2.5-4.0s >4.0s
INP <200ms 200-500ms >500ms
CLS <0.1 0.1-0.25 >0.25
TTFB <800ms 800ms-1.8s >1.8s
FCP <1.8s 1.8-3.0s >3.0s

Image Optimization

import Image from 'next/image'

// ✅ Always use next/image
<Image
  src="/hero.jpg"
  alt="Hero image"
  width={1200}
  height={630}
  priority  // LCP image — load immediately
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
  placeholder="blur"
  blurDataURL={shimmer}  // Base64 placeholder
/>

// For dynamic images
<Image
  src={user.avatar}
  alt={user.name}
  width={48}
  height={48}
  loading="lazy"  // Below fold — lazy load
/>

Image Rules

  1. Always set priority on LCP image (hero, above-fold)
  2. Always provide sizes — prevents downloading oversized images
  3. Use placeholder="blur" for large images — prevents CLS
  4. Configure remotePatterns in next.config.ts for external images
  5. Use WebP/AVIF — next/image auto-converts by default

Bundle Optimization

// next.config.ts
const nextConfig = {
  // Strict mode for catching bugs
  reactStrictMode: true,

  // Optimize packages
  experimental: {
    optimizePackageImports: [
      'lucide-react',
      '@radix-ui/react-icons',
      'date-fns',
      'lodash-es'
    ]
  },

  // Bundle analyzer (dev only)
  // npm install @next/bundle-analyzer
  ...(process.env.ANALYZE === 'true' && {
    webpack: (config) => {
      const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
      config.plugins.push(new BundleAnalyzerPlugin({ analyzerMode: 'static' }))
      return config
    }
  })
}

Dynamic Imports for Heavy Components

import dynamic from 'next/dynamic'

// Heavy chart library — only load when needed
const Chart = dynamic(() => import('@/components/chart'), {
  loading: () => <ChartSkeleton />,
  ssr: false  // Client-only component
})

// Code editor — definitely client-only
const CodeEditor = dynamic(() => import('@/components/code-editor'), {
  ssr: false
})

Font Optimization

// app/layout.tsx
import { Inter, JetBrains_Mono } from 'next/font/google'

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter'
})

const jetbrains = JetBrains_Mono({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-mono'
})

export default function RootLayout({ children }) {
  return (
    <html lang="en" className={`${inter.variable} ${jetbrains.variable}`}>
      <body className="font-sans">{children}</body>
    </html>
  )
}

Performance Budget

Resource Budget Tool
First Load JS <100KB next build output
Page JS <50KB per route Bundle analyzer
Total page weight <500KB Lighthouse
LCP image <200KB next/image handles
Third-party scripts <50KB total Script component
Web fonts <100KB next/font handles

Phase 6: Database & ORM

ORM Selection Guide

ORM Best For Tradeoffs
Drizzle Type-safe, lightweight, SQL-like Newer ecosystem
Prisma Rapid prototyping, schema-first Heavier, edge limitations
Kysely Type-safe raw SQL More manual, no migrations
Raw SQL (pg/mysql2) Max performance, full control No type safety, manual migrations
// lib/db/index.ts
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
import * as schema from './schema'

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,
  idleTimeoutMillis: 30000
})

export const db = drizzle(pool, { schema })

// lib/db/schema.ts
import { pgTable, text, timestamp, uuid, boolean } from 'drizzle-orm/pg-core'

export const users = pgTable('users', {
  id: uuid('id').defaultRandom().primaryKey(),
  email: text('email').notNull().unique(),
  name: text('name').notNull(),
  role: text('role', { enum: ['admin', 'editor', 'viewer'] }).default('viewer'),
  emailVerified: boolean('email_verified').default(false),
  createdAt: timestamp('created_at').defaultNow(),
  updatedAt: timestamp('updated_at').defaultNow()
})

Connection Pooling for Serverless

// For Vercel/serverless — use connection pooler
// Neon: use pooler URL (port 5432 → 6543)
// Supabase: use Supavisor URL
// PlanetScale: serverless driver built-in

// lib/db/index.ts (serverless-safe)
import { neon } from '@neondatabase/serverless'
import { drizzle } from 'drizzle-orm/neon-http'

const sql = neon(process.env.DATABASE_URL!)
export const db = drizzle(sql)

Phase 7: Testing Strategy

Test Pyramid for Next.js

Level Tool What to Test Coverage Target
Unit Vitest Utils, hooks, pure functions 80%+
Component Testing Library + Vitest UI components, forms 70%+
Integration Testing Library Page-level with mocked data Key flows
E2E Playwright Critical user journeys 5-10 flows
Visual Playwright screenshots UI regression Key pages

Vitest Configuration

// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'

export default defineConfig({
  plugins: [react(), tsconfigPaths()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./tests/setup.ts'],
    include: ['**/*.test.{ts,tsx}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov'],
      exclude: ['**/*.config.*', '**/types/**']
    }
  }
})

Server Component Testing

// Server Components can be tested as async functions
import { render } from '@testing-library/react'
import Page from '@/app/dashboard/page'

// Mock the data fetching
vi.mock('@/lib/db', () => ({
  getUser: vi.fn().mockResolvedValue({ id: '1', name: 'Test' })
}))

test('dashboard page renders user name', async () => {
  const Component = await Page()  // Call as async function
  const { getByText } = render(Component)
  expect(getByText('Test')).toBeInTheDocument()
})

Playwright E2E Pattern

// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Authentication', () => {
  test('login flow', async ({ page }) => {
    await page.goto('/login')
    await page.fill('[name="email"]', '[email protected]')
    await page.fill('[name="password"]', 'password123')
    await page.click('button[type="submit"]')

    await expect(page).toHaveURL('/dashboard')
    await expect(page.getByText('Welcome')).toBeVisible()
  })

  test('protected route redirects', async ({ page }) => {
    await page.goto('/dashboard')
    await expect(page).toHaveURL(//login/)
  })
})

Phase 8: Error Handling & Monitoring

Error Boundary Architecture

app/
├── global-error.tsx     # Catches root layout errors (must include <html>)
├── error.tsx            # Catches app-level errors
├── not-found.tsx        # 404 page
├── (dashboard)/
│   ├── error.tsx        # Dashboard-specific errors
│   └── settings/
│       └── error.tsx    # Settings-specific errors

Error Component Pattern

// app/error.tsx
'use client'

import { useEffect } from 'react'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  useEffect(() => {
    // Log to error tracking service
    console.error('Application error:', error)
    // Sentry.captureException(error)
  }, [error])

  return (
    <div className="flex flex-col items-center justify-center min-h-[400px]">
      <h2 className="text-2xl font-bold">Something went wrong</h2>
      <p className="text-gray-500 mt-2">
        {error.digest ? `Error ID: ${error.digest}` : error.message}
      </p>
      <button
        onClick={reset}
        className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
      >
        Try again
      </button>
    </div>
  )
}

Structured Logging

// lib/logger.ts
type LogLevel = 'debug' | 'info' | 'warn' | 'error'

function log(level: LogLevel, message: string, meta?: Record<string, unknown>) {
  const entry = {
    timestamp: new Date().toISOString(),
    level,
    message,
    ...meta,
    // Add request context if available
    ...(meta?.requestId && { requestId: meta.requestId })
  }

  if (level === 'error') {
    console.error(JSON.stringify(entry))
  } else {
    console.log(JSON.stringify(entry))
  }
}

export const logger = {
  debug: (msg: string, meta?: Record<string, unknown>) => log('debug', msg, meta),
  info: (msg: string, meta?: Record<string, unknown>) => log('info', msg, meta),
  warn: (msg: string, meta?: Record<string, unknown>) => log('warn', msg, meta),
  error: (msg: string, meta?: Record<string, unknown>) => log('error', msg, meta)
}

Phase 9: Deployment & Infrastructure

Platform Comparison

Platform Best For Edge DB Cost (hobby)
Vercel Default choice, best DX External Free → $20/mo
Cloudflare Pages Edge-first, Workers D1, KV Free → $5/mo
AWS Amplify AWS ecosystem RDS, DynamoDB Pay-per-use
Railway Full-stack, Docker Built-in Postgres $5/mo
Fly.io Global, Docker Built-in Postgres Pay-per-use
Self-hosted (Docker) Full control Any Server cost

Docker Production Setup

# Dockerfile
FROM node:20-alpine AS base
RUN corepack enable

FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build

FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

CMD ["node", "server.js"]
// next.config.ts — required for standalone
const nextConfig = {
  output: 'standalone'
}

CI/CD Pipeline (GitHub Actions)

# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm tsc --noEmit
      - run: pnpm lint
      - run: pnpm test -- --coverage
      - run: pnpm build

  e2e:
    runs-on: ubuntu-latest
    needs: quality
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec playwright install --with-deps
      - run: pnpm build
      - run: pnpm exec playwright test
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

Environment Variables

// env.ts — runtime validation with t3-env
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'

export const env = createEnv({
  server: {
    DATABASE_URL: z.string().url(),
    AUTH_SECRET: z.string().min(32),
    STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
    REDIS_URL: z.string().url().optional(),
  },
  client: {
    NEXT_PUBLIC_APP_URL: z.string().url(),
    NEXT_PUBLIC_STRIPE_KEY: z.string().startsWith('pk_'),
  },
  runtimeEnv: {
    DATABASE_URL: process.env.DATABASE_URL,
    AUTH_SECRET: process.env.AUTH_SECRET,
    STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
    REDIS_URL: process.env.REDIS_URL,
    NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
    NEXT_PUBLIC_STRIPE_KEY: process.env.NEXT_PUBLIC_STRIPE_KEY,
  },
})

Phase 10: Common Patterns Library

Optimistic Updates

'use client'
import { useOptimistic, useTransition } from 'react'
import { toggleTodo } from '@/actions/todos'

export function TodoItem({ todo }: { todo: Todo }) {
  const [optimisticTodo, setOptimisticTodo] = useOptimistic(todo)
  const [, startTransition] = useTransition()

  return (
    <label>
      <input
        type="checkbox"
        checked={optimisticTodo.completed}
        onChange={() => {
          startTransition(async () => {
            setOptimisticTodo({ ...todo, completed: !todo.completed })
            await toggleTodo(todo.id)
          })
        }}
      />
      {optimisticTodo.title}
    </label>
  )
}

Infinite Scroll

'use client'
import { useInView } from 'react-intersection-observer'
import { useEffect, useState, useTransition } from 'react'
import { loadMore } from '@/actions/feed'

export function InfiniteList({ initialItems }: { initialItems: Item[] }) {
  const [items, setItems] = useState(initialItems)
  const [cursor, setCursor] = useState(initialItems.at(-1)?.id)
  const [hasMore, setHasMore] = useState(true)
  const [isPending, startTransition] = useTransition()
  const { ref, inView } = useInView()

  useEffect(() => {
    if (inView && hasMore && !isPending) {
      startTransition(async () => {
        const newItems = await loadMore(cursor)
        if (newItems.length === 0) {
          setHasMore(false)
        } else {
          setItems(prev => [...prev, ...newItems])
          setCursor(newItems.at(-1)?.id)
        }
      })
    }
  }, [inView, hasMore, isPending, cursor])

  return (
    <div>
      {items.map(item => <ItemCard key={item.id} item={item} />)}
      {hasMore && <div ref={ref}>{isPending ? <Spinner /> : null}</div>}
    </div>
  )
}

Search with URL State

'use client'
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
import { useDebouncedCallback } from 'use-debounce'

export function SearchBar() {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()

  const handleSearch = useDebouncedCallback((term: string) => {
    const params = new URLSearchParams(searchParams)
    if (term) {
      params.set('q', term)
      params.set('page', '1')
    } else {
      params.delete('q')
    }
    router.replace(`${pathname}?${params.toString()}`)
  }, 300)

  return (
    <input
      type="search"
      placeholder="Search..."
      defaultValue={searchParams.get('q') ?? ''}
      onChange={e => handleSearch(e.target.value)}
    />
  )
}

Multi-step Form with URL State

// app/onboarding/page.tsx
export default function OnboardingPage({
  searchParams
}: {
  searchParams: { step?: string }
}) {
  const step = Number(searchParams.step) || 1

  return (
    <div>
      <ProgressBar step={step} total={4} />
      {step === 1 && <StepOne />}
      {step === 2 && <StepTwo />}
      {step === 3 && <StepThree />}
      {step === 4 && <StepFour />}
    </div>
  )
}

Phase 11: Production Checklist

Pre-Launch (Mandatory)

  • [ ] next build succeeds with zero warnings
  • [ ] TypeScript strict mode, no any types in production code
  • [ ] All environment variables validated (t3-env or manual)
  • [ ] Security headers configured (CSP, HSTS, X-Frame-Options)
  • [ ] Authentication + authorization tested (pen test critical flows)
  • [ ] Error boundaries at every route level
  • [ ] 404 and 500 pages customized
  • [ ] Favicon, OG images, meta tags configured
  • [ ] Core Web Vitals passing (Lighthouse >90)
  • [ ] Mobile responsive tested on real devices
  • [ ] Accessibility audit (axe, keyboard nav, screen reader)
  • [ ] Rate limiting on API routes and Server Actions
  • [ ] CORS configured correctly
  • [ ] Database connection pooling configured for serverless
  • [ ] Monitoring & error tracking connected (Sentry, etc.)
  • [ ] E2E tests for critical user journeys
  • [ ] Bundle size within budget (<100KB first load)
  • [ ] Image optimization verified (next/image, proper sizes)
  • [ ] Sitemap.xml and robots.txt configured
  • [ ] Analytics configured
  • [ ] Preview deployment tested
  • [ ] Rollback plan documented
  • [ ] Load testing completed
  • [ ] CDN caching verified
  • [ ] Edge middleware tested in production environment

Phase 12: Anti-Patterns & Troubleshooting

10 Next.js Mistakes

# Mistake Fix
1 "use client" at the top of every file Default to Server Components, push client boundary down
2 Fetching data with useEffect Fetch in Server Components or use SWR/React Query for client
3 Not using loading.tsx Add loading states to prevent layout shift
4 Ignoring bundle size Run next build and check output, use dynamic imports
5 No error boundaries Add error.tsx at every route level
6 Storing secrets in NEXT_PUBLIC_* Server-only env vars for secrets, validate with t3-env
7 Not setting image sizes prop Always provide sizes for responsive images
8 Sequential data fetching Use Promise.all() for parallel fetches
9 Caching everything or nothing Explicit cache strategy per data type
10 Not using revalidateTag Tag-based revalidation for precise cache control

Troubleshooting Decision Tree

Build error?
├── "Module not found" → Check import paths, tsconfig paths
├── "Server Component error" → Remove "use client" or move hooks to client component
├── "Hydration mismatch" → Check for browser-only code in shared components
│   → Use suppressHydrationWarning for timestamps
│   → Wrap in useEffect or dynamic(ssr: false)
├── "Edge runtime error" → Check node APIs (fs, crypto) not available at edge
└── Slow build → Check for large static generation, reduce ISR pages

Runtime error?
├── 500 on production → Check error.tsx, logs, Sentry
├── Slow TTFB → Check database queries, add caching
├── CLS → Add explicit dimensions to images/embeds
├── High JS bundle → Run bundle analyzer, dynamic import heavy libs
└── Stale data → Check revalidation settings, revalidateTag

Layer Recommendation Why
Framework Next.js 15+ (App Router) RSC, streaming, Server Actions
Language TypeScript (strict) Type safety, better DX
Styling Tailwind CSS 4 Utility-first, no runtime cost
UI Components shadcn/ui Copy-paste, customizable
Forms react-hook-form + zod Type-safe validation
ORM Drizzle Type-safe, lightweight, SQL-like
Database PostgreSQL (Neon/Supabase) Serverless-friendly, proven
Auth Auth.js (NextAuth v5) Built for Next.js
Payments Stripe Industry standard
Hosting Vercel Best Next.js DX
Testing Vitest + Playwright Fast unit + reliable E2E
Monitoring Sentry Error tracking + performance
Analytics PostHog Product analytics, open source

Quality Rubric (0-100)

Dimension Weight Scoring
Architecture (RSC boundaries, structure) 20% 0-20
Performance (CWV, bundle, TTFB) 20% 0-20
Security (auth, headers, validation) 15% 0-15
Data layer (caching, fetching, DB) 15% 0-15
Testing (pyramid, coverage, E2E) 10% 0-10
Error handling (boundaries, logging) 10% 0-10
DX (types, linting, CI) 5% 0-5
Deployment (Docker/platform, monitoring) 5% 0-5

Score: 90-100 Elite | 75-89 Production-ready | 60-74 Needs improvement | <60 Not production-ready


Natural Language Commands

  1. "Set up a new Next.js project" → Phase 1 architecture + structure + Phase 6 DB setup
  2. "Add authentication" → Phase 4 auth pattern + middleware + authorization
  3. "Optimize performance" → Phase 5 full checklist + image + bundle + fonts
  4. "Set up testing" → Phase 7 full pyramid + Vitest + Playwright config
  5. "Deploy to production" → Phase 9 platform selection + Docker + CI/CD + env vars
  6. "Fix hydration error" → Phase 12 troubleshooting tree
  7. "Add caching" → Phase 2 caching strategy table + fetch config + tags
  8. "Create a Server Action" → Phase 3 best practices + useActionState pattern
  9. "Audit my app" → Quick health check + Phase 11 production checklist
  10. "Add error handling" → Phase 8 error boundary architecture + logging
  11. "Set up search" → Phase 10 search with URL state pattern
  12. "Review my architecture" → Phase 1 decision matrix + rendering strategy

Built by AfrexAI — the AI automation agency that ships. Zero dependencies.