Next.js 10 min read

Next.js App Router Best Practices From Production Projects

Server Components, caching, layouts, Server Actions, and error handling — App Router patterns I rely on after shipping Next.js apps for US and EU clients.

By Omprakash Tanwar
Next.js application architecture diagram on developer screen

The App Router is powerful and easy to misuse. I’ve taken over Next.js 14 projects where every file started with "use client", data was fetched in useEffect inside Client Components, and the caching model was ignored entirely — effectively a client-side SPA with extra steps and a server bill.

I’ve also shipped App Router projects that load in under a second, handle mutations through Server Actions, and scale to thousands of pages with ISR. The difference isn’t the framework version. It’s understanding which pieces run on the server, which run on the client, and how Next.js caches between them.

This article documents the patterns I use on client projects — the ones that survive code review, perform in production, and don’t break when Next.js ships another minor update.

Server Components by Default

In the App Router, every component is a Server Component unless you add "use client". This is the most important rule.

Server Components:

  • Run only on the server — zero client JavaScript
  • Can directly access databases, file systems, and secrets
  • Can await data fetching without useEffect
  • Cannot use hooks, event handlers, or browser APIs
// app/blog/[slug]/page.tsx — Server Component (no directive needed)
import { getPost } from "@/lib/posts";
import { notFound } from "next/navigation";

interface PageProps {
  params: Promise<{ slug: string }>;
}

export default async function BlogPost({ params }: PageProps) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time>
      <div dangerouslySetInnerHTML={{ __html: post.html }} />
    </article>
  );
}

Push "use client" to the leaves of your component tree — buttons, forms, interactive widgets — not the page root.

The Client Boundary Pattern

Structure files so the server/client split is obvious:

app/
├── dashboard/
│   ├── page.tsx              # Server Component — fetches data
│   ├── DashboardStats.tsx    # Server Component — renders stats
│   ├── ActivityFeed.tsx      # Client Component — real-time updates
│   └── ExportButton.tsx      # Client Component — download trigger
// app/dashboard/page.tsx
import { getDashboardData } from "@/lib/data";
import DashboardStats from "./DashboardStats";
import ActivityFeed from "./ActivityFeed";

export default async function DashboardPage() {
  const data = await getDashboardData();

  return (
    <div>
      <DashboardStats stats={data.stats} />
      <ActivityFeed initialActivities={data.recent} />
    </div>
  );
}
// app/dashboard/ActivityFeed.tsx
"use client";

import { useState, useEffect } from "react";

export default function ActivityFeed({ initialActivities }) {
  const [activities, setActivities] = useState(initialActivities);

  useEffect(() => {
    const interval = setInterval(async () => {
      const fresh = await fetch("/api/activities").then((r) => r.json());
      setActivities(fresh);
    }, 30_000);
    return () => clearInterval(interval);
  }, []);

  return (
    <ul>
      {activities.map((a) => (
        <li key={a.id}>{a.message}</li>
      ))}
    </ul>
  );
}

The page fetches data on the server and passes it as props. The client component handles only the polling interactivity. Clean boundary, minimal client JS.

Data Fetching and the Caching Model

Next.js 15 changed default fetch caching behavior. Understand what you’re opting into:

// Static — cached indefinitely (until revalidation)
const data = await fetch("https://api.example.com/posts", {
  cache: "force-cache",
});

// ISR — cached with time-based revalidation
const data = await fetch("https://api.example.com/posts", {
  next: { revalidate: 3600 },
});

// Dynamic — fresh on every request
const data = await fetch("https://api.example.com/posts", {
  cache: "no-store",
});

For database queries (not fetch), use route segment config:

// Revalidate this page every hour
export const revalidate = 3600;

// Or force dynamic rendering
export const dynamic = "force-dynamic";

My default strategy for client projects:

Page typeStrategy
Marketing pagesStatic (force-cache or default SSG)
Blog postsISR (revalidate: 3600 or on-demand)
Product catalogISR with short revalidation (300–900s)
User dashboardDynamic (no-store)
API routesDepends on data freshness needs

Parallel Data Fetching

Avoid waterfall requests — fetch in parallel:

// Bad — sequential waterfalls
export default async function Page() {
  const user = await getUser();
  const posts = await getPosts(user.id);
  const comments = await getComments(user.id);
  // ...
}

// Good — parallel fetching
export default async function Page() {
  const [user, posts, comments] = await Promise.all([
    getUser(),
    getPosts(),
    getComments(),
  ]);
  // ...
}

For independent data within a page, Promise.all is the simplest win. For streaming partial content, use React Suspense boundaries.

Streaming With Suspense

Show the page shell immediately while slow data loads:

import { Suspense } from "react";
import { PostListSkeleton } from "@/components/skeletons";

export default function BlogPage() {
  return (
    <div>
      <h1>Blog</h1>
      <Suspense fallback={<PostListSkeleton />}>
        <PostList />
      </Suspense>
    </div>
  );
}

async function PostList() {
  const posts = await getPosts(); // slow query
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

Each Suspense boundary streams independently. The header renders immediately. The post list streams when the database responds. Users see content faster — LCP improves even if total load time is the same.

Layouts and Template Patterns

Layouts persist across navigations. Templates re-mount on every navigation. Use layouts for shared UI:

// app/dashboard/layout.tsx
import { Sidebar } from "@/components/Sidebar";
import { getSession } from "@/lib/auth";

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getSession();

  return (
    <div className="flex">
      <Sidebar user={session.user} />
      <main className="flex-1 p-6">{children}</main>
    </div>
  );
}

Don’t fetch data in layouts that children also need — layouts don’t re-render on child navigation, so data can go stale. Fetch in the page or use a shared cache.

For loading states, use loading.tsx:

