React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

nextjsrulesbest-practicesapp-routerproduction

40 Next.js Rules

Rules focused specifically on building Next.js applications with the App Router. Architecture, routing, rendering, deployment, and operational best practices.

Routing & File Conventions (Rules 1-10)

1. One route, one responsibility. Each page.tsx should represent a single view. If a page file grows beyond 100 lines, extract components into colocated files.

2. Use route groups to organize without affecting URLs. (marketing), (dashboard), (auth) keep your file tree clean without adding URL segments.

app/
  (marketing)/
    page.tsx           # /
    pricing/page.tsx   # /pricing
  (dashboard)/
    dashboard/page.tsx # /dashboard
    settings/page.tsx  # /settings

3. Always provide loading.tsx for dynamic routes. Every route segment that fetches data should have a loading.tsx. Users should never see a blank screen while data loads.

4. Always provide error.tsx for every route segment. Errors happen. Catch them gracefully with error.tsx. Include a retry button and a way to navigate away.

"use client";
 
export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div>
      <h2>Something went wrong</h2>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

5. Use not-found.tsx for custom 404 pages. Call notFound() from Server Components when data does not exist. Provide a helpful not-found page per route segment.

6. Use layouts for shared UI, templates for per-navigation state reset. Layouts persist across navigations (good for sidebars, nav). Templates remount on every navigation (good for animations, per-page analytics).

7. Use private folders (_components, _lib) for non-route files. Anything prefixed with _ is excluded from the routing system. Colocate helpers without accidentally creating routes.

8. Use parallel routes (@slots) for complex layouts. Dashboards with independently loading panels, modal patterns, and conditional content benefit from @slot parallel routes with default.tsx fallbacks.

9. Use intercepting routes for modal patterns. (.)photo/[id] intercepts navigation to show a modal while preserving the shareable URL. The full page renders on direct navigation or refresh.

10. Keep middleware lean. Middleware runs on every request. Only use it for auth redirects, locale detection, A/B testing headers, and path rewrites. Never fetch data or do heavy computation in middleware.

// middleware.ts - keep it fast
export function middleware(request: NextRequest) {
  const session = request.cookies.get("session");
  if (!session && request.nextUrl.pathname.startsWith("/dashboard")) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
}
 
export const config = {
  matcher: ["/dashboard/:path*", "/settings/:path*"],
};

Rendering & Performance (Rules 11-20)

11. Default to static rendering. Pages without dynamic data should be statically generated. This is the fastest option. Next.js does this automatically when there are no dynamic functions.

12. Understand what makes a route dynamic. Using cookies(), headers(), searchParams, or fetch with cache: "no-store" opts the entire route into dynamic rendering. Be intentional about it.

FunctionEffect
cookies()Makes route dynamic
headers()Makes route dynamic
searchParamsMakes route dynamic
fetch with cache: "no-store"Makes route dynamic
unstable_noStore()Makes route dynamic

13. Use generateStaticParams for known dynamic routes. Pre-render product pages, blog posts, and other content with known slugs at build time.

export async function generateStaticParams() {
  const posts = await db.post.findMany({ select: { slug: true } });
  return posts.map((post) => ({ slug: post.slug }));
}

14. Use ISR (revalidation) for content that changes periodically. Set revalidate to refresh cached pages without a full rebuild.

// Revalidate every hour
export const revalidate = 3600;
 
// Or per-fetch
const data = await fetch(url, { next: { revalidate: 3600 } });

15. Use Suspense boundaries strategically. Do not wrap the entire page in one Suspense boundary. Wrap each independent data-fetching section separately so they stream independently.

16. Set image dimensions or use fill mode. Every next/image must have explicit width/height or use fill with a sized container. This prevents Cumulative Layout Shift (CLS).

17. Use priority on above-the-fold images. The hero image and LCP element should have priority={true} to preload immediately.

18. Configure remotePatterns instead of domains. remotePatterns in next.config.ts gives you fine-grained control over allowed external image sources with protocol and pathname matching.

19. Use next/font for all fonts. Self-hosted fonts via next/font eliminate layout shift from font loading and avoid external network requests to Google Fonts CDN.

20. Enable Turbopack for development. Add --turbopack to your dev script. It is stable in Next.js 15+ and significantly faster for HMR.

{
  "scripts": {
    "dev": "next dev --turbopack"
  }
}

Data & Server Actions (Rules 21-30)

21. Fetch data in Server Components, not in Client Components. Server Components have direct access to databases, file systems, and internal APIs. No API layer needed.

22. Use Server Actions for all mutations. Forms, button clicks, and any write operation should go through Server Actions. They handle CSRF protection, progressive enhancement, and optimistic updates.

23. Validate Server Action inputs with Zod. Never trust form data. Parse and validate on the server before processing.

"use server";
import { z } from "zod";
 
const schema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().min(10),
});
 
export async function createPost(formData: FormData) {
  const parsed = schema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) {
    return { errors: parsed.error.flatten().fieldErrors };
  }
  await db.post.create({ data: parsed.data });
  revalidatePath("/posts");
}

24. Always call revalidatePath or revalidateTag after mutations. Stale UI after a successful action is a common bug. Revalidate the affected paths.

25. Use Route Handlers (GET, POST) for webhook endpoints and external API consumers. Server Actions are for internal form submissions. Route Handlers are for external integrations, webhooks, and REST APIs.

26. Protect every Server Action with authentication. Server Actions are public HTTP endpoints. Check the session at the top of every action.

"use server";
 
export async function deletePost(id: string) {
  const session = await auth();
  if (!session) throw new Error("Unauthorized");
  if (session.user.role !== "admin") throw new Error("Forbidden");
  await db.post.delete({ where: { id } });
  revalidatePath("/posts");
}

