Illustration showing a modern web app layout with file-based routing
Frontend DevelopmentUpdated: 10 min read

By Akshay Singh

Share this article:

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 params prop or useParams in 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 state
  • useEffect — 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:

FilePurposeWhen it shows
loading.tsxSkeleton/spinner UIDuring route transitions while data loads
error.tsxError boundary UIWhen a runtime error occurs in the route
not-found.tsx404 UIWhen 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:

  1. Start with the App Router — don't use the Pages Router for new projects
  2. Default to Server Components — add "use client" only when you need interactivity
  3. Use route groups to organize your code by feature, not by file type
  4. Handle every edge case with loading.tsx, error.tsx, and not-found.tsx
  5. Configure metadata for every public-facing route

The framework handles routing, rendering, optimization, and bundling. You focus on building features.


Keep Learning

nextjsapp routerreactfullstackweb development
TheDailyDevsTheDailyDevs
TheDailyDevs is a developer-first blog and knowledge hub created by passionate engineers to share real-world development tips, deep-dive tutorials, industry insights, and hands-on solutions to everyday coding challenges. Whether you're building apps, exploring new frameworks, or leveling up your dev game, you'll find practical, no-fluff content here, updated daily.