Next.js App Router Patterns I Use in Every Production Project

Server Components by default, streaming with Suspense, type-safe params, and metadata that ranks — the App Router conventions I reach for in every client build.

Yousef Romany
Yousef Romanyabout ↗updated Aug 14, 20262 min read

After shipping a dozen production projects on the App Router — portfolio sites, dashboards, AI tools — the same set of patterns keeps proving itself. These are the conventions I now apply before writing a single feature, because they prevent the two most common App Router problems: accidental client-side bloat and broken SEO.

Server by default, client at the leaves

The biggest mindset shift with the App Router is that every component is a Server Component until you prove otherwise. Instead of sprinkling 'use client' everywhere and pulling it out later, I invert the workflow: keep pages fully server-rendered, then push interactivity down into small leaf components.

tsx
// app/page.tsx — server component (default)
import { ProductFilters } from '@/components/product-filters';
import { getProducts } from '@/lib/data';

export default async function Page() {
  const products = await getProducts();
  return (
    <main>
      <ProductFilters products={products} />
    </main>
  );
}
tsx
// components/product-filters.tsx — the only interactive part
'use client';

export function ProductFilters({ products }: { products: Product[] }) {
  const [query, setQuery] = useState('');
  // ...
}

The rule of thumb: if a component doesn't need state, effects, or browser APIs, it doesn't need 'use client'. This one discipline routinely cuts client JavaScript bundles by half or more.

Treat params and searchParams as promises

Since Next.js 15, dynamic APIs are asynchronous. Old tutorials show synchronous access, which breaks silently or throws. Every dynamic route I write follows this shape:

tsx
type Props = {
  params: Promise<{ slug: string }>;
};

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  return {
    title: post.title,
    description: post.description,
    alternates: { canonical: `/posts/${slug}` },
  };
}

export async function Page({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);
  return <Article post={post} />;
}

Pair this with React's cache() so getPost runs once per request even when both the page and its metadata call it:

ts
import { cache } from 'react';

export const getPost = cache(async (slug: string) => {
  return db.posts.findFirst({ where: { slug } });
});

Stream slow sections with Suspense

Nothing hurts perceived performance like waiting for your slowest query before painting anything. Instead of blocking the whole route, wrap slow sections in Suspense and let the shell render instantly:

tsx
export default function DashboardPage() {
  return (
    <>
      <Header />
      <Suspense fallback={<StatsSkeleton />}>
        <SlowRevenueStats />
      </Suspense>
      <FastNavLinks />
    </>
  );
}

Users see the header and navigation immediately; only the genuinely slow card streams in when its data resolves. It's the single highest-impact performance change you can make in under ten minutes.

Metadata is a feature, not an afterthought

Every project gets the same baseline:

  • A metadataBase in the root layout so OG images resolve to absolute URLs.
  • Per-page generateMetadata with unique titles, descriptions, and canonicals.
  • Static routes (sitemap.ts, robots.ts) generated from real data.
  • JSON-LD structured data for the entities the site actually represents.
tsx
export const metadata: Metadata = {
  metadataBase: new URL('https://example.com'),
};

Skipping canonical URLs is the mistake I see most often — especially on sites reachable from multiple domains, where duplicate-content issues quietly tank rankings.

Colocate everything, share almost nothing

Route groups let me give marketing pages one layout and app pages another without touching the URL structure. Shared UI lives in a components/ folder; data access lives beside the database schema, not inside components. When a component imports data-fetching logic directly, refactoring the backend becomes a frontend change too — and that coupling compounds fast.

The checklist I run before every deploy

  1. No 'use client' above the fold unless it earns its bytes.
  2. All dynamic APIs awaited; types reflect the promise wrappers.
  3. Slow data behind <Suspense> boundaries.
  4. Unique title + description + canonical per route.
  5. sitemap.xml and robots.txt generated from source-of-truth data.
  6. Lighthouse run on a production build, not dev mode.

None of these are clever. That's exactly why they work — boring conventions, applied every time, are what keep App Router projects maintainable a year later.

SHAREXLinkedInWhatsApp
Yousef Romany

Yousef Romany

Full-Stack Developer & AI Agent Engineer based in Luxor, Egypt. I build web applications and AI-powered automation for clients worldwide.