Skip to main content

Building Production-Ready Apps with Next.js

February 18, 2026

</>

Next.js has become the go-to React framework for production applications. But using it effectively requires understanding its patterns and conventions. Here are the best practices every Next.js developer should follow.

Project Structure

Organize by Feature Inside the App Directory

src/
├── app/
   ├── (marketing)/       # Route group - no URL impact
      ├── page.tsx
      ├── about/page.tsx
      └── layout.tsx
   ├── (dashboard)/
      ├── dashboard/page.tsx
      ├── settings/page.tsx
      └── layout.tsx
   ├── api/
      └── users/route.ts
   ├── layout.tsx
   └── not-found.tsx
├── components/
   ├── ui/                # Generic UI components
   └── features/          # Feature-specific components
├── lib/                   # Utilities, helpers, config
├── hooks/                 # Custom React hooks
└── types/                 # TypeScript types

Use Route Groups for Layout Organization

Route groups (folder) let you organize routes without affecting the URL:

// app/(auth)/login/page.tsx  → /login
// app/(auth)/signup/page.tsx → /signup
// Both share app/(auth)/layout.tsx

Server vs Client Components

Default to Server Components

Server Components are the default in the App Router. Only add 'use client' when you need interactivity.

// Server Component (default) - no directive needed
// Can access databases, env vars, and fetch data directly
async function UserProfile({ userId }: { userId: string }) {
  const user = await db.user.findUnique({ where: { id: userId } });
  return <div>{user.name}</div>;
}
// Client Component - only when needed
'use client';

import { useState } from 'react';

export function LikeButton() {
  const [likes, setLikes] = useState(0);
  return <button onClick={()=> setLikes(likes + 1)}>Likes: {likes}</button>;
}

Push Client Boundaries Down

Keep 'use client' as low in the component tree as possible:

// Bad - entire page is a client component
'use client';
export default function Page() {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <h1>Dashboard</h1>         {/* Static - doesn't need client */}
      <Stats />                   {/* Static - doesn't need client */}
      <Modal open={open} />       {/* Interactive - needs client */}
    </div>
  );
}

// Good - only the interactive part is client
export default function Page() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Stats />
      <ModalToggle />  {/* Only this is 'use client' */}
    </div>
  );
}

Data Fetching

Fetch Data in Server Components

// app/users/page.tsx
async function UsersPage() {
  const users = await fetch('https://api.example.com/users', {
    next: { revalidate: 3600 }, // Revalidate every hour
  }).then((res) => res.json());

  return (
    <ul>
      {users.map((user: User) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

Use Server Actions for Mutations

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  await db.post.create({ data: { title, content } });
  revalidatePath('/posts');
}
// app/posts/new/page.tsx
import { createPost } from '@/app/actions';

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <textarea name="content" required />
      <button type="submit">Create</button>
    </form>
  );
}

Parallel Data Fetching

Don't await sequentially when requests are independent:

// Bad - sequential (slow)
async function Page() {
  const user = await getUser();
  const posts = await getPosts(); // Waits for user to finish
  return <Dashboard user={user} posts={posts} />;
}

// Good - parallel (fast)
async function Page() {
  const [user, posts] = await Promise.all([getUser(), getPosts()]);
  return <Dashboard user={user} posts={posts} />;
}

Caching and Revalidation

Understand the Caching Layers

// Static - cached at build time (default for static pages)
async function Page() {
  const data = await fetch('https://api.example.com/data');
  return <div>{data}</div>;
}

// Time-based revalidation
async function Page() {
  const data = await fetch('https://api.example.com/data', {
    next: { revalidate: 60 }, // Revalidate every 60 seconds
  });
  return <div>{data}</div>;
}

// On-demand revalidation
import { revalidatePath, revalidateTag } from 'next/cache';

// In a Server Action or Route Handler
revalidatePath('/posts');
revalidateTag('posts');

Use unstable_cache for Database Queries

import { unstable_cache } from 'next/cache';

const getUser = unstable_cache(
  async (id: string) => {
    return db.user.findUnique({ where: { id } });
  },
  ['user'],
  { revalidate: 3600, tags: ['user'] },
);

Performance

Use Dynamic Imports for Heavy Components

import dynamic from 'next/dynamic';

const Chart = dynamic(() => import('@/components/Chart'), {
  loading: () => <div className="skeleton h-64" />,
  ssr: false, // Skip SSR for browser-only components
});

Optimize Images

Always use next/image instead of plain <img> tags:

import Image from 'next/image';

export function Hero() {
  return (
    <Image
      src="/hero.jpg"
      alt="Hero image"
      width={1200}
      height={600}
      priority           // Preload above-the-fold images
      placeholder="blur" // Show blur while loading
    />
  );
}

Optimize Fonts

// app/layout.tsx
import { Inter } from 'next/font/google';

const inter = Inter({ subsets: ['latin'], display: 'swap' });

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={inter.className}>
      <body>{children}</body>
    </html>
  );
}

