Next.js Best Practices Skill
Next.js 14+ App Router patterns for production applications
PRINCIPLES
- •Server-first: Default to Server Components
- •Streaming: Use Suspense for progressive rendering
- •Colocation: Keep related files together
- •Type-safe: Full TypeScript support
APP ROUTER ARCHITECTURE
File Conventions
code
app/
├── layout.tsx # Root layout (required)
├── page.tsx # Home page
├── loading.tsx # Loading UI
├── error.tsx # Error boundary
├── not-found.tsx # 404 page
├── global-error.tsx # Global error boundary
├── (marketing)/ # Route group (no URL impact)
│ ├── about/page.tsx
│ └── contact/page.tsx
├── dashboard/
│ ├── layout.tsx # Nested layout
│ ├── page.tsx
│ └── settings/
│ └── page.tsx
└── api/
└── users/
└── route.ts # API route
Route Groups
typescript
// (auth) - groups auth-related routes without affecting URL // app/(auth)/login/page.tsx -> /login // app/(auth)/register/page.tsx -> /register // Use for: // 1. Organizing routes by feature // 2. Different layouts for route sections // 3. Parallel routes
Dynamic Routes
typescript
// app/blog/[slug]/page.tsx
interface Props {
params: { slug: string };
searchParams: { [key: string]: string | string[] | undefined };
}
export default function BlogPost({ params, searchParams }: Props) {
return <article>Post: {params.slug}</article>;
}
// Generate static params for SSG
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({ slug: post.slug }));
}
SERVER COMPONENTS
Default Behavior
typescript
// Server Component (default - no directive needed)
async function ProductList() {
// Direct database access - no API needed
const products = await db.product.findMany();
// Sensitive operations safe on server
const apiKey = process.env.STRIPE_SECRET_KEY;
return (
<ul>
{products.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
);
}
Data Fetching Patterns
typescript
// Parallel data fetching (recommended)
async function Dashboard() {
// Initiates both requests simultaneously
const [user, posts] = await Promise.all([
getUser(),
getPosts()
]);
return (
<div>
<UserProfile user={user} />
<PostList posts={posts} />
</div>
);
}
// Sequential (when data depends on previous)
async function UserPosts({ userId }: { userId: string }) {
const user = await getUser(userId);
const posts = await getPosts(user.id); // Needs user.id
return <PostList posts={posts} />;
}
Streaming with Suspense
typescript
// app/dashboard/page.tsx
import { Suspense } from 'react';
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* Instant - no data fetching */}
<WelcomeMessage />
{/* Streams when ready */}
<Suspense fallback={<ChartSkeleton />}>
<Analytics />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders />
</Suspense>
</div>
);
}
CLIENT COMPONENTS
When to Use
typescript
// Use 'use client' for:
// ✅ Interactivity (onClick, onChange)
// ✅ State (useState, useReducer)
// ✅ Effects (useEffect)
// ✅ Browser APIs (localStorage, window)
// ✅ Custom hooks with state
'use client';
import { useState, useTransition } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
const [isPending, startTransition] = useTransition();
const handleClick = () => {
startTransition(() => {
setCount(c => c + 1);
});
};
return (
<button onClick={handleClick} disabled={isPending}>
Count: {count}
</button>
);
}
Composition Pattern
typescript
// Server Component wraps Client Component
// app/products/page.tsx (Server)
import { ProductFilters } from './filters'; // Client
async function ProductsPage() {
const products = await getProducts();
return (
<div>
{/* Client Component for interactivity */}
<ProductFilters />
{/* Server-rendered list */}
<ProductList products={products} />
</div>
);
}
// Pass Server Component as children
export function ClientWrapper({ children }: { children: React.ReactNode }) {
return <div className="interactive">{children}</div>;
}
SERVER ACTIONS
Form Handling
typescript
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
const CreatePostSchema = z.object({
title: z.string().min(1).max(100),
content: z.string().min(10),
});
export async function createPost(formData: FormData) {
// Validate
const validated = CreatePostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
});
if (!validated.success) {
return { error: validated.error.flatten().fieldErrors };
}
// Create
await db.post.create({ data: validated.data });
// Revalidate and redirect
revalidatePath('/posts');
redirect('/posts');
}
// Usage in component
export function CreatePostForm() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">Create</button>
</form>
);
}
With useFormState
typescript
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { createPost } from './actions';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Post'}
</button>
);
}
export function CreatePostForm() {
const [state, formAction] = useFormState(createPost, null);
return (
<form action={formAction}>
<input name="title" />
{state?.error?.title && <p>{state.error.title}</p>}
<textarea name="content" />
{state?.error?.content && <p>{state.error.content}</p>}
<SubmitButton />
</form>
);
}
METADATA & SEO
Static Metadata
typescript
// app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: {
default: 'My App',
template: '%s | My App', // "About | My App"
},
description: 'My amazing application',
keywords: ['Next.js', 'React', 'TypeScript'],
authors: [{ name: 'Author' }],
openGraph: {
title: 'My App',
description: 'My amazing application',
url: 'https://myapp.com',
siteName: 'My App',
images: [{ url: '/og-image.png', width: 1200, height: 630 }],
locale: 'en_US',
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: 'My App',
description: 'My amazing application',
images: ['/twitter-image.png'],
},
robots: {
index: true,
follow: true,
},
};
Dynamic Metadata
typescript
// app/blog/[slug]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next';
interface Props {
params: { slug: string };
}
export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata
): Promise<Metadata> {
const post = await getPost(params.slug);
const previousImages = (await parent).openGraph?.images || [];
return {
title: post.title,
description: post.excerpt,
openGraph: {
images: [post.image, ...previousImages],
},
};
}
CACHING & REVALIDATION
Fetch Caching
typescript
// Force cache (default for GET)
const data = await fetch('https://api.example.com/data');
// No cache
const data = await fetch('https://api.example.com/data', {
cache: 'no-store',
});
// Time-based revalidation
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 }, // Revalidate every hour
});
// Tag-based revalidation
const data = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
});
// Revalidate by tag
import { revalidateTag } from 'next/cache';
revalidateTag('posts');
// Revalidate by path
import { revalidatePath } from 'next/cache';
revalidatePath('/posts');
ANTI-PATTERNS
❌ Avoid
typescript
// ❌ Using 'use client' for everything
'use client';
export function StaticContent() {
return <div>This doesn't need client JS</div>;
}
// ❌ Fetching in useEffect for initial data
'use client';
export function Products() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch('/api/products').then(res => res.json()).then(setProducts);
}, []);
return <ProductList products={products} />;
}
// ❌ Passing server-only code to client
'use client';
import { db } from './db'; // ❌ Database in client!
// ❌ Prop drilling instead of composition
function Parent() {
const data = await getData();
return <Child data={data} />; // Deep prop drilling
}
✅ Prefer
typescript
// ✅ Server Component by default
export function StaticContent() {
return <div>No client JS needed</div>;
}
// ✅ Fetch in Server Component
async function Products() {
const products = await getProducts(); // Direct fetch
return <ProductList products={products} />;
}
// ✅ Composition pattern
async function Parent() {
const data = await getData();
return (
<ClientWrapper>
<ServerContent data={data} />
</ClientWrapper>
);
}
QUICK REFERENCE
| Feature | When to Use |
|---|---|
| Server Component | Data fetching, DB access, sensitive logic |
| Client Component | Interactivity, state, browser APIs |
| Server Action | Form mutations, data updates |
| Suspense | Streaming, loading states |
| Route Groups | Layout organization, parallel routes |
| Parallel Routes | Simultaneous slot rendering |
| Intercepting Routes | Modals, preview panels |