Next.js has become the go-to React framework for production applications. But using it effectively requires understanding its patterns and conventions. Here are the best practices every Next.js developer should follow.
Project Structure
Organize by Feature Inside the App Directory
src/
├── app/
│ ├── (marketing)/ # Route group - no URL impact
│ │ ├── page.tsx
│ │ ├── about/page.tsx
│ │ └── layout.tsx
│ ├── (dashboard)/
│ │ ├── dashboard/page.tsx
│ │ ├── settings/page.tsx
│ │ └── layout.tsx
│ ├── api/
│ │ └── users/route.ts
│ ├── layout.tsx
│ └── not-found.tsx
├── components/
│ ├── ui/ # Generic UI components
│ └── features/ # Feature-specific components
├── lib/ # Utilities, helpers, config
├── hooks/ # Custom React hooks
└── types/ # TypeScript typesUse Route Groups for Layout Organization
Route groups (folder) let you organize routes without affecting the URL:
// app/(auth)/login/page.tsx → /login
// app/(auth)/signup/page.tsx → /signup
// Both share app/(auth)/layout.tsx
Server vs Client Components
Default to Server Components
Server Components are the default in the App Router. Only add 'use client' when you need interactivity.
// Server Component (default) - no directive needed
// Can access databases, env vars, and fetch data directly
async function UserProfile({ userId }: { userId: string }) {
const user = await db.user.findUnique({ where: { id: userId } });
return <div>{user.name}</div>;
}// Client Component - only when needed
'use client';
import { useState } from 'react';
export function LikeButton() {
const [likes, setLikes] = useState(0);
return <button onClick={()=> setLikes(likes + 1)}>Likes: {likes}</button>;
}Push Client Boundaries Down
Keep 'use client' as low in the component tree as possible:
// Bad - entire page is a client component
'use client';
export default function Page() {
const [open, setOpen] = useState(false);
return (
<div>
<h1>Dashboard</h1> {/* Static - doesn't need client */}
<Stats /> {/* Static - doesn't need client */}
<Modal open={open} /> {/* Interactive - needs client */}
</div>
);
}
// Good - only the interactive part is client
export default function Page() {
return (
<div>
<h1>Dashboard</h1>
<Stats />
<ModalToggle /> {/* Only this is 'use client' */}
</div>
);
}Data Fetching
Fetch Data in Server Components
// app/users/page.tsx
async function UsersPage() {
const users = await fetch('https://api.example.com/users', {
next: { revalidate: 3600 }, // Revalidate every hour
}).then((res) => res.json());
return (
<ul>
{users.map((user: User) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}Use Server Actions for Mutations
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await db.post.create({ data: { title, content } });
revalidatePath('/posts');
}// app/posts/new/page.tsx
import { createPost } from '@/app/actions';
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">Create</button>
</form>
);
}Parallel Data Fetching
Don't await sequentially when requests are independent:
// Bad - sequential (slow)
async function Page() {
const user = await getUser();
const posts = await getPosts(); // Waits for user to finish
return <Dashboard user={user} posts={posts} />;
}
// Good - parallel (fast)
async function Page() {
const [user, posts] = await Promise.all([getUser(), getPosts()]);
return <Dashboard user={user} posts={posts} />;
}Caching and Revalidation
Understand the Caching Layers
// Static - cached at build time (default for static pages)
async function Page() {
const data = await fetch('https://api.example.com/data');
return <div>{data}</div>;
}
// Time-based revalidation
async function Page() {
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 60 }, // Revalidate every 60 seconds
});
return <div>{data}</div>;
}
// On-demand revalidation
import { revalidatePath, revalidateTag } from 'next/cache';
// In a Server Action or Route Handler
revalidatePath('/posts');
revalidateTag('posts');Use unstable_cache for Database Queries
import { unstable_cache } from 'next/cache';
const getUser = unstable_cache(
async (id: string) => {
return db.user.findUnique({ where: { id } });
},
['user'],
{ revalidate: 3600, tags: ['user'] },
);Performance
Use Dynamic Imports for Heavy Components
import dynamic from 'next/dynamic';
const Chart = dynamic(() => import('@/components/Chart'), {
loading: () => <div className="skeleton h-64" />,
ssr: false, // Skip SSR for browser-only components
});Optimize Images
Always use next/image instead of plain <img> tags:
import Image from 'next/image';
export function Hero() {
return (
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority // Preload above-the-fold images
placeholder="blur" // Show blur while loading
/>
);
}Optimize Fonts
// app/layout.tsx
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], display: 'swap' });
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}Use loading.tsx and Suspense
// app/dashboard/loading.tsx - automatic loading UI
export default function Loading() {
return <DashboardSkeleton />;
}
// Or use Suspense for granular control
import { Suspense } from 'react';
export default function Page() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
</div>
);
}SEO and Metadata
Use the Metadata API
// Static metadata
export const metadata: Metadata = {
title: 'My App',
description: 'Built with Next.js',
openGraph: {
title: 'My App',
description: 'Built with Next.js',
images: ['/og-image.png'],
},
};
// Dynamic metadata
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return {
title: post.title,
description: post.excerpt,
openGraph: { title: post.title, images: [post.coverImage] },
};
}
Generate Sitemaps
// app/sitemap.ts
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
return [
{ url: 'https://example.com', lastModified: new Date(), changeFrequency: 'daily' },
...posts.map((post) => ({
url: `https://example.com/blog/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: 'weekly' as const,
})),
];
}
Error Handling
Use Error Boundaries
// app/dashboard/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>
);
}Use not-found.tsx
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return <Article post={post} />;
}Middleware
Use Middleware for Auth and Redirects
// middleware.ts (project root)
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*'],
};Environment Variables
Follow the Naming Convention
# .env.local
# Server-only (default) - never exposed to browser
DATABASE_URL=postgresql://...
API_SECRET=...
# Client-accessible - must have NEXT_PUBLIC_ prefix
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_GA_ID=G-XXXXXXQuick Reference
| Practice | Why |
|---|---|
| Default to Server Components | Smaller bundles, direct data access |
Push 'use client' down | Minimize client JavaScript |
| Parallel data fetching | Faster page loads |
| Server Actions for mutations | Type-safe, progressive enhancement |
next/image for all images | Automatic optimization and lazy loading |
loading.tsx + Suspense | Better perceived performance |
| Metadata API for SEO | Type-safe, dynamic meta tags |
| Middleware for auth | Edge-fast redirects |
| Route groups for layouts | Clean URL structure |
Summary
The key principles for Next.js best practices:
- Server-first — use Server Components by default, client only when needed
- Fetch where you render — colocate data fetching with the component that uses it
- Cache smartly — use revalidation strategies that match your data freshness needs
- Progressive enhancement — Server Actions work without JavaScript
- Optimize assets — images, fonts, and dynamic imports
- Handle errors gracefully — error boundaries and not-found pages at every level
Want to level up your fundamentals too? Read Modern JavaScript: Writing Code That Doesn't Hurt for the core JS patterns that make Next.js code cleaner.