Next.js Patterns
Next.js 15 App Router — Server Components, Server Actions, caching, middleware, and deployment patterns for AI-assisted development.
Next.js Patterns
Next.js 15 App Router — Server Components, Server Actions, caching, middleware, and deployment patterns for AI-assisted development.
Principles
1. The App Router Mental Model
Next.js App Router uses file-system conventions inside the app/ directory. Every folder is a route segment. Special files define behavior:
page.tsx— the UI for a route (makes the segment publicly accessible)layout.tsx— shared UI that wraps child segments (persists across navigation)loading.tsx— instant loading UI (Suspense boundary)error.tsx— error UI (error boundary)not-found.tsx— 404 UIroute.ts— API endpoint (Route Handler)
Layouts are the killer feature. They don't re-render when you navigate between child routes. Put your nav, sidebar, and providers in layouts.
2. Server Components vs Client Components
Server Components (default) run only on the server:
- Zero JavaScript sent to the client
- Direct access to databases, file systems, environment variables
- Can
awaitdirectly in the component body - Cannot use hooks, browser APIs, or event handlers
Client Components ("use client" directive) run on both server (for SSR) and client:
- Full React interactivity — hooks, state, effects, event handlers
- Hydrated on the client with JavaScript
- Cannot directly access server-only resources
The mental model: Server Components are the default. Add "use client" only when you need interactivity. Push "use client" as deep as possible in the tree.
3. The "use client" Boundary
"use client" marks the entry point into client-side React. Everything imported into a client component becomes part of the client bundle — including child components.
Push it down: Don't make your entire page a client component because one button needs onClick. Extract the interactive part:
// page.tsx — Server Component (default)
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = await getProduct(id);
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={id} /> {/* Only this is "use client" */}
</article>
);
}Children pattern: A client component can render Server Component children via the children prop, because children is already serialized:
// ClientLayout.tsx — "use client"
export function ClientLayout({ children }: { children: React.ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex">
<Sidebar open={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
<main>{children}</main> {/* children can be Server Components */}
</div>
);
}4. File-Based Routing Conventions
Dynamic segments: app/products/[id]/page.tsx → /products/123
Catch-all: app/docs/[...slug]/page.tsx → /docs/a/b/c
Optional catch-all: app/docs/[[...slug]]/page.tsx → matches /docs too
Route groups: app/(marketing)/about/page.tsx — group routes without affecting the URL path. Use for:
- Applying different layouts to different route groups
- Organizing routes logically
- Splitting the root layout
Parallel routes: app/@modal/login/page.tsx — render multiple pages simultaneously in the same layout. Used for modals, split views, conditional content.
Intercepting routes: app/feed/(..)photo/[id]/page.tsx — show a route in a different context (e.g., photo modal over feed, full page on direct navigation).
5. Server Actions for Mutations
Server Actions are async functions that run on the server, called directly from client or server components. Defined with "use server" directive:
// actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
// Validate input — Server Actions are public HTTP endpoints
if (!title || title.length > 200) {
return { error: "Invalid title" };
}
await db.post.create({ data: { title, content } });
revalidatePath("/posts");
redirect("/posts");
}Key rules:
- Server Actions are POST endpoints — always validate and authorize input
- They work without JavaScript (progressive enhancement) when used with
<form action> - Use
revalidatePathorrevalidateTagafter mutations to update cached data - Return serializable data only (no functions, classes, or Dates)
6. Middleware
Middleware runs before every request at the edge. Use it for:
- Authentication checks and redirects
- Geolocation-based routing
- A/B testing (cookie-based)
- Request/response header manipulation
// middleware.ts (root of project)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const token = request.cookies.get("session")?.value;
if (!token && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*"],
};Keep middleware thin. It runs on every matched request at the edge. Don't do database queries or heavy computation. Validate JWTs, check cookies, redirect — that's it.
7. Caching: The Four Layers
Next.js has four caching layers. Understanding them prevents the most common bugs:
Request Memoization — duplicate fetch calls in the same render are automatically deduplicated. If three components call fetch("/api/user"), only one request is made.
Data Cache — fetch responses are cached on the server across requests. Persists until revalidated. Opt out with { cache: "no-store" }.
Full Route Cache — statically rendered routes are cached as HTML + RSC payload at build time. Dynamic routes are rendered on every request.
Router Cache — client-side cache of visited routes. Layouts persist, pages refresh on navigation. Use router.refresh() to clear.
8. Revalidation Strategies
Time-based (ISR):
// Revalidate every 60 seconds
fetch(url, { next: { revalidate: 60 } });
// Or at the segment level
export const revalidate = 60;On-demand:
// In a Server Action or Route Handler
import { revalidatePath, revalidateTag } from "next/cache";
revalidatePath("/products"); // Revalidate a specific path
revalidateTag("products"); // Revalidate all fetches with this tag
// Tag a fetch for targeted revalidation
fetch(url, { next: { tags: ["products"] } });Static vs dynamic rendering:
- If all data is cached or static → route is statically rendered at build time
- If any data is dynamic (
cookies(),headers(),searchParams,no-store) → route is dynamically rendered per request
9. Streaming with Suspense
Streaming sends HTML progressively as Server Components resolve. The user sees content as it becomes ready rather than waiting for everything.
Automatic: loading.tsx creates a Suspense boundary for the entire segment.
Explicit: Use <Suspense> boundaries for granular control:
export default async function Page() {
return (
<div>
<h1>Dashboard</h1>
{/* Fast — renders immediately */}
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
{/* Slow — streams in when ready */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
</div>
);
}Each Suspense boundary streams independently. The page shell and fast content arrive first, slow sections fill in progressively.
10. Route Handlers (API Routes)
Route Handlers define server-side API endpoints:
// app/api/products/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const category = searchParams.get("category");
const products = await db.product.findMany({
where: category ? { category } : undefined,
});
return NextResponse.json(products);
}
export async function POST(request: NextRequest) {
const body = await request.json();
// Validate with Zod...
const product = await db.product.create({ data: body });
return NextResponse.json(product, { status: 201 });
}When to use Route Handlers vs Server Actions:
- Server Actions → mutations triggered from UI (forms, buttons)
- Route Handlers → webhooks, third-party API integrations, endpoints consumed by external clients
11. Image and Font Optimization
next/image handles responsive images, lazy loading, format conversion (WebP/AVIF), and CLS prevention:
import Image from "next/image";
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={630}
priority // Set for LCP image — disables lazy loading
className="rounded-lg"
/>
// Fill mode for unknown dimensions
<div className="relative h-64 w-full">
<Image src={url} alt={alt} fill className="object-cover" />
</div>next/font loads fonts with zero layout shift:
// app/layout.tsx
import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"] });
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}12. Metadata API and SEO
Generate metadata statically or dynamically:
// Static metadata
export const metadata: Metadata = {
title: "My App",
description: "App description",
};
// Dynamic metadata
export async function generateMetadata(
{ params }: { params: Promise<{ id: string }> },
): Promise<Metadata> {
const { id } = await params;
const product = await getProduct(id);
return {
title: product.name,
description: product.description,
openGraph: {
title: product.name,
description: product.description,
images: [product.image],
},
};
}Use generateMetadata for any page with dynamic content (product pages, blog posts, user profiles).
13. Edge vs Node.js Runtime and Deployment
Node.js runtime (default): Full Node.js APIs, no size limits, all npm packages. Use for most routes.
Edge runtime: V8 isolates, limited APIs (no fs, no native modules), cold starts in milliseconds. Use for middleware and latency-critical Route Handlers.
// Opt into edge runtime for a route
export const runtime = "edge";Deployment options:
- Vercel — zero-config, optimized caching, edge functions, image optimization CDN
- Standalone build —
output: "standalone"innext.config.ts, produces a minimal Node.js server - Docker — multi-stage build with standalone output, ideal for self-hosted environments
LLM Instructions
Scaffolding a Next.js App
When generating a Next.js project structure:
- Use the
app/directory exclusively (notpages/) - Create
layout.tsxat each level that needs shared UI - Use route groups
(groupName)for organizing without URL impact - Include
loading.tsxanderror.tsxfor every data-dependent route - Put metadata exports in every
page.tsxandlayout.tsx
Server vs Client Component Decisions
Follow this rule: default to Server Component. Add "use client" only when the component needs:
useState,useEffect,useRef, or any React hook- Event handlers (
onClick,onChange,onSubmit) - Browser APIs (
window,document,localStorage) - Third-party libraries that use hooks or browser APIs
If only a small part of a page needs interactivity, extract just that part into a client component. Keep the page itself as a Server Component.
Data Fetching Patterns
For data fetching in Server Components:
awaitdirectly in the component body — nouseEffect, nouseState- Use
fetchwithnext.revalidateornext.tagsfor cache control - Use
Promise.allfor parallel fetches that don't depend on each other - Wrap slow sections in
<Suspense>with skeleton fallbacks - For Prisma/Drizzle, call the ORM directly (no fetch needed)
For mutations:
- Use Server Actions with
"use server"directive - Call
revalidatePathorrevalidateTagafter the mutation - Use
useActionStatefor form state feedback - Use
useOptimisticfor instant UI updates
Caching Configuration
When setting up caching:
- Static pages with no dynamic data → let Next.js cache fully (default)
- Data that changes occasionally → ISR with
revalidate: 60(or appropriate interval) - Data that must be fresh →
cache: "no-store"ordynamic = "force-dynamic" - After mutations → always call
revalidatePathorrevalidateTag - User-specific data (cookies, auth) → route becomes dynamic automatically
API Routes and Middleware
For Route Handlers:
- Use typed
NextRequestandNextResponse - Validate request bodies with Zod before processing
- Return appropriate status codes (201 for creation, 204 for deletion)
- Handle errors with try/catch and structured error responses
For middleware:
- Keep it lean — only auth checks, redirects, header manipulation
- Always use the
matcherconfig to avoid running on static assets - Never do database queries in middleware
Image, Font, and Metadata
- Always use
next/imageinstead of<img>— setpriorityon the LCP image - Always use
next/font— import and apply in root layout - Add
generateMetadatafor every dynamic page - Include
openGraphmetadata for pages shared on social media
Examples
1. App Router File Structure
A complete production layout showing route groups, parallel routes, and conventions:
app/
├── layout.tsx # Root layout (font, providers)
├── page.tsx # Home page
├── not-found.tsx # Global 404
├── error.tsx # Global error boundary
├── (marketing)/
│ ├── layout.tsx # Marketing layout (nav + footer)
│ ├── page.tsx # Landing page
│ ├── about/page.tsx
│ ├── pricing/page.tsx
│ └── blog/
│ ├── page.tsx # Blog index
│ └── [slug]/page.tsx # Blog post
├── (app)/
│ ├── layout.tsx # App layout (sidebar + topbar)
│ ├── dashboard/
│ │ ├── page.tsx
│ │ ├── loading.tsx # Dashboard skeleton
│ │ └── error.tsx # Dashboard error UI
│ ├── settings/
│ │ ├── page.tsx
│ │ └── layout.tsx # Settings sub-nav
│ └── @modal/
│ ├── default.tsx # Parallel route default
│ └── (.)invite/page.tsx # Intercepted modal
├── api/
│ ├── webhooks/stripe/route.ts # Webhook handler
│ └── og/route.tsx # OG image generation
└── middleware.ts # Auth + redirects2. Server Component with Parallel Data Fetching
Fetching multiple data sources in parallel without client-side waterfalls:
// app/(app)/dashboard/page.tsx
import { Suspense } from "react";
import { StatsSkeleton, ChartSkeleton, ActivitySkeleton } from "./skeletons";
export const metadata = {
title: "Dashboard",
};
export default function DashboardPage() {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Dashboard</h1>
<Suspense fallback={<StatsSkeleton />}>
<StatsSection />
</Suspense>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<Suspense fallback={<ChartSkeleton />}>
<div className="lg:col-span-2">
<RevenueChart />
</div>
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
</div>
);
}
// Each component fetches its own data independently
async function StatsSection() {
const stats = await getStats(); // Cached, revalidates every 60s
return (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{stats.map(stat => (
<div key={stat.label} className="rounded-lg border p-4">
<p className="text-sm text-gray-500">{stat.label}</p>
<p className="text-2xl font-bold">{stat.value}</p>
</div>
))}
</div>
);
}
async function RevenueChart() {
const data = await getRevenueData(); // Heavier query, separate Suspense
return (
<div className="rounded-lg border p-6">
<h2 className="mb-4 text-lg font-semibold">Revenue</h2>
{/* Chart component here */}
</div>
);
}
async function RecentActivity() {
const activities = await getRecentActivity();
return (
<div className="rounded-lg border p-6">
<h2 className="mb-4 text-lg font-semibold">Recent Activity</h2>
<ul className="space-y-3">
{activities.map(activity => (
<li key={activity.id} className="flex items-center gap-3 text-sm">
<span className="text-gray-500">{activity.time}</span>
<span>{activity.description}</span>
</li>
))}
</ul>
</div>
);
}3. Server Action Form with Validation
A complete form using Server Actions with Zod validation and error handling:
// lib/validations/post.ts
import { z } from "zod";
export const createPostSchema = z.object({
title: z.string().min(1, "Title is required").max(200, "Title too long"),
content: z.string().min(10, "Content must be at least 10 characters"),
category: z.enum(["tech", "design", "business"]),
});
export type CreatePostInput = z.infer<typeof createPostSchema>;// actions/posts.ts
"use server";
import { revalidateTag } from "next/cache";
import { redirect } from "next/navigation";
import { createPostSchema } from "@/lib/validations/post";
import { auth } from "@/lib/auth";
export type PostActionState = {
errors?: Record<string, string[]>;
message?: string;
};
export async function createPost(
prevState: PostActionState,
formData: FormData,
): Promise<PostActionState> {
// Authenticate
const session = await auth();
if (!session?.user) {
return { message: "Unauthorized" };
}
// Validate
const result = createPostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
category: formData.get("category"),
});
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
// Create
await db.post.create({
data: { ...result.data, authorId: session.user.id },
});
revalidateTag("posts");
redirect("/posts");
}// app/(app)/posts/new/page.tsx
"use client";
import { useActionState } from "react";
import { createPost, type PostActionState } from "@/actions/posts";
const initialState: PostActionState = {};
export default function NewPostPage() {
const [state, formAction, isPending] = useActionState(createPost, initialState);
return (
<form action={formAction} className="mx-auto max-w-2xl space-y-6">
<div>
<label htmlFor="title" className="block text-sm font-medium">
Title
</label>
<input
id="title"
name="title"
className="mt-1 w-full rounded border px-3 py-2"
aria-describedby={state.errors?.title ? "title-error" : undefined}
/>
{state.errors?.title && (
<p id="title-error" className="mt-1 text-sm text-red-600">
{state.errors.title[0]}
</p>
)}
</div>
<div>
<label htmlFor="category" className="block text-sm font-medium">
Category
</label>
<select id="category" name="category" className="mt-1 w-full rounded border px-3 py-2">
<option value="tech">Tech</option>
<option value="design">Design</option>
<option value="business">Business</option>
</select>
</div>
<div>
<label htmlFor="content" className="block text-sm font-medium">
Content
</label>
<textarea
id="content"
name="content"
rows={8}
className="mt-1 w-full rounded border px-3 py-2"
aria-describedby={state.errors?.content ? "content-error" : undefined}
/>
{state.errors?.content && (
<p id="content-error" className="mt-1 text-sm text-red-600">
{state.errors.content[0]}
</p>
)}
</div>
{state.message && (
<p className="text-sm text-red-600">{state.message}</p>
)}
<button
type="submit"
disabled={isPending}
className="rounded bg-blue-600 px-6 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
>
{isPending ? "Publishing..." : "Publish Post"}
</button>
</form>
);
}4. Authentication Middleware
Middleware that checks session tokens and protects routes:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { verifyToken } from "@/lib/auth/verify";
const publicPaths = ["/", "/login", "/signup", "/about", "/pricing"];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Skip public paths
if (publicPaths.some(path => pathname === path || pathname.startsWith("/api/public"))) {
return NextResponse.next();
}
// Check session
const token = request.cookies.get("session")?.value;
if (!token) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
try {
const payload = await verifyToken(token);
// Add user info to headers for downstream use
const response = NextResponse.next();
response.headers.set("x-user-id", payload.userId);
response.headers.set("x-user-role", payload.role);
return response;
} catch {
// Invalid token — clear cookie and redirect
const response = NextResponse.redirect(new URL("/login", request.url));
response.cookies.delete("session");
return response;
}
}
export const config = {
matcher: [
// Match all paths except static files and Next.js internals
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};5. Dynamic Metadata for SEO
Generating metadata for a product page with Open Graph and structured data:
// app/(marketing)/products/[slug]/page.tsx
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import Image from "next/image";
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const product = await getProduct(slug);
if (!product) return { title: "Product Not Found" };
return {
title: `${product.name} | MyStore`,
description: product.description.slice(0, 160),
openGraph: {
title: product.name,
description: product.description.slice(0, 160),
images: [
{
url: product.image,
width: 1200,
height: 630,
alt: product.name,
},
],
type: "website",
},
twitter: {
card: "summary_large_image",
title: product.name,
description: product.description.slice(0, 160),
images: [product.image],
},
};
}
// Generate static paths at build time
export async function generateStaticParams() {
const products = await getAllProductSlugs();
return products.map(slug => ({ slug }));
}
export default async function ProductPage({ params }: Props) {
const { slug } = await params;
const product = await getProduct(slug);
if (!product) notFound();
// JSON-LD structured data
const jsonLd = {
"@context": "https://schema.org",
"@type": "Product",
name: product.name,
description: product.description,
image: product.image,
offers: {
"@type": "Offer",
price: product.price,
priceCurrency: "USD",
availability: product.inStock
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<div className="mx-auto max-w-4xl">
<Image
src={product.image}
alt={product.name}
width={800}
height={600}
priority
className="rounded-lg"
/>
<h1 className="mt-6 text-3xl font-bold">{product.name}</h1>
<p className="mt-2 text-2xl text-gray-700">${product.price}</p>
<p className="mt-4 text-gray-600">{product.description}</p>
</div>
</>
);
}6. next/image and next/font Setup
Configuring images and fonts in the root layout:
// app/layout.tsx
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import localFont from "next/font/local";
import "./globals.css";
// Google font with variable weight
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-sans",
});
// Monospace for code blocks
const mono = JetBrains_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-mono",
});
// Local font example
const brand = localFont({
src: [
{ path: "../public/fonts/brand-regular.woff2", weight: "400" },
{ path: "../public/fonts/brand-bold.woff2", weight: "700" },
],
variable: "--font-brand",
display: "swap",
});
export const metadata: Metadata = {
title: { default: "MyApp", template: "%s | MyApp" },
description: "My application description",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${mono.variable} ${brand.variable}`}>
<body className="font-sans antialiased">{children}</body>
</html>
);
}/* globals.css — Reference font variables */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
}
}// next.config.ts — Image configuration
import type { NextConfig } from "next";
const config: NextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "images.example.com",
},
{
protocol: "https",
hostname: "cdn.example.com",
pathname: "/uploads/**",
},
],
formats: ["image/avif", "image/webp"],
},
};
export default config;Common Mistakes
1. Making the Root Layout a Client Component
Wrong:
"use client"; // Now EVERYTHING in the app is a client component
export default function RootLayout({ children }) { ... }Fix: Keep the root layout as a Server Component. Extract interactive parts (theme toggle, mobile nav) into small client components.
2. Using useEffect for Data Fetching in Server Components
Wrong:
"use client";
export default function Products() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch("/api/products").then(r => r.json()).then(setProducts);
}, []);
return <ProductList products={products} />;
}Fix: Fetch directly in a Server Component:
export default async function Products() {
const products = await db.product.findMany();
return <ProductList products={products} />;
}3. Misunderstanding "use client" Scope
Wrong thinking: "use client" means the component ONLY runs on the client.
Reality: "use client" components are SSR'd on the server AND hydrated on the client. The directive marks the boundary between the Server Component and Client Component module graphs.
4. Not Validating Server Action Input
Wrong:
"use server";
export async function deleteUser(formData: FormData) {
const id = formData.get("id") as string;
await db.user.delete({ where: { id } }); // No auth check, no validation
}Fix: Always authenticate and validate:
"use server";
export async function deleteUser(formData: FormData) {
const session = await auth();
if (session?.user.role !== "admin") throw new Error("Forbidden");
const { id } = z.object({ id: z.string().uuid() }).parse({
id: formData.get("id"),
});
await db.user.delete({ where: { id } });
revalidatePath("/users");
}5. Fighting the Cache Instead of Understanding It
Wrong:
// "Nothing updates!" — using no-store everywhere
export const dynamic = "force-dynamic"; // Nuclear option
export const fetchCache = "force-no-store";Fix: Understand the caching layers and use targeted revalidation:
// Tag your fetches
const products = await fetch(url, { next: { tags: ["products"] } });
// Revalidate after mutations
"use server";
export async function createProduct(data: FormData) {
await db.product.create({ data: validated });
revalidateTag("products"); // Only this data refreshes
}6. Importing Server-Only Code in Client Components
Wrong:
"use client";
import { db } from "@/lib/db"; // Prisma client bundled into client JavaScript!Fix: Use the server-only package to catch this at build time:
// lib/db.ts
import "server-only";
import { PrismaClient } from "@prisma/client";
export const db = new PrismaClient();7. Oversized Middleware
Wrong:
export async function middleware(request: NextRequest) {
const user = await db.user.findUnique({ where: { id: token.sub } }); // DB call in middleware
const permissions = await getPermissions(user.role); // Another DB call
// ...complex logic
}Fix: Middleware should only check tokens and redirect. Move complex logic to Server Components or Route Handlers:
export async function middleware(request: NextRequest) {
const token = request.cookies.get("session")?.value;
if (!token) return NextResponse.redirect(new URL("/login", request.url));
return NextResponse.next();
}8. Using <img> Instead of next/image
Wrong:
<img src="/hero.png" alt="Hero" /> // No optimization, no lazy loading, causes CLSFix:
import Image from "next/image";
<Image src="/hero.png" alt="Hero" width={1200} height={630} priority />9. Hardcoded Metadata Instead of generateMetadata
Wrong:
// app/products/[id]/page.tsx
export const metadata = {
title: "Product Page", // Same title for every product
};Fix:
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const product = await getProduct(id);
return { title: product.name, description: product.description };
}10. Not Using Route Groups
Wrong:
app/
├── layout.tsx # One layout for marketing AND app — messy
├── page.tsx
├── about/page.tsx
├── dashboard/page.tsx
├── settings/page.tsxFix:
app/
├── layout.tsx # Root: just fonts + providers
├── (marketing)/
│ ├── layout.tsx # Marketing nav + footer
│ ├── page.tsx
│ └── about/page.tsx
├── (app)/
│ ├── layout.tsx # App sidebar + topbar
│ ├── dashboard/page.tsx
│ └── settings/page.tsx11. One Giant Client Component Per Page
Wrong:
"use client"; // Entire page is a client component
export default function SettingsPage() {
// 500 lines of mixed data display and interactivity
}Fix: Keep the page as a Server Component, extract only interactive parts:
// page.tsx — Server Component
export default async function SettingsPage() {
const settings = await getSettings();
return (
<div>
<h1>Settings</h1>
<ProfileDisplay profile={settings.profile} /> {/* Server */}
<NotificationToggle initial={settings.notifications} /> {/* Client */}
<ThemeSelector current={settings.theme} /> {/* Client */}
</div>
);
}12. Missing loading.tsx and error.tsx
Wrong: No loading or error files — users see a blank screen during data fetching and an unhandled error crashes the page.
Fix: Add these files to every data-dependent route:
// loading.tsx
export default function Loading() {
return (
<div className="space-y-4">
<div className="h-8 w-48 animate-pulse rounded bg-gray-200" />
<div className="h-64 animate-pulse rounded bg-gray-200" />
</div>
);
}
// error.tsx
"use client";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="rounded-lg border border-red-200 bg-red-50 p-6">
<h2 className="text-lg font-semibold text-red-800">Something went wrong</h2>
<button onClick={reset} className="mt-4 rounded bg-red-600 px-4 py-2 text-white">
Try again
</button>
</div>
);
}See also: React Fundamentals | Data Fetching | CSS Architecture | Performance | TypeScript-React | Forms & Validation | Frontend Security | SEO
Last reviewed: 2026-02
By Ryan Lind, Assisted by Claude Code and Google Gemini.