startupbricks logo

Startupbricks

Next.js App Router Best Practices 2025

Next.js App Router Best Practices 2025

2025-01-16
5 min read
Frontend Development

Here's what confuses every Next.js developer in 2025:

"Should I use Server Components or Client Components?"

App Router changed everything. Some articles say "use Server Components everywhere." Others say "it's over-hyped."

Let me give you clear answer: It depends on your use case.

This guide shows you exactly when to use each—with real examples for 2025.


App Router vs Pages Router: What Changed

Let's understand the shift first.

Pages Router (Next.js 12 and earlier)

javascript
// pages/index.js
export default function Home() {
return <div>Hello World</div>;
}

Characteristics:

  • Everything is Client Component by default
  • getServerSideProps, getStaticProps for data fetching
  • Server rendering but less control
  • More client JavaScript sent to browser

App Router (Next.js 14+)

javascript
// app/page.js
export default function Home() {
return <div>Hello World</div>;
}

Characteristics:

  • Server Components by default (no client JS sent)
  • async/await for data fetching (simpler)
  • Nested layouts and routing
  • Streaming support
  • Better performance (smaller bundles)

The Key Difference: App Router sends zero JavaScript to browser by default. Pages Router sends everything.


Server Components: Use by Default

Server Components are the biggest win in Next.js 14.

What Are Server Components?

  • Render on server (not in browser)
  • Zero client JavaScript for component itself
  • Direct database access (no API calls)
  • Secret access (environment variables safe)
  • Automatic streaming for slow data

Performance Gain: 30-50% smaller client bundles, faster initial loads

When to Use Server Components

Use For:

  • Data fetching (databases, APIs)
  • Components that don't need interactivity
  • SEO-critical content
  • Heavy computations
  • Sensitive data (can't leak to client)

Server Component Example

javascript
// app/products/page.js
async function getProducts() {
// Direct database access (no API!)
const products = await db.product.findMany();
return products;
}
// Server Component by default
export default async function ProductsPage() {
const products = await getProducts();
return (
<div>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}

What Happens:

  • Query runs on server
  • HTML sent to browser with products rendered
  • Zero JavaScript sent for this component
  • Client sees content instantly

Client Components: Use When Needed

Client Components are for interactivity.

What Are Client Components?

  • Render in browser
  • Full access to browser APIs
  • State and effects allowed
  • 'use client' directive required

When to Use Client Components

Use For:

  • Interactivity (onClick, onChange, etc.)
  • Browser APIs (window, localStorage, etc.)
  • State (useState, useEffect)
  • Third-party libraries that require browser
  • Real-time features (WebSockets, etc.)

Client Component Example

javascript
// app/components/Counter.js
"use client"; // Required!
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
</div>
);
}

What Happens:

  • Component sends as JavaScript to browser
  • Renders in browser (not server)
  • Full React capabilities available

Mixing Server and Client Components

Real apps need both. Here's how to mix them properly.

Architecture: Server Parent, Client Children

javascript
// app/users/page.js (Server Component)
async function getUsers() {
return await db.user.findMany();
}
export default async function UsersPage() {
const users = await getUsers();
return (
<div>
<h1>Users</h1>
{/* Client child for interactivity */}
<UserTable users={users} />
</div>
);
}
// app/components/UserTable.js (Client Component)
'use client';
export default function UserTable({ users }) {
const [sortConfig, setSortConfig] = useState({ column: null, direction: 'asc' });
const [filter, setFilter] = useState('');
const sortedUsers = useMemo(() => {
if (!sortConfig.column) return users;
return [...users].sort((a, b) => {
const multiplier = sortConfig.direction === 'asc' ? 1 : -1;
return a[sortConfig.column] > b[sortConfig.column] ? multiplier : -multiplier;
});
}, [users, sortConfig]);
return (
<table>
<thead>
<tr>
<th onClick={() => setSortConfig({ column: 'name', direction: sortConfig.direction === 'asc' ? 'desc' : 'asc' })}>
Name
</th>
<th onClick={() => setSortConfig({ column: 'email', direction: sortConfig.direction === 'asc' ? 'desc' : 'asc' })}>
Email
</th>
</tr>
</thead>
<tbody>
<input
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder="Filter users..."
/>
{sortedUsers
.filter(user => user.name.toLowerCase().includes(filter.toLowerCase()))
.map(user => (
<tr key={user.id}>
<td>{user.name}</td>
<td>{user.email}</td>
</tr>
))}
</tbody>
</table>
);
}