// app/dashboard/loading.tsx
import { DashboardSkeleton } from "@/components/skeletons";

export default function Loading() {
  return <DashboardSkeleton />;
}

This automatically wraps the page in a Suspense boundary. Instant UX improvement with one file.

Server Actions for Mutations

Server Actions replace many API routes for form handling:

// app/actions/contact.ts
"use server";

import { z } from "zod";
import { revalidatePath } from "next/cache";

const contactSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  message: z.string().min(10),
});

export async function submitContact(formData: FormData) {
  const parsed = contactSchema.safeParse({
    name: formData.get("name"),
    email: formData.get("email"),
    message: formData.get("message"),
  });

  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors };
  }

  await db.contact.create({ data: parsed.data });
  revalidatePath("/contact");
  return { success: true };
}
// app/contact/ContactForm.tsx
"use client";

import { useActionState } from "react";
import { submitContact } from "@/app/actions/contact";

export default function ContactForm() {
  const [state, action, isPending] = useActionState(submitContact, null);

  return (
    <form action={action}>
      <input name="name" required />
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit" disabled={isPending}>
        {isPending ? "Sending..." : "Send message"}
      </button>
      {state?.error && <p className="error">Please fix the errors above.</p>}
      {state?.success && <p className="success">Message sent!</p>}
    </form>
  );
}

Server Actions give you server-side validation, database access, and cache revalidation without maintaining separate API routes. I use them for forms, toggles, and simple mutations. For complex APIs consumed by mobile apps, I still use Route Handlers.

Route Handlers for APIs

// app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getSession } from "@/lib/auth";

export async function GET(request: NextRequest) {
  const session = await getSession();
  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const posts = await db.post.findMany({
    where: { authorId: session.user.id },
    orderBy: { createdAt: "desc" },
  });

  return NextResponse.json(posts);
}

Keep Route Handlers thin — auth check, validate input, call service layer, return response. Business logic belongs in lib/.

Error Handling

Use error boundaries at the route level:

// app/dashboard/error.tsx
"use client";

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div role="alert">
      <h2>Something went wrong</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}
// app/not-found.tsx
import Link from "next/link";

export default function NotFound() {
  return (
    <div>
      <h2>Page not found</h2>
      <Link href="/">Return home</Link>
    </div>
  );
}

Call notFound() in Server Components when data doesn’t exist — it renders not-found.tsx with a 404 status.

Metadata API

Replace manual <head> tags with the Metadata API:

// app/blog/[slug]/page.tsx
import type { Metadata } from "next";

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) return { title: "Post not found" };

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.coverImage, width: 1200, height: 630 }],
      type: "article",
      publishedTime: post.publishedAt,
    },
    alternates: {
      canonical: `https://example.com/blog/${slug}`,
    },
  };
}

Static metadata for pages that don’t need dynamic generation:

export const metadata: Metadata = {
  title: "About Us",
  description: "Learn about our team and mission.",
};

Middleware for Auth and Redirects

// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getToken } from "next-auth/jwt";

export async function middleware(request: NextRequest) {
  const token = await getToken({ req: request });
  const isAuthPage = request.nextUrl.pathname.startsWith("/login");
  const isProtected = request.nextUrl.pathname.startsWith("/dashboard");

  if (isProtected && !token) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  if (isAuthPage && token) {
    return NextResponse.redirect(new URL("/dashboard", request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard/:path*", "/login"],
};

Keep middleware fast — it runs on every matched request at the edge. No database calls; use JWT or session cookies.

File Organization I Use

app/                    # Routes only — thin pages
├── (marketing)/        # Route group — shared layout, no URL segment
├── (dashboard)/
├── api/
└── actions/            # Server Actions

components/
├── ui/                 # Generic UI (Button, Card, Input)
├── features/           # Feature-specific (ProductCard, UserAvatar)
└── layouts/            # Layout components

lib/
├── db.ts               # Database client
├── auth.ts             # Auth utilities
├── data/               # Data access functions
└── utils.ts            # Shared utilities

Route groups (marketing) and (dashboard) share layouts without affecting URLs. Server Actions live in app/actions/ for colocation with routes but are importable anywhere.

Common App Router Mistakes

"use client" on page.tsx. You’ve just made the entire page client-rendered. Push the directive down.

Fetching in useEffect when Server Components can fetch directly. Adds loading states, error handling, and client JS for no reason.

Ignoring caching defaults. Next.js 15 defaults to no-store for fetch. If you expect static pages, set caching explicitly.

Giant Client Components. A 400-line "use client" file usually needs splitting into server wrapper + small client islands.

Not using loading.tsx and error.tsx. Free UX improvements you’re leaving on the table.

Waterfall data fetching. Sequential await calls in Server Components when Promise.all would work.

Conclusion

The App Router rewards developers who think in server/client boundaries, understand the caching model, and keep Client Components small and leaf-level. It’s not a harder Pages Router — it’s a different architecture that happens to use the same framework name.

The projects I’m proudest of use Server Components for 80% of the UI, Server Actions for mutations, Suspense for streaming, and Client Components only where the browser is genuinely required. That ratio produces fast, maintainable Next.js apps that clients can afford to host and developers can afford to maintain.

Key Takeaways

  • Default to Server Components; add "use client" only at the leaves for interactivity
  • Fetch data in Server Components with parallel Promise.all — avoid useEffect fetching
  • Choose caching explicitly: static for marketing, ISR for catalogs, dynamic for dashboards
  • Use Suspense boundaries and loading.tsx for streaming and instant perceived performance
  • Server Actions handle form mutations without separate API routes
  • Metadata API replaces manual head tags with type-safe, per-route SEO configuration
  • Keep middleware fast — auth checks via JWT, no database calls at the edge
  • Organize with route groups, colocated actions, and thin page files that delegate to lib/
Table of Contents