Next.js (latest stable) best practices. Use when working on Next.js projects, including App Router, Server and Client Components, Route Handlers, data fetching, metadata, and environment configuration. Auto-activates when next.config.ts is present or files are in app/, server/, pages/.
app/
layout.tsx # Root layout (always a Server Component)
page.tsx # Root page
(auth)/ # Route group — no URL segment
login/
page.tsx
dashboard/
layout.tsx # Nested layout
page.tsx
loading.tsx # Suspense boundary
error.tsx # Error boundary (must be Client Component)
components/
ui/ # Shared presentational components
features/ # Feature-specific components
lib/
actions/ # Server Actions
api/ # Shared API utilities
db/ # Database access layer
app/api/ # Route Handlers (REST endpoints)
users/
route.ts
[id]/
route.ts
Server Components are the default. Only add "use client" when the component needs:
window, document, localStorage)onClick, onChange)useState, useEffect, useContext)// ✅ Server Component — data fetching, no interactivity (default)
// app/dashboard/page.tsx
export default async function DashboardPage() {
const user = await db.user.findFirst(); // runs on the server
return <UserProfile user={user} />;
}
// ✅ Client Component — only where interactivity is needed
// components/ui/Counter.tsx
"use client";
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
Push "use client" as far down the tree as possible — keep parents as Server Components.
// ✅ Fetch directly in Server Components — no useEffect, no API route needed
async function UserPage({ params }: { params: { id: string } }) {
const user = await db.user.findUnique({ where: { id: params.id } });
if (!user) notFound();
return <UserProfile user={user} />;
}
// ✅ Parallel fetching with Promise.all to avoid waterfalls
const [user, posts] = await Promise.all([
db.user.findUnique({ where: { id } }),
db.post.findMany({ where: { authorId: id } }),
]);
Never fetch data in Client Components when the parent can be a Server Component.
Use Server Actions for mutations — no need for a separate API route for form submissions:
// lib/actions/user.ts
"use server";
import { revalidatePath } from "next/cache";
export async function updateUser(formData: FormData) {
const name = formData.get("name") as string;
// Validate input at the boundary
if (!name || name.length < 2) {
return { error: "Name must be at least 2 characters" };
}
await db.user.update({ where: { id: session.userId }, data: { name } });
revalidatePath("/dashboard");
}
// Usage in a Server Component form
// app/dashboard/settings/page.tsx
import { updateUser } from "@/lib/actions/user";
export default function SettingsPage() {
return (
<form action={updateUser}>
<input name="name" />
<button type="submit">Save</button>
</form>
);
}
Use Route Handlers for external-facing REST APIs (webhooks, mobile clients, third parties). Not for internal data fetching — use Server Components for that.
// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(
_req: NextRequest,
{ params }: { params: { id: string } }
) {
const user = await db.user.findUnique({ where: { id: params.id } });
if (!user) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(user);
}
Always return typed NextResponse.json(). Never return raw Response objects.
// Static metadata
export const metadata: Metadata = {
title: "Dashboard",
description: "User dashboard",
};
// Dynamic metadata
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const user = await db.user.findUnique({ where: { id: params.id } });
return { title: user?.name ?? "Not found" };
}
Every page must export metadata or generateMetadata. Never leave pages without titles.
// next.config.ts
const nextConfig = {
env: {
// Never expose server secrets to the client
},
};
// ✅ Server-only (default — no prefix)
process.env.DATABASE_URL
process.env.SECRET_KEY
// ✅ Client-safe (must be prefixed NEXT_PUBLIC_)
process.env.NEXT_PUBLIC_API_URL
Validate environment variables at startup with a typed schema:
// lib/env.ts
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().url(),
SECRET_KEY: z.string().min(32),
NEXT_PUBLIC_API_URL: z.string().url(),
});
export const env = envSchema.parse(process.env);
"use client" only when requiredmetadata or generateMetadatatsc --noEmit before finishing any task