What Happens:

  • UsersPage (server) fetches data, renders static HTML
  • UserTable (client) hydrates in browser, adds interactivity
  • Server JavaScript = 0, Client JavaScript = small bundle

Data Fetching in App Router

App Router simplifies data fetching significantly.

Fetching in Server Components

javascript
// app/dashboard/page.js
async function getDashboardData() {
const [user, stats, notifications] = await Promise.all([
db.user.findUnique({ where: { id: userId } }),
db.stats.findMany({ where: { userId } }),
db.notification.findMany({ where: { userId, read: false } }),
]);
return { user, stats, notifications };
}
export default async function Dashboard() {
const data = await getDashboardData();
return (
<div>
<UserStats stats={data.stats} />
<Notifications notifications={data.notifications} />
</div>
);
}

Fetching in Client Components

javascript
// app/components/RealtimeNotifications.js
"use client";
import { useEffect, useState } from "react";
export default function RealtimeNotifications() {
const [notifications, setNotifications] = useState([]);
useEffect(() => {
// Browser API: fetch in browser
const eventSource = new EventSource("/api/notifications");
eventSource.onmessage = (event) => {
const notification = JSON.parse(event.data);
setNotifications((prev) => [...prev, notification]);
};
return () => eventSource.close();
}, []);
return <NotificationList notifications={notifications} />;
}

Parallel Data Fetching

javascript
export default async function Page() {
// Fetches in parallel
const [products, categories, recommendations] = await Promise.all([
getProducts(),
getCategories(),
getRecommendations(),
]);
return (
<PageContent
products={products}
categories={categories}
recommendations={recommendations}
/>
);
}

Routing in App Router

File-based routing is simpler and more powerful.

File-Based Routing Structure

text
app/
├── layout.js # Root layout
├── page.js # Home (/)
├── about/
│ └── page.js # About (/about)
├── blog/
│ ├── page.js # Blog index (/blog)
│ ├── [slug]/ # Dynamic route (/blog/:slug)
│ │ └── page.js
│ └── category/
│ └── [id]/ # Nested dynamic route (/blog/category/:id)
│ └── page.js
├── (group)/ # Route groups
│ └── dashboard/
│ └── page.js # /dashboard (without /group in URL)
└── api/
└── route.js # API routes

Dynamic Routes

javascript
// app/blog/[slug]/page.js
export default async function BlogPost({ params }) {
const { slug } = params;
const post = await db.post.findUnique({ where: { slug } });
if (!post) {
notFound();
}
return <PostContent post={post} />;
}

Route Groups

javascript
// app/(marketing)/layout.js
export default function MarketingLayout({ children }) {
return (
<div className="marketing-layout">
{children}
</div>
);
}
// app/(marketing)/about/page.js
export default function About() {
return <div>About page (inside marketing layout)</div>;
}
// app/(dashboard)/layout.js
export default function DashboardLayout({ children }) {
return (
<div className="dashboard-layout">
{children}
</div>
);
}

Streaming: The Performance Superpower

App Router supports React Server Components streaming.

What Is Streaming?

Server sends HTML in chunks as it's generated. Browser renders each chunk immediately.

Without Streaming:

[Wait 3 seconds for entire page...]
[Display entire page at once]

With Streaming:

[Display header immediately]
[Display hero after 0.5s]
[Display content after 1s]
[Display sidebar after 2s]

Performance Gain: Perceived load time 50-80% faster even if total time is same.

Automatic Streaming

javascript
// app/slow-page/page.js
export default async function SlowPage() {
// These fetch in parallel and stream as ready
const [header, content, footer] = await Promise.all([
getHeader(), // Takes 0.5s
getContent(), // Takes 2s
getFooter(), // Takes 0.3s
]);
return (
<>
<header data={header} />
<content data={content} />
<footer data={footer} />
</>
);
}

What Happens:

  • Header streams immediately (0.5s)
  • Content streams when ready (2s)
  • Footer streams when ready (0.3s)
  • User sees progress, not blank screen

Server Actions: Form Handling Simplified

Server Actions make form handling secure and simple.

Server Action Example

javascript
// app/actions.js
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function createPost(formData) {
// Validate input
const title = formData.get("title");
const content = formData.get("content");
if (!title || !content) {
return { error: "Title and content are required" };
}
// Direct database access
const post = await db.post.create({
data: { title, content },
});
// Revalidate cache
revalidatePath("/");
// Redirect
redirect(`/blog/${post.slug}`);
}

Use in Component

