Next.js Best Practices with the App Router

Next.js has evolved significantly with the App Router (introduced in v13, stable in v14). The mental model shifts from page-level data fetching to component-level server rendering. Getting the most out of Next.js means understanding when to run code on the server, when to run it on the client, and how to structure your app to make both work together cleanly.

1. Understand Server vs Client Components

This is the most important concept in the App Router. Server Components are the default. They run only on the server and never ship their code to the browser.

// app/articles/page.tsx — Server Component (default)
// No "use client" — runs on server, can be async, can access DB directly
async function ArticlesPage() {
  const articles = await db.articles.findMany({ where: { published: true } });
  return (
    <ul>
      {articles.map(a => <ArticleCard key={a.id} article={a} />)}
    </ul>
  );
}

Use "use client" only when you need:

  • useState / useEffect / other React hooks
  • Browser APIs (window, localStorage, event listeners)
  • Third-party client-only libraries
"use client";

// InteractiveLikeButton.tsx — Client Component
import { useState } from "react";

export function LikeButton({ initialCount }: { initialCount: number }) {
  const [count, setCount] = useState(initialCount);
  return <button onClick={() => setCount(c => c + 1)}>❤ {count}</button>;
}

Push "use client" as far down the tree as possible. The rest of your component tree stays on the server. Leaner bundle, faster page.

2. Use the app/ Directory Conventions Correctly

The App Router uses filesystem-based routing with special file conventions:

app/
├── layout.tsx          ← Root layout — wraps all pages (persistent, not re-rendered on navigation)
├── page.tsx            ← Route's UI ("/")
├── loading.tsx         ← Instant loading UI while page streams in
├── error.tsx           ← Error boundary for this segment ("use client" required)
├── not-found.tsx       ← 404 for this segment
├── articles/
│   ├── page.tsx        ← "/articles"
│   └── [slug]/
│       └── page.tsx    ← "/articles/[slug]"
└── api/
    └── comments/
        └── route.ts    ← API Route handler (GET, POST, etc.)

Always define loading.tsx alongside data-fetching pages. Next.js uses React Suspense to show it instantly while the server fetches data.

3. Fetch Data at the Right Level

In the App Router, you fetch data where you need it: directly inside the Server Component that renders it. No prop drilling, no global state for server data.

// app/articles/[slug]/page.tsx
interface Props {
  params: { slug: string };
}

export default async function ArticlePage({ params }: Props) {
  const article = await getArticleBySlug(params.slug);

  if (!article) notFound(); // triggers not-found.tsx

  return <ArticleBody article={article} />;
}

// Generate static pages at build time
export async function generateStaticParams() {
  const articles = await getAllArticleSlugs();
  return articles.map(a => ({ slug: a.slug }));
}

Next.js automatically deduplicates identical fetch() calls within a single render. Call fetch("/api/user") in ten components and it only makes one request.

4. Cache and Revalidate Intentionally

Next.js extends the native fetch API with caching options. Use them explicitly. Do not rely on defaults blindly.

// Cached indefinitely (default) — good for static content
const res = await fetch("https://api.example.com/config");

// Revalidate every 60 seconds — good for semi-dynamic content
const res = await fetch("https://api.example.com/articles", {
  next: { revalidate: 60 },
});

// Never cache — good for user-specific or real-time data
const res = await fetch("https://api.example.com/cart", {
  cache: "no-store",
});

Use unstable_cache (or the stable cache in React) for non-fetch data sources like database queries:

import { unstable_cache } from "next/cache";

const getArticles = unstable_cache(
  async () => db.articles.findMany(),
  ["articles-list"],
  { revalidate: 300 }
);

5. Use next/image for All Images

Never use a plain <img> tag in Next.js. next/image handles lazy loading, size optimisation, WebP conversion, and layout shift prevention automatically.

import Image from "next/image";

