By Akshay Singh
What's New in Next.js 14.3+ — App Router Best Practices
Next.js has entered a new era. The App Router is now the default recommended approach for building production-ready applications, and with version 14.3+, it's more stable, faster, and feature-rich than ever.
Whether you're starting a new project or migrating from the Pages Router, this guide covers what's actually changed in 14.3+ and the best practices you should follow to build clean, performant applications.
What's New in Next.js 14.3+
1. Segment-Level Caching Control
Next.js 14.3 gives you finer control over caching at the individual route segment level. You can now decide exactly which parts of your app should be cached and which should be rendered dynamically — without complex middleware or external configuration.
// Force a specific route to always render dynamically
export const dynamic = "force-dynamic";
// Or force it to be statically generated
export const dynamic = "force-static";
// Revalidate cached data every 60 seconds
export const revalidate = 60;
This is particularly powerful when you have a layout that's mostly static but contains one dynamic section. For example, a product page where the product info is cached but the inventory count needs to be fresh:
// app/products/[id]/page.tsx
export const revalidate = 3600; // Revalidate product info every hour
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id); // Cached
return (
<div>
<ProductInfo product={product} />
<Suspense fallback={<Spinner />}>
<InventoryStatus productId={params.id} /> {/* Dynamic */}
</Suspense>
</div>
);
}
2. Partial Prerendering (PPR) Improvements
Partial Prerendering lets you serve a static shell instantly while streaming dynamic content as it becomes ready. In 14.3+, PPR is more stable and predictable.
How it works in practice: the static parts of your page (navigation, layout, headings, cached content) are served immediately as HTML. Dynamic parts (user-specific data, real-time info) are streamed in using React Suspense boundaries.
import { Suspense } from "react";
export default function BlogPost({ params }: { params: { slug: string } }) {
return (
<article>
{/* Static: rendered at build time */}
<BlogContent slug={params.slug} />
{/* Dynamic: streamed after the initial static shell */}
<Suspense fallback={<p>Loading comments...</p>}>
<Comments slug={params.slug} />
</Suspense>
</article>
);
}
The result: your page loads instantly (static content), and the dynamic parts fill in seamlessly. This gives you excellent LCP scores while still serving fresh data.
3. Improved Streaming and Server Component Stability
Server Components now stream more reliably, especially during client-side navigation and hydration. This reduces time-to-first-byte (TTFB) and creates a smoother user experience on slower connections.
Key improvements in 14.3+:
- More predictable hydration — fewer mismatches between server and client renders
- Better error recovery — Server Component errors are caught more gracefully
- Improved navigation — streaming works correctly across shared layouts during page transitions
- Lower memory usage — server-rendered components are more efficiently garbage collected
4. Faster Dev Server and Hot Reloading
The development experience is noticeably improved. Hot module replacement (HMR) is faster, especially on larger projects with deeply nested routes and shared layouts.
The dev server also uses less memory and handles incremental compilation better, so you spend less time waiting for rebuilds during development.
5. Stabilized Routing APIs
Functions like generateMetadata, generateStaticParams, and route segment configuration (dynamic, revalidate, fetchCache) are now more predictable across development and production environments.
// These APIs are now fully stable and reliable
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
images: [post.coverImage],
},
};
}
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
Best Practices for the App Router
1. Organize with a Clean Folder Structure
Use route groups (parenthesized folders) to organize your routes logically without affecting the URL structure:
app/
layout.tsx # Root layout (shared across all routes)
page.tsx # Homepage (/)
(marketing)/ # Route group — doesn't create a URL segment
about/
page.tsx # /about
contact/
page.tsx # /contact
pricing/
page.tsx # /pricing
(dashboard)/ # Route group — separate layout
layout.tsx # Dashboard layout (sidebar, etc.)
page.tsx # /dashboard
settings/
page.tsx # /settings
users/
page.tsx # /users
[id]/
page.tsx # /users/123
(blog)/ # Route group for blog
layout.tsx # Blog-specific layout
page.tsx # /blog (listing)
[slug]/
page.tsx # /blog/my-article
loading.tsx # Shown while loading
not-found.tsx # Shown for invalid slugs
This structure keeps your codebase organized as it grows. Route groups help separate concerns (marketing pages have a different layout from the dashboard) without polluting your URLs.
2. Use layout.tsx for Shared UI
Layouts persist across page navigations — they don't unmount and remount. This makes them ideal for shared UI like sidebars, navigation bars, and page shells:
// app/(dashboard)/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1 p-6">{children}</main>
</div>
);
}
Key points about layouts:
- They don't re-render when navigating between child routes — only the
{children}part changes - They can be nested — a dashboard layout inside a root layout
- They can fetch data independently — each layout can have its own data requirements
- They can't access route params from child segments (use
paramsprop oruseParamsin client components)
3. Keep Components Server-First
In the App Router, components are Server Components by default. This means they render on the server, send HTML to the client, and ship zero JavaScript for that component.
Only add "use client" when you actually need browser APIs:
// ✅ Server Component (default) — no JavaScript shipped to client
async function RecentPosts() {
const posts = await db.posts.findMany({ take: 10 });
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
// ❌ Don't do this — unnecessary client component
"use client";
import { useEffect, useState } from "react";
function RecentPosts() {
const [posts, setPosts] = useState([]);
useEffect(() => {
fetch("/api/posts")
.then((r) => r.json())
.then(setPosts);
}, []);
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
The first version sends zero JavaScript to the browser. The second ships React, the component code, and makes an extra API call. For data display, Server Components are almost always the right choice.
When to use "use client":
useState,useReducer— component has interactive stateuseEffect— component needs browser-side effects- Event handlers —
onClick,onChange,onSubmit - Browser APIs —
localStorage,window,document - Third-party libraries that use React context or hooks
4. Configure Metadata per Route
Every page should have its own metadata. The App Router makes this straightforward with static or dynamic metadata exports:
// Static metadata
export const metadata = {
title: "User Settings",
description: "Manage your profile, preferences, and account settings",
};
// Dynamic metadata (when you need data from params or fetch)
export async function generateMetadata({ params }: { params: { slug: string } }) {
const product = await getProduct(params.slug);
return {
title: `${product.name} — MyStore`,
description: product.description,
openGraph: {
images: [product.image],
},
};
}
Use the title template in your root layout so you don't repeat the site name everywhere:
// app/layout.tsx
export const metadata = {
title: {
default: "MyApp",
template: "%s | MyApp", // Child pages: "Settings | MyApp"
},
};
5. Handle Edge Cases with Route-Specific Files
The App Router has special files for handling loading states, errors, and not-found pages at the route level:
| File | Purpose | When it shows |
|---|---|---|
loading.tsx | Skeleton/spinner UI | During route transitions while data loads |
error.tsx | Error boundary UI | When a runtime error occurs in the route |
not-found.tsx | 404 UI | When notFound() is called or route doesn't exist |
// app/blog/[slug]/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4" />
<div className="h-4 bg-gray-200 rounded w-full mb-2" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
</div>
);
}
// app/blog/[slug]/error.tsx
"use client"; // Error components must be client components
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div>
<h2>Something went wrong loading this article.</h2>
<button onClick={() => reset()}>Try again</button>
</div>
);
}
6. Use generateStaticParams for Dynamic Routes
For dynamic routes like /blog/[slug], use generateStaticParams to pre-render pages at build time:
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
This generates a static HTML file for each post during the build. Combined with ISR, you get the best of both worlds — fast static pages that can update without a full rebuild:
// Revalidate this page every 60 seconds
export const revalidate = 60;
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
return <Article post={post} />;
}
Common Mistakes to Avoid
1. Overusing "use client"
Every component you mark with "use client" increases the JavaScript bundle sent to the browser. Only add it when you genuinely need browser interactivity. If a component just displays data, keep it as a Server Component.
2. Fetching data in useEffect when Server Components exist
If your component doesn't need client-side interactivity, fetch data directly in the component body as a Server Component. It's simpler, faster, and sends less JavaScript to the client.
3. Ignoring generateMetadata
SEO doesn't happen by accident. Every public-facing page needs a title, description, and Open Graph metadata. The Metadata API makes this easy — use it.
4. Duplicating UI instead of using layouts
If you find yourself copying the same header/sidebar/footer across multiple pages, you need a layout. Layouts are specifically designed for shared UI that persists across navigation.
5. Not handling loading and error states
Users see a blank screen while your data loads? That's a loading state problem. Add loading.tsx files to show skeletons. Add error.tsx files to catch crashes gracefully.
6. Using redirect() instead of notFound() for missing resources
When a blog post doesn't exist, call notFound() — not redirect("/"). The notFound() function returns a proper 404 status code, which tells search engines the page doesn't exist. A redirect hides the problem.
Real-World Example: Blog + Dashboard
Here's a production-ready folder structure for a site that has both a public blog and a private dashboard:
app/
layout.tsx # Root layout (nav, footer, theme)
page.tsx # Homepage
globals.css # Global styles
(blog)/
layout.tsx # Blog layout (reading-optimized)
page.tsx # Blog listing (/blog)
[slug]/
page.tsx # Individual post (/blog/my-post)
loading.tsx # Skeleton while post loads
not-found.tsx # 404 for invalid slugs
(dashboard)/
layout.tsx # Dashboard layout (sidebar, auth check)
page.tsx # Dashboard home
settings/
page.tsx # User settings
analytics/
page.tsx # Analytics dashboard
posts/
page.tsx # Post management
new/
page.tsx # Create new post
[id]/
edit/
page.tsx # Edit existing post
api/
posts/
route.ts # API: GET/POST /api/posts
[id]/
route.ts # API: GET/PUT/DELETE /api/posts/:id
Each route group has its own layout. The blog uses a reading-focused layout with a narrow content column. The dashboard has a sidebar with navigation. Both share the root layout for global elements like the theme provider and analytics.
Final Thoughts
Next.js 14.3+ represents a mature, production-ready framework. The App Router is no longer experimental — it's the recommended way to build Next.js applications, and the tooling around it has become reliable and predictable.
If you're starting a new project with Next.js in 2025:
- Start with the App Router — don't use the Pages Router for new projects
- Default to Server Components — add
"use client"only when you need interactivity - Use route groups to organize your code by feature, not by file type
- Handle every edge case with
loading.tsx,error.tsx, andnot-found.tsx - Configure metadata for every public-facing route
The framework handles routing, rendering, optimization, and bundling. You focus on building features.
Keep Learning
- Not sure whether to use React or Next.js? Read our React vs Next.js comparison for 2025 to make an informed choice.
- Need global state in your Next.js app? Learn how to integrate Redux Toolkit with React.
- Building for international users? Follow our complete React localization guide.
- Ready to deploy? Learn how to host a static site on a VPS using Apache.