javascript
// app/blog/new/page.js
import { createPost } from "../actions";
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" placeholder="Title" />
<textarea name="content" placeholder="Content" />
<button type="submit">Create Post</button>
</form>
);
}

Benefits:

  • No client-side validation needed (happens on server)
  • No API routes to create
  • Direct database access
  • Automatic form validation
  • Progressives enhancement (works without JS)

Caching in App Router

App Router has built-in caching.

Request Deduplication

javascript
// Same component called multiple times
export default async function Page() {
// This runs ONCE per request
const data = await getData();
return <Content data={data} />;
}

Full Route Cache

javascript
// app/page.js
export const revalidate = 3600; // Revalidate every hour
export default async function Page() {
const data = await fetchData(); // Cached for hour
return <Content data={data} />;
}

On-Demand Revalidation

javascript
// app/api/create-post/route.js
import { revalidatePath } from "next/cache";
export async function POST(req) {
const post = await createPost(req.body);
// Revalidate specific page
revalidatePath("/blog");
return Response.json({ success: true });
}

App Router Best Practices

1. Server Components First

Start with Server Component. Add 'use client' only when needed.

javascript
// Good: Server by default
export default function Page() {
return <div>{data}</div>;
}
// Add client only when interactivity needed
'use client';
export default function Interactive() {
const [state, setState] = useState();
return <button onClick={() => setState(!state)}>{state ? 'On' : 'Off'}</button>;
}

2. Keep Client Components Small

Split large client components into smaller pieces.

javascript
// Bad: Everything in one client component
"use client";
export default function HugeComponent() {
// 500 lines of state, effects, hooks
}
// Good: Split into focused components
// UserProfile.js (client)
// UserSettings.js (client)
// UserNotifications.js (client)

3. Move State Down

Keep state as low in tree as possible.

javascript
// Bad: State in parent, passed through 3 levels
<Parent state={state}>
<Child1 state={state}>
<Child2 state={state} />
</Child1>
</Parent>
// Good: State where needed
<Parent>
<Child1 />
<Child2 />
</Parent>

4. Use Loading and Streaming

Don't block entire page on one slow request.

javascript
export default async function Page() {
return (
<Suspense fallback={<Skeleton />}>
<SlowComponent />
</Suspense>
);
}

5. Optimize Images

Use next/image for automatic optimization.

javascript
import Image from "next/image";
export default function Page() {
return (
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={630}
priority
/>
);
}

Migration Guide: Pages to App Router

Step 1: Create app/ directory

bash
mkdir app

Step 2: Move pages to app structure

bash
# Before
pages/index.js
pages/about.js
pages/blog/[slug].js
# After
app/page.js
app/about/page.js
app/blog/[slug]/page.js

Step 3: Remove getServerSideProps/getStaticProps

javascript
// Before (pages router)
export async function getServerSideProps() {
return { props: { data } };
}
// After (app router)
export default async function Page() {
const data = await getData();
return <PageContent data={data} />;
}

Step 4: Update imports and routing

javascript
// Before
import Link from "next/link";
import { useRouter } from "next/router";
// After
import Link from "next/link";
import { useRouter } from "next/navigation";

Step 5: Test thoroughly

  • Test all pages render correctly
  • Test data fetching works
  • Test client components have interactivity
  • Test routing works
  • Test forms submit correctly

Common App Router Mistakes

1. Adding 'use client' everywhere

Mistake: Marking everything as client component

Reality: Defeats purpose of App Router (performance)

Fix: Use 'use client' only when interactivity needed

2. Fetching data in useEffect in Server Components

Mistake:

javascript
export default function Page() {
const [data, setData] = useState();
useEffect(() => {
fetchData().then(setData);
}, []);
return <div>{data}</div>;
}

Reality: Extra round trip, doesn't use streaming

Fix:

javascript
export default async function Page() {
const data = await fetchData();
return <div>{data}</div>;
}

3. Not using Suspense for streaming

Mistake: No streaming, page waits for everything

Reality: Worse perceived performance

Fix:

javascript
export default function Page() {
return (
<Suspense fallback={<Loading />}>
<SlowComponent />
</Suspense>
);
}

Related Reading

If you found this helpful, you might also enjoy:


Need Help with Next.js App Router?

At Startupbricks, we've built dozens of Next.js apps with App Router. We know common pitfalls, performance optimizations, and best practices.

Whether you need:

  • Full Next.js application build
  • Migration from Pages to App Router
  • Performance optimization
  • Server component architecture

Let's talk about building with App Router.

Ready for App Router? Download our free App Router Checklist and start today.

Share: