Files
2025-11-29 18:46:49 +08:00

12 KiB

Next.js Best Practices (2025)

Current best practices for Next.js 15/16 development with App Router, Server Components, and modern patterns.

Framework Versions

Current Versions

  • Next.js: 15.x or 16.x (latest stable)
  • React: 19.x (Server Components, Actions)
  • TypeScript: 5.x
  • Node.js: 20 LTS or higher

Deprecated Versions to Flag

  • Next.js 12.x or older (Pages Router era)
  • React 17.x or older
  • TypeScript 4.x or older

App Router vs Pages Router

[OK] Modern (App Router)

app/
├── layout.tsx          # Root layout
├── page.tsx            # Homepage
├── entities/
│   ├── layout.tsx      # Nested layout
│   ├── page.tsx        # Entity list
│   └── [id]/
│       └── page.tsx    # Entity detail
└── api/                # API route handlers (minimal)
    └── webhook/
        └── route.ts    # External webhooks only

[ERROR] Deprecated (Pages Router)

pages/
├── _app.tsx            # Old pattern
├── _document.tsx       # Old pattern
├── index.tsx           # Old pattern
├── entities/
│   ├── index.tsx
│   └── [id].tsx
└── api/                # Old API routes
    └── entities.ts

Migration Check: Flag any skills using pages/ directory or getServerSideProps

Server Components vs Client Components

Default: Server Components

// app/entities/page.tsx
// No 'use client' directive = Server Component (default)

import { db } from '@/lib/db'

export default async function EntitiesPage() {
  // Direct database access - only in Server Components
  const entities = await db.entity.findMany()

  return (
    <div>
      <h1>Entities</h1>
      {entities.map(entity => (
        <EntityCard key={entity.id} entity={entity} />
      ))}
    </div>
  )
}

Client Components (When Needed)

// components/EntityForm.tsx
'use client'  // Required for hooks, events, browser APIs

import { useState } from 'react'
import { useForm } from 'react-hook-form'

export function EntityForm() {
  const [isSubmitting, setIsSubmitting] = useState(false)
  const form = useForm()

  return <form>...</form>
}

When to use 'use client':

  • useState, useEffect, other hooks
  • Event handlers (onClick, onChange)
  • Browser APIs (localStorage, window)
  • Third-party libraries requiring browser

Best Practice: Keep 'use client' boundary as low as possible in component tree

Data Fetching

[OK] Modern: Server Components + fetch

// app/characters/page.tsx
export default async function CharactersPage() {
  // Fetch in Server Component
  const characters = await fetch('https://api.example.com/characters', {
    next: { revalidate: 3600 } // Cache for 1 hour
  }).then(res => res.json())

  return <CharacterList characters={characters} />
}

[OK] Modern: Server Components + Database

// app/locations/page.tsx
import { db } from '@/lib/db'

export default async function LocationsPage() {
  // Direct database access
  const locations = await db.location.findMany({
    include: { region: true }
  })

  return <LocationList locations={locations} />
}

[ERROR] Deprecated: getServerSideProps

// pages/characters.tsx - OLD PATTERN
export async function getServerSideProps() {
  const characters = await fetchCharacters()
  return { props: { characters } }
}

Migration Check: Replace getServerSideProps, getStaticProps, getInitialProps with Server Component async data fetching

Server Actions

[OK] Modern: Server Actions for Mutations

// app/actions/character.ts
'use server'

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

export async function createCharacter(formData: FormData) {
  const name = formData.get('name') as string

  const character = await db.character.create({
    data: { name }
  })

  revalidatePath('/characters')
  return { success: true, character }
}
// app/characters/CreateForm.tsx
'use client'

import { createCharacter } from '@/app/actions/character'

export function CreateForm() {
  return (
    <form action={createCharacter}>
      <input name="name" />
      <button type="submit">Create</button>
    </form>
  )
}

[ERROR] Deprecated: API Routes for Simple Mutations

// pages/api/characters.ts - OLD PATTERN
export default async function handler(req, res) {
  if (req.method === 'POST') {
    const character = await createCharacter(req.body)
    res.json({ character })
  }
}

When to still use API Routes:

  • Webhooks from external services
  • OAuth callbacks
  • Third-party integrations requiring public endpoints

Migration Check: Replace API routes used for form submissions with Server Actions

Metadata and SEO

[OK] Modern: generateMetadata

// app/characters/[id]/page.tsx
import { Metadata } from 'next'

export async function generateMetadata({ params }): Promise<Metadata> {
  const character = await db.character.findUnique({
    where: { id: params.id }
  })

  return {
    title: character.name,
    description: character.bio,
    openGraph: {
      title: character.name,
      description: character.bio,
      images: [character.image],
    },
  }
}

[ERROR] Deprecated: next/head

// OLD PATTERN
import Head from 'next/head'

<Head>
  <title>{character.name}</title>
</Head>

Routing and Navigation

import Link from 'next/link'

<Link href="/characters/123">View Character</Link>

[OK] Modern: useRouter from next/navigation

'use client'
import { useRouter } from 'next/navigation'  // Not 'next/router'!

export function BackButton() {
  const router = useRouter()
  return <button onClick={() => router.back()}>Back</button>
}

[ERROR] Deprecated: useRouter from next/router

// OLD PATTERN
import { useRouter } from 'next/router'  // Pages Router only

Loading States

[OK] Modern: loading.tsx