Use loading.tsx and Suspense

// app/dashboard/loading.tsx - automatic loading UI
export default function Loading() {
  return <DashboardSkeleton />;
}

// Or use Suspense for granular control
import { Suspense } from 'react';

export default function Page() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<StatsSkeleton />}>
        <Stats />
      </Suspense>
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>
    </div>
  );
}

SEO and Metadata

Use the Metadata API

// Static metadata
export const metadata: Metadata = {
  title: 'My App',
  description: 'Built with Next.js',
  openGraph: {
    title: 'My App',
    description: 'Built with Next.js',
    images: ['/og-image.png'],
  },
};

// Dynamic metadata
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: { title: post.title, images: [post.coverImage] },
  };
}

Generate Sitemaps

// app/sitemap.ts
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getAllPosts();

  return [
    { url: 'https://example.com', lastModified: new Date(), changeFrequency: 'daily' },
    ...posts.map((post) => ({
      url: `https://example.com/blog/${post.slug}`,
      lastModified: post.updatedAt,
      changeFrequency: 'weekly' as const,
    })),
  ];
}

Error Handling

Use Error Boundaries

// app/dashboard/error.tsx
'use client';

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

Use not-found.tsx

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';

export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);

  if (!post) notFound();

  return <Article post={post} />;
}

Middleware

Use Middleware for Auth and Redirects

// middleware.ts (project root)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const token = request.cookies.get('session')?.value;

  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next();
}

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

Environment Variables

Follow the Naming Convention

# .env.local

# Server-only (default) - never exposed to browser
DATABASE_URL=postgresql://...
API_SECRET=...

# Client-accessible - must have NEXT_PUBLIC_ prefix
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_GA_ID=G-XXXXXX

Quick Reference

PracticeWhy
Default to Server ComponentsSmaller bundles, direct data access
Push 'use client' downMinimize client JavaScript
Parallel data fetchingFaster page loads
Server Actions for mutationsType-safe, progressive enhancement
next/image for all imagesAutomatic optimization and lazy loading
loading.tsx + SuspenseBetter perceived performance
Metadata API for SEOType-safe, dynamic meta tags
Middleware for authEdge-fast redirects
Route groups for layoutsClean URL structure

Summary

The key principles for Next.js best practices:

  1. Server-first — use Server Components by default, client only when needed
  2. Fetch where you render — colocate data fetching with the component that uses it
  3. Cache smartly — use revalidation strategies that match your data freshness needs
  4. Progressive enhancement — Server Actions work without JavaScript
  5. Optimize assets — images, fonts, and dynamic imports
  6. Handle errors gracefully — error boundaries and not-found pages at every level

Want to level up your fundamentals too? Read Modern JavaScript: Writing Code That Doesn't Hurt for the core JS patterns that make Next.js code cleaner.

Recommended Posts