27. Use revalidateTag for fine-grained cache invalidation. Tag your fetches, then invalidate only what changed. More efficient than revalidatePath for complex data relationships.

// Fetching with tags
const posts = await fetch(url, { next: { tags: ["posts"] } });
 
// Invalidating
revalidateTag("posts");

28. Use redirect in Server Actions for post-mutation navigation. Call redirect("/success") after a successful action. It throws internally, so place it outside try/catch.

29. Handle Server Action errors with return values, not thrown errors. Return { error: "message" } from actions and handle in the UI with useActionState. Only throw for truly exceptional cases.

30. Use server-only package to prevent server code leaking to client. Import "server-only" at the top of any module that should never be bundled client-side (database clients, secret keys, etc.).

import "server-only";
import { db } from "./db";
 
export async function getSecretData() {
  return db.secrets.findMany();
}

Configuration & Deployment (Rules 31-40)

31. Use environment variables correctly. NEXT_PUBLIC_ prefix for client-safe values only. Everything else is server-only. Validate env vars at startup with Zod.

32. Configure next.config.ts minimally. Only add config you need. Avoid experimental features in production unless thoroughly tested.

33. Use standalone output for Docker deployments. output: "standalone" creates a self-contained build with only the necessary dependencies.

// next.config.ts
const config = {
  output: "standalone",
};
export default config;

34. Set proper security headers. Configure Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, and Referrer-Policy in next.config.ts or middleware.

35. Generate sitemap.xml and robots.txt dynamically. Use app/sitemap.ts and app/robots.ts for dynamic generation based on your content.

36. Use generateMetadata for dynamic pages. Static metadata for fixed pages, generateMetadata function for dynamic pages (blog posts, products).

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: { images: [post.coverImage] },
  };
}

37. Test with next build and next start before deploying. Development mode hides many issues. Always test in production mode locally.

38. Monitor bundle size. Use @next/bundle-analyzer periodically to catch unexpected bundle growth. Set budgets for client JavaScript.

39. Pin Next.js versions in production. Use exact versions ("next": "16.2.2") not ranges. Upgrade intentionally after reading changelogs.

40. Use instrumentation.ts for server startup tasks. Database connection warmup, telemetry initialization, and one-time setup belong in the instrumentation file.

// instrumentation.ts
export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    // Initialize database connection pool
    // Set up error tracking (Sentry, etc.)
  }
}

FAQs

What is the difference between a layout and a template in Next.js?
  • Layouts persist across navigations -- good for sidebars, nav bars, and shared UI
  • Templates remount on every navigation -- good for animations, per-page analytics, or resetting state
What makes a route dynamic instead of static in Next.js?

Any of these opt the route into dynamic rendering:

  • Calling cookies() or headers()
  • Reading searchParams
  • Using fetch with cache: "no-store"
  • Calling unstable_noStore()
How do you pre-render known dynamic routes at build time?
export async function generateStaticParams() {
  const posts = await db.post.findMany({ select: { slug: true } });
  return posts.map((post) => ({ slug: post.slug }));
}
When should you use Route Handlers vs. Server Actions?
  • Server Actions: internal form submissions and UI mutations from your own app
  • Route Handlers: external consumers, webhooks (Stripe, GitHub), REST APIs for mobile apps, CORS-enabled endpoints
Gotcha: Why must you always call revalidatePath or revalidateTag after a mutation?
  • Without revalidation, the cached page/data remains stale after a successful Server Action
  • Users will see outdated content even though the mutation succeeded
  • This is one of the most common bugs in Next.js applications
How should you handle errors returned from Server Actions?
  • Return { error: "message" } from the action instead of throwing
  • Handle the error in the UI using useActionState
  • Only throw for truly exceptional/unexpected cases
  • Placing redirect() inside try/catch will break because redirect throws internally
What does the "server-only" package do?
import "server-only";
  • Importing this at the top of a module causes a build error if that module is ever bundled into the client
  • Use it for files containing database clients, secret keys, or any server-exclusive logic
Gotcha: What happens if you wrap your entire page in a single Suspense boundary?
  • The entire page waits for the slowest data source before showing anything
  • Independent sections cannot stream in as they resolve
  • Instead, wrap each data-fetching section in its own Suspense boundary
How do you type the params prop for a dynamic route page in TypeScript?
type Props = {
  params: Promise<{ slug: string }>;
};
 
export default async function Page({ params }: Props) {
  const { slug } = await params;
  // ...
}
 
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  // ...
}
How do you type the error.tsx component in TypeScript?
"use client";
 
export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return <button onClick={reset}>Try again</button>;
}

Note: error.tsx must be a Client Component.

What is the purpose of instrumentation.ts?
  • Runs once at server startup before any request is handled
  • Use it for database connection warmup, telemetry initialization, or one-time setup
  • Check process.env.NEXT_RUNTIME to distinguish between Node.js and Edge runtimes
Why should you use remotePatterns instead of domains for external images?
  • remotePatterns allows fine-grained control with protocol, hostname, port, and pathname matching
  • domains only matches the hostname and is less secure
  • remotePatterns prevents loading images from unexpected paths on an allowed domain
How should you validate environment variables at startup?

Use Zod to validate env vars so the app fails fast with a clear error:

import { z } from "zod";
 
const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  NEXT_PUBLIC_APP_URL: z.string().url(),
});
 
export const env = envSchema.parse(process.env);

Quick Reference

CategoryKey Rule
RoutingOne route, one responsibility. Always provide loading + error.
RenderingDefault static. Be intentional about dynamic.
DataServer fetch by default. Server Actions for mutations.
SecurityAuth on every action. Validate all inputs. Never expose secrets.
PerformanceSuspense per section. Priority images. Turbopack in dev.
DeploymentStandalone output. Test with next build + next start.