function ArticleHeader({ src, title }: { src: string; title: string }) {
  return (
    <Image
      src={src}
      alt={title}
      width={1200}
      height={630}
      priority          // add for above-the-fold images (LCP element)
      className="rounded-xl"
    />
  );
}

For images with unknown dimensions (CMS-sourced), use fill with a positioned container:

<div className="relative h-64 w-full">
  <Image src={url} alt={alt} fill className="object-cover rounded-xl" />
</div>

Configure allowed external domains in next.config.js:

// next.config.js
const nextConfig = {
  images: {
    remotePatterns: [
      { protocol: "https", hostname: "cdn.mysite.com" },
    ],
  },
};

6. Use Route Handlers for API Endpoints

Replace pages/api/ with app/api/route.ts handlers. They support all HTTP methods as named exports and have access to the Web Request/Response APIs.

// app/api/articles/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const topic = searchParams.get("topic");

  const articles = await db.articles.findMany({
    where: topic ? { topic } : undefined,
  });

  return NextResponse.json(articles);
}

export async function POST(request: NextRequest) {
  const body = await request.json();
  const article = await db.articles.create({ data: body });
  return NextResponse.json(article, { status: 201 });
}

7. Handle Metadata Properly

Use the metadata export or generateMetadata function instead of <Head> tags. This integrates with Next.js's streaming architecture.

// Static metadata
export const metadata = {
  title: "My Blog",
  description: "Technical articles on Node.js, React, and TypeScript.",
  openGraph: {
    title: "My Blog",
    images: ["/og-image.png"],
  },
};

// Dynamic metadata (for [slug] pages)
export async function generateMetadata({ params }: Props) {
  const article = await getArticleBySlug(params.slug);
  return {
    title: article.title,
    description: article.abstract,
    openGraph: {
      title: article.title,
      images: [article.headerImage],
    },
  };
}

8. Protect Routes with Middleware

Use middleware.ts at the project root to protect routes, handle redirects, and set headers. It runs on the Edge before the page renders.

// middleware.ts
import { NextRequest, NextResponse } from "next/server";

export function middleware(request: NextRequest) {
  const token = request.cookies.get("auth-token")?.value;
  const isProtected = request.nextUrl.pathname.startsWith("/dashboard");

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

  return NextResponse.next();
}

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

9. Use Environment Variables Correctly

Next.js has a clear convention for environment variables:

# .env.local
DATABASE_URL=postgres://...         # server-only (never exposed to browser)
NEXTAUTH_SECRET=...                  # server-only

NEXT_PUBLIC_SITE_URL=https://...    # NEXT_PUBLIC_ prefix = safe for browser
NEXT_PUBLIC_ANALYTICS_ID=UA-...    # NEXT_PUBLIC_ prefix = safe for browser

Never put secrets in NEXT_PUBLIC_ variables. They are bundled into the client JavaScript.

10. Structure the App Directory for Scale

app/
├── (marketing)/         ← Route group — no URL segment, shared layout
│   ├── layout.tsx
│   ├── page.tsx         ← "/"
│   └── about/page.tsx   ← "/about"
├── (blog)/
│   ├── layout.tsx
│   └── articles/
│       ├── page.tsx
│       └── [slug]/page.tsx
├── api/
│   └── articles/route.ts
src/
├── components/          ← shared UI components
├── lib/                 ← DB client, auth helpers, utilities
├── hooks/               ← shared client hooks
└── types/               ← TypeScript interfaces

Route groups (folders wrapped in parentheses) let you share layouts without affecting the URL. Useful for organising complex apps without adding URL segments.

Conclusion

Next.js with the App Router rewards you for thinking about where code runs: server vs client, cached vs dynamic, static vs per-request. Follow the filesystem conventions, fetch data in Server Components, push "use client" to the leaves, and use next/image and proper metadata exports. These are not stylistic preferences. They directly affect your Core Web Vitals and the maintainability of your codebase.


References: