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.
// 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>
);
}// 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:
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:
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:
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
metadataBasein the root layout so OG images resolve to absolute URLs. - Per-page
generateMetadatawith 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.
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
- No
'use client'above the fold unless it earns its bytes. - All dynamic APIs awaited; types reflect the promise wrappers.
- Slow data behind
<Suspense>boundaries. - Unique title + description + canonical per route.
sitemap.xmlandrobots.txtgenerated from source-of-truth data.- 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.