// app/characters/loading.tsx
export default function Loading() {
  return <div>Loading characters...</div>
}

[OK] Modern: Suspense Boundaries

// app/characters/page.tsx
import { Suspense } from 'react'

export default function Page() {
  return (
    <Suspense fallback={<LoadingSkeleton />}>
      <CharacterList />
    </Suspense>
  )
}

Error Handling

[OK] Modern: error.tsx

// app/characters/error.tsx
'use client'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div>
      <h2>Something went wrong!</h2>
      <button onClick={() => reset()}>Try again</button>
    </div>
  )
}

Caching and Revalidation

[OK] Modern: Revalidation Strategies

// Time-based revalidation
fetch(url, { next: { revalidate: 3600 } })  // 1 hour

// On-demand revalidation
import { revalidatePath } from 'next/cache'
revalidatePath('/characters')

// Tag-based revalidation
fetch(url, { next: { tags: ['characters'] } })
import { revalidateTag } from 'next/cache'
revalidateTag('characters')

// Disable caching
fetch(url, { cache: 'no-store' })

Middleware

[OK] Modern: middleware.ts

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

export function middleware(request: NextRequest) {
  // Auth check
  const token = request.cookies.get('token')

  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: '/dashboard/:path*',
}

TypeScript Patterns

[OK] Type-Safe Server Components

interface Character {
  id: string
  name: string
  bio: string
}

export default async function CharacterPage({
  params,
}: {
  params: { id: string }
}) {
  const character: Character = await db.character.findUnique({
    where: { id: params.id }
  })

  return <div>{character.name}</div>
}

[OK] Type-Safe Server Actions

'use server'

import { z } from 'zod'

const createCharacterSchema = z.object({
  name: z.string().min(2),
  bio: z.string().max(5000),
})

export async function createCharacter(
  data: z.infer<typeof createCharacterSchema>
) {
  const validated = createCharacterSchema.parse(data)
  // ...
}

Environment Variables

[OK] Modern: Type-Safe Env

// env.mjs
import { createEnv } from "@t3-oss/env-nextjs"
import { z } from "zod"

export const env = createEnv({
  server: {
    DATABASE_URL: z.string().url(),
    NEXTAUTH_SECRET: z.string().min(1),
  },
  client: {
    NEXT_PUBLIC_API_URL: z.string().url(),
  },
  runtimeEnv: {
    DATABASE_URL: process.env.DATABASE_URL,
    NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
    NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
  },
})

Configuration

[OK] Modern: next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    serverActions: {
      bodySizeLimit: '2mb',
    },
  },
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'example.com',
      },
    ],
  },
}

export default nextConfig

Common Anti-Patterns to Flag

[ERROR] Fetching in Client Components

'use client'
import { useEffect, useState } from 'react'

// BAD: Fetch in client component
export function Characters() {
  const [data, setData] = useState([])

  useEffect(() => {
    fetch('/api/characters').then(r => r.json()).then(setData)
  }, [])

  return <div>{data.map(...)}</div>
}

[OK] Fix: Move data fetching to Server Component or use React Query for client-side data management

[ERROR] Unnecessary 'use client'

'use client'  // Not needed!

export function StaticCard({ title, description }) {
  return (
    <div>
      <h3>{title}</h3>
      <p>{description}</p>
    </div>
  )
}

[OK] Fix: Remove 'use client' if component doesn't use hooks, events, or browser APIs

[ERROR] Mixing Data Fetching Methods

// BAD: Using both old and new patterns
export async function getServerSideProps() { ... }

export default async function Page() {
  const data = await fetch(...)
}

[OK] Fix: Use only Server Component data fetching in App Router

Performance Best Practices

Image Optimization

import Image from 'next/image'

<Image
  src="/character.jpg"
  alt="Character portrait"
  width={800}
  height={600}
  priority  // For LCP images
/>

Font Optimization

import { Inter } from 'next/font/google'

const inter = Inter({ subsets: ['latin'] })

export default function RootLayout({ children }) {
  return (
    <html lang="en" className={inter.className}>
      {children}
    </html>
  )
}

Code Splitting

import dynamic from 'next/dynamic'

const HeavyComponent = dynamic(() => import('@/components/HeavyComponent'), {
  loading: () => <p>Loading...</p>,
  ssr: false,  // Client-only if needed
})

Validation Checklist

When reviewing Next.js skills, check for:

  • Uses App Router (app/ directory), not Pages Router
  • Server Components for data fetching
  • Server Actions for mutations
  • Proper 'use client' boundaries
  • generateMetadata for SEO
  • useRouter from 'next/navigation'
  • Modern caching with fetch options
  • loading.tsx and error.tsx files
  • Type-safe environment variables
  • Image and font optimization
  • No deprecated APIs (getServerSideProps, etc.)
  • Proper middleware usage
  • Correct import paths

Quick Reference

Must Use (Modern)

  • [OK] app/ directory
  • [OK] Server Components (default)
  • [OK] Server Actions ('use server')
  • [OK] generateMetadata
  • [OK] useRouter from 'next/navigation'
  • [OK] revalidatePath/revalidateTag

Must Avoid (Deprecated)

  • [ERROR] pages/ directory
  • [ERROR] getServerSideProps
  • [ERROR] getStaticProps
  • [ERROR] getInitialProps
  • [ERROR] API routes for mutations
  • [ERROR] useRouter from 'next/router'
  • [ERROR] next/head