10 Next.js Practices I Wish I Knew Earlier
Patterns that survive production traffic, not just localhost demos
1. Stop using "use client" by default
Server Components are the default in Next.js App Router for a reason. Every time you add "use client", you're shipping that component's JavaScript to the browser. Push interactivity to the smallest leaf components possible.
// Bad: entire page is a client component
"use client";
export default function DashboardPage() { ... }
// Good: server page with a tiny client island
export default async function DashboardPage() {
const stats = await getStats(); // runs on server, zero JS shipped
return (
<div>
<StatsDisplay stats={stats} />
<RefreshButton /> {/* only this ships JS */}
</div>
);
}2. Colocate data fetching with the component that needs it
Don't prop-drill data three levels deep. In App Router, any server component can be async and fetch its own data. Next.js deduplicates identical fetch calls automatically within a single render pass.
async function Sidebar() {
const categories = await getCategories();
return (
<nav>
{categories.map(c => (
<Link key={c.id} href={`/${c.slug}`}>{c.name}</Link>
))}
</nav>
);
}3. Use loading.tsx instead of manual loading states
Drop a loading.tsx file next to any page.tsx and Next.js wraps it in a Suspense boundary for you. No useState(true), no skeleton component wiring.
// app/dashboard/loading.tsx
export default function Loading() {
return <div className="animate-pulse h-64 bg-gray-100 rounded-xl" />;
}4. Validate environment variables at build time
A missing env var that crashes at runtime is a production incident. A missing env var that crashes at build time is a deployment rejection. Use Zod to validate your env on module load:
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().url(),
SESSION_SECRET: z.string().min(32),
NEXT_PUBLIC_APP_URL: z.string().url(),
});
export const env = envSchema.parse(process.env);5. Use revalidatePath instead of client-side cache invalidation
After a mutation (creating a post, updating settings), don't call router.refresh() or window.location.reload(). Use Next.js cache invalidation from Server Actions or Route Handlers:
"use server";
import { revalidatePath } from "next/cache";
export async function publishPost(postId: string) {
await db.post.update({ where: { id: postId }, data: { published: true } });
revalidatePath("/dashboard");
revalidatePath(`/posts/${postId}`);
}6. Put API logic in Server Actions, not Route Handlers
Route Handlers (app/api/.../route.ts) are great for webhooks, OAuth callbacks, and third-party integrations. But for your own form submissions and mutations, Server Actions eliminate the boilerplate of fetch, JSON parsing, and error handling:
async function createPost(formData: FormData) {
"use server";
const title = formData.get("title") as string;
await db.post.create({ data: { title } });
redirect("/dashboard");
}
export default function NewPostForm() {
return (
<form action={createPost}>
<input name="title" required />
<button type="submit">Publish</button>
</form>
);
}7. Use dynamic imports for heavy client libraries
Editors, chart libraries, syntax highlighters — anything over 50KB that only renders on the client should be dynamically imported:
import dynamic from "next/dynamic";
const Editor = dynamic(() => import("@/components/editor"), {
ssr: false,
loading: () => <div className="h-96 animate-pulse bg-gray-50 rounded-lg" />,
});8. Set proper caching headers for API routes
Next.js doesn't cache Route Handler responses by default. For public data that doesn't change every request, set explicit cache headers:
export async function GET() {
const posts = await getPublicPosts();
return Response.json(posts, {
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=300",
},
});
}9. Use notFound() instead of conditional rendering
When a resource doesn't exist, don't render an inline "not found" message. Call notFound() from next/navigation — it triggers your not-found.tsx page and returns a proper 404 status code, which matters for SEO:
import { notFound } from "next/navigation";
export default async function PostPage({ params }) {
const post = await getPost(params.id);
if (!post) notFound(); // proper 404, not a 200 with "not found" text
return <Article post={post} />;
}10. Use Middleware sparingly
Middleware runs on every request at the edge. It's perfect for redirects, auth checks, and geolocation routing. It's terrible for database queries, heavy computation, or anything that belongs in a server component. Keep it thin:
// middleware.ts
export function middleware(request: NextRequest) {
const session = request.cookies.get("session");
if (!session && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/api/auth/login", request.url));
}
}
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*", "/write/:path*"],
};The principle behind all of these
Every practice here follows one idea: let the framework do the work. Next.js has opinions about caching, rendering, and routing. When you fight those opinions, you write more code and get worse performance. When you lean into them, you write less code and ship faster pages.