DEV Community

Cover image for Next.js App Router Best Practices in 2026
TheKitBase
TheKitBase

Posted on • Originally published at thekitbase.app

Next.js App Router Best Practices in 2026

Next.js App Router has been stable since 13.4 and is the default for every new project. But most tutorials stop at the basics. Production apps have harder problems: nested layouts, type-safe route params, loading states that don't flash, and data fetches that don't waterfall. Here are the patterns that actually matter.

1. Route groups keep your folder structure sane

Route groups (folders wrapped in parentheses) let you organise routes without affecting the URL. The most common use case: a marketing site and an app that share a domain but need completely different layouts.

app/
  (marketing)/
    layout.tsx        ← nav + footer for public pages
    page.tsx          ← /
    pricing/page.tsx  ← /pricing
  (app)/
    layout.tsx        ← sidebar layout for authenticated app
    dashboard/page.tsx ← /dashboard
  (auth)/
    layout.tsx        ← minimal layout for login/signup
    login/page.tsx    ← /login
Enter fullscreen mode Exit fullscreen mode

The (app) folder name is invisible to the router - /dashboard is just /dashboard. Without route groups, you end up with a single root layout doing conditional rendering for every possible page state.

2. Default to Server Components - reach for 'use client' only when you need it

Use Server Components for Use Client Components for
Data fetching from DB or API useState, useReducer, useEffect
Reading server-only env vars Browser APIs (localStorage, window)
Large dependencies Event listeners (onClick, onChange)
SEO-critical content Real-time subscriptions

Push 'use client' as far down the tree as possible. A page can be a Server Component that fetches data and passes it to a small Client Component that handles interaction:

// app/dashboard/page.tsx (Server Component - no 'use client')
import { getMetrics } from "@/lib/metrics";
import { ExportButton } from "@/components/export-button"; // 'use client'

export default async function DashboardPage() {
  const metrics = await getMetrics(); // direct DB/API call, no useEffect
  return (
    <div>
      <MetricCard data={metrics} />   {/* server rendered */}
      <ExportButton data={metrics} /> {/* client island */}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

3. Parallel data fetching prevents waterfalls

The most common performance mistake: awaiting fetches sequentially when they don't depend on each other.

// ❌ Sequential - 400ms total if each takes 200ms
const user = await getUser(id);
const posts = await getPosts(id);

// ✅ Parallel - 200ms total
const [user, posts] = await Promise.all([getUser(id), getPosts(id)]);
Enter fullscreen mode Exit fullscreen mode

4. Scope loading.tsx to the segment that needs it

A single loading.tsx at the root means every navigation shows a full-page loader. Scope loading states to the segment:

app/
  (app)/
    dashboard/
      loading.tsx   ← only shows when /dashboard is loading
      page.tsx
    analytics/
      loading.tsx   ← separate loader for /analytics
      page.tsx
Enter fullscreen mode Exit fullscreen mode

For granular loading within a page, use Suspense directly:

import { Suspense } from "react";

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>         {/* renders immediately */}
      <Suspense fallback={<MetricsSkeleton />}>
        <SlowMetricsSection />   {/* streams in when ready */}
      </Suspense>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

5. Type-safe route params in Next.js 15+

Params are Promise<...> in Next.js 15+. Destructuring them synchronously is a bug:

// ❌ Outdated pattern (Next.js 14 and below)
export default function PostPage({ params }: { params: { slug: string } }) {
  return <Post slug={params.slug} />;
}

// ✅ Next.js 15+ - params is a Promise
export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);
  return <Post post={post} />;
}
Enter fullscreen mode Exit fullscreen mode

6. generateStaticParams for known dynamic routes

If a dynamic route has a known set of values at build time, pre-render them statically:

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

export async function generateMetadata({ params }) {
  const post = await getPost((await params).slug);
  return {
    title: post.title,
    description: post.description,
    openGraph: { title: post.title, images: [{ url: post.ogImage }] },
  };
}
Enter fullscreen mode Exit fullscreen mode

7. Metadata API instead of manual head tags

// app/layout.tsx - site-wide defaults
export const metadata: Metadata = {
  metadataBase: new URL("https://yoursite.com"),
  title: {
    default: "YourSite",
    template: "%s | YourSite",
  },
  description: "Default site description",
  openGraph: { type: "website", siteName: "YourSite" },
  twitter: { card: "summary_large_image" },
};
Enter fullscreen mode Exit fullscreen mode

8. Middleware: auth checks only - no database calls

Middleware runs on every matched request. Database calls here add 50-200ms to every page load:

// middleware.ts - read cookies/JWTs only, never query a DB
export function middleware(request: NextRequest) {
  const sessionToken = request.cookies.get("session")?.value;
  const isProtected = request.nextUrl.pathname.startsWith("/dashboard");

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

export const config = {
  matcher: ["/dashboard/:path*", "/settings/:path*"],
};
Enter fullscreen mode Exit fullscreen mode

The patterns worth internalising

  • Use route groups from day one - retrofitting them means moving files and fixing all import paths
  • Every page.tsx should have generateMetadata - free SEO in 10 minutes
  • Await params and searchParams - they are Promises in Next.js 15+
  • Promise.all for independent fetches - sequential awaits are the most common performance mistake
  • Never put secrets in NEXT_PUBLIC_ variables - use import "server-only" to enforce the boundary

Originally published at thekitbase.app

Top comments (0)