Back to Blog

Next.js Project Structure: A Comprehensive Architecture Guide

Introduction: File-System Based Routing Philosophy

Next.js App Router represents a paradigm shift from configuration-based routing to convention-based, file-system routing. Understanding these conventions isn't just about knowing where to put files—it's about understanding how Next.js transforms your folder structure into a rendering tree, how it optimizes bundle splitting, and how it determines what code runs on the server versus client.

This guide dissects every convention, explains the underlying mechanics, and provides architectural patterns for scaling Next.js applications.

Architecture Overview

┌─────────────────────────────────────────────────────────────────────┐
│                    NEXT.JS PROJECT ARCHITECTURE                      │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │                    PROJECT ROOT                              │   │
│  │                                                              │   │
│  │  ┌──────────┐  ┌──────────┐  ┌──────────┐  ┌──────────┐    │   │
│  │  │  app/    │  │  public/ │  │   src/   │  │ config   │    │   │
│  │  │ (Router) │  │ (Static) │  │(Optional)│  │  files   │    │   │
│  │  └────┬─────┘  └──────────┘  └──────────┘  └──────────┘    │   │
│  │       │                                                      │   │
│  └───────┼──────────────────────────────────────────────────────┘   │
│          │                                                          │
│          ▼                                                          │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │                    ROUTE SEGMENTS                            │   │
│  │                                                              │   │
│  │   app/                                                       │   │
│  │   ├── layout.tsx        ─────► Root Layout (required)        │   │
│  │   ├── page.tsx          ─────► / route                       │   │
│  │   ├── loading.tsx       ─────► Suspense boundary             │   │
│  │   ├── error.tsx         ─────► Error boundary                │   │
│  │   │                                                          │   │
│  │   ├── blog/                                                  │   │
│  │   │   ├── layout.tsx    ─────► Nested layout                 │   │
│  │   │   ├── page.tsx      ─────► /blog route                   │   │
│  │   │   └── [slug]/                                            │   │
│  │   │       └── page.tsx  ─────► /blog/:slug route             │   │
│  │   │                                                          │   │
│  │   ├── (marketing)/      ─────► Route group (not in URL)      │   │
│  │   │   └── about/                                             │   │
│  │   │       └── page.tsx  ─────► /about route                  │   │
│  │   │                                                          │   │
│  │   ├── @modal/           ─────► Parallel route slot           │   │
│  │   │   └── login/                                             │   │
│  │   │       └── page.tsx  ─────► Rendered in slot              │   │
│  │   │                                                          │   │
│  │   └── _components/      ─────► Private folder (not routed)   │   │
│  │       └── Button.tsx                                         │   │
│  │                                                              │   │
│  └──────────────────────────────────────────────────────────────┘   │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Top-Level Folder Structure

The app Directory: App Router's Domain

The app directory is the heart of Next.js 13+ routing. Every folder inside app potentially represents a URL segment, but only folders containing page.tsx or route.ts become publicly accessible routes.

app/
├── layout.tsx          # Root layout - wraps entire application
├── page.tsx            # Home page (/)
├── globals.css         # Global styles (colocated, not routed)
├── favicon.ico         # Automatically served
│
├── dashboard/
│   ├── layout.tsx      # Dashboard layout
│   ├── page.tsx        # /dashboard
│   ├── settings/
│   │   └── page.tsx    # /dashboard/settings
│   └── analytics/
│       ├── page.tsx    # /dashboard/analytics
│       └── loading.tsx # Loading UI for this segment
│
└── api/
    └── users/
        └── route.ts    # API: /api/users

Key Insight: Folders without page.tsx or route.ts are invisible to the router but can contain colocated components, utilities, and tests.

The public Directory: Static Asset Serving

Files in public are served at the root URL path. Next.js does not process these files—they're served as-is by the web server.

public/
├── favicon.ico         # → /favicon.ico
├── robots.txt          # → /robots.txt
├── images/
│   └── logo.png        # → /images/logo.png
└── fonts/
    └── inter.woff2     # → /fonts/inter.woff2

When to use public vs imports:

  • Use public for files that need stable URLs (social sharing, emails, external references)
  • Use imports for files that benefit from optimization (images via next/image, fonts via next/font)

The Optional src Directory

The src folder is purely organizational—Next.js treats src/app identically to app:

# These are equivalent:
project/app/page.tsx
project/src/app/page.tsx

Tradeoff Analysis:

ApproachProsCons
Root app/Flatter structure, less nestingConfig files mixed with source
src/app/Clean separation, familiar to manyExtra directory level

Routing Files: The Core Conventions

page.tsx: Route Entry Points

A page.tsx file makes a route segment publicly accessible. Without it, the folder is just organizational.

// app/blog/[slug]/page.tsx

interface PageProps {
  params: Promise<{ slug: string }>;
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}

// Server Component by default
export default async function BlogPost({ params, searchParams }: PageProps) {
  const { slug } = await params;
  const { ref } = await searchParams;

  const post = await fetchPost(slug);

  return (
    <article>
      <h1>{post.title}</h1>
      <PostContent content={post.content} />
      {ref && <ReferralBanner source={ref} />}
    </article>
  );
}

// Static generation with dynamic params
export async function generateStaticParams() {
  const posts = await fetchAllPosts();

  return posts.map((post) => ({
    slug: post.slug,
  }));
}

// Metadata for SEO
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const { slug } = await params;
  const post = await fetchPost(slug);

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      images: [post.coverImage],
    },
  };
}

Rendering Behavior:

  • Pages are Server Components by default
  • They can be async and fetch data directly
  • Adding 'use client' converts to Client Component (loses async, gains interactivity)

layout.tsx: Persistent UI Wrappers

Layouts wrap child segments and persist across navigations. They don't re-render when navigating between child routes—only the changed segments re-render.

// app/dashboard/layout.tsx

interface DashboardLayoutProps {
  children: React.ReactNode;
  analytics: React.ReactNode;  // Parallel route slot
  modal: React.ReactNode;      // Another parallel route slot
}

export default function DashboardLayout({
  children,
  analytics,
  modal,
}: DashboardLayoutProps) {
  return (
    <div className="dashboard-container">
      <Sidebar />

      <main className="dashboard-main">
        <DashboardHeader />
        {children}
      </main>

      <aside className="dashboard-analytics">
        {analytics}
      </aside>

      {modal}
    </div>
  );
}

Root Layout Requirements (app/layout.tsx):

// app/layout.tsx - REQUIRED file

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

The root layout must include <html> and <body> tags. It's the only layout with this requirement.

Layout vs Template: Layouts persist state; templates remount on every navigation:

// app/blog/template.tsx
// Remounts on every navigation - useful for:
// - Enter/exit animations
// - Features requiring fresh state
// - useEffect that should run on every page change

export default function BlogTemplate({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <motion.div
      initial={{ opacity: 0, y: 20 }}
      animate={{ opacity: 1, y: 0 }}
      exit={{ opacity: 0, y: -20 }}
    >
      {children}
    </motion.div>
  );
}

loading.tsx: Streaming and Suspense

loading.tsx creates an automatic Suspense boundary around page.tsx. While the page loads, the loading UI displays.

// app/dashboard/loading.tsx

export default function DashboardLoading() {
  return (
    <div className="dashboard-skeleton">
      <div className="skeleton-header" />
      <div className="skeleton-grid">
        {Array.from({ length: 6 }).map((_, i) => (
          <div key={i} className="skeleton-card" />
        ))}
      </div>
    </div>
  );
}

How It Works Internally:

Next.js transforms this:

app/dashboard/
├── layout.tsx
├── loading.tsx
└── page.tsx

Into this component tree:

<DashboardLayout>
  <Suspense fallback={<DashboardLoading />}>
    <DashboardPage />
  </Suspense>
</DashboardLayout>

Streaming Architecture:

┌─────────────────────────────────────────────────────────────────┐
│                    STREAMING RESPONSE                           │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  1. Initial HTML shell (immediate)                              │
│     ┌─────────────────────────────────────────────────────┐    │
│     │  <html>                                              │    │
│     │    <body>                                            │    │
│     │      <Layout>                                        │    │
│     │        <LoadingSkeleton /> ◄── Suspense fallback    │    │
│     │      </Layout>                                       │    │
│     │    </body>                                            │    │
│     │  </html>                                              │    │
│     └─────────────────────────────────────────────────────┘    │
│                           │                                     │
│                           ▼                                     │
│  2. Streamed content (when ready)                               │
│     ┌─────────────────────────────────────────────────────┐    │
│     │  <script>                                            │    │
│     │    // Replace skeleton with actual content           │    │
│     │    $RC("suspense-id", "<ActualPage />")             │    │
│     │  </script>                                           │    │
│     └─────────────────────────────────────────────────────┘    │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

error.tsx: Error Boundaries

error.tsx creates a React Error Boundary that catches errors in child segments.

// app/dashboard/error.tsx
'use client'; // Error boundaries must be Client Components

import { useEffect } from 'react';

interface ErrorProps {
  error: Error & { digest?: string };
  reset: () => void;
}

export default function DashboardError({ error, reset }: ErrorProps) {
  useEffect(() => {
    // Log to error reporting service
    reportError(error);
  }, [error]);

  return (
    <div className="error-container">
      <h2>Something went wrong!</h2>
      <p className="error-message">
        {process.env.NODE_ENV === 'development'
          ? error.message
          : 'An unexpected error occurred'}
      </p>
      {error.digest && (
        <p className="error-digest">Error ID: {error.digest}</p>
      )}
      <button onClick={reset}>Try again</button>
    </div>
  );
}

Error Boundary Hierarchy:

app/
├── layout.tsx         # NOT caught by error.tsx (use global-error.tsx)
├── error.tsx          # Catches errors in page.tsx and children
├── page.tsx           # ✓ Caught
└── dashboard/
    ├── layout.tsx     # ✓ Caught by parent error.tsx
    ├── error.tsx      # Catches dashboard-specific errors
    └── page.tsx       # ✓ Caught by dashboard/error.tsx

global-error.tsx: Catches errors in root layout:

// app/global-error.tsx
'use client';

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    // Must include html and body - replaces root layout
    <html>
      <body>
        <h2>Something went wrong!</h2>
        <button onClick={reset}>Try again</button>
      </body>
    </html>
  );
}

not-found.tsx: 404 Handling

Triggered by notFound() function or when a route segment doesn't exist.

// app/blog/[slug]/not-found.tsx

import Link from 'next/link';

export default function BlogNotFound() {
  return (
    <div className="not-found">
      <h2>Post Not Found</h2>
      <p>Could not find the requested blog post.</p>
      <Link href="/blog">View all posts</Link>
    </div>
  );
}

// In page.tsx - trigger programmatically:
import { notFound } from 'next/navigation';

export default async function BlogPost({ params }: PageProps) {
  const { slug } = await params;
  const post = await fetchPost(slug);

  if (!post) {
    notFound(); // Renders not-found.tsx
  }

  return <article>{/* ... */}</article>;
}

route.ts: API Routes

Create API endpoints without pages. Supports all HTTP methods.

// app/api/users/route.ts

import { NextRequest, NextResponse } from 'next/server';

// GET /api/users
export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const page = parseInt(searchParams.get('page') ?? '1');
  const limit = parseInt(searchParams.get('limit') ?? '10');

  const users = await db.user.findMany({
    skip: (page - 1) * limit,
    take: limit,
  });

  return NextResponse.json({
    data: users,
    pagination: { page, limit },
  });
}

// POST /api/users
export async function POST(request: NextRequest) {
  try {
    const body = await request.json();

    const user = await db.user.create({
      data: body,
    });

    return NextResponse.json(user, { status: 201 });
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to create user' },
      { status: 500 }
    );
  }
}

// app/api/users/[id]/route.ts

interface RouteContext {
  params: Promise<{ id: string }>;
}

// GET /api/users/:id
export async function GET(
  request: NextRequest,
  { params }: RouteContext
) {
  const { id } = await params;

  const user = await db.user.findUnique({
    where: { id },
  });

  if (!user) {
    return NextResponse.json(
      { error: 'User not found' },
      { status: 404 }
    );
  }

  return NextResponse.json(user);
}

// PATCH /api/users/:id
export async function PATCH(
  request: NextRequest,
  { params }: RouteContext
) {
  const { id } = await params;
  const body = await request.json();

  const user = await db.user.update({
    where: { id },
    data: body,
  });

  return NextResponse.json(user);
}

// DELETE /api/users/:id
export async function DELETE(
  request: NextRequest,
  { params }: RouteContext
) {
  const { id } = await params;

  await db.user.delete({
    where: { id },
  });

  return new NextResponse(null, { status: 204 });
}

Route Handlers vs Server Actions:

FeatureRoute HandlersServer Actions
Use CaseREST APIs, webhooksForm mutations, RPC-style calls
Invocationfetch() from clientDirect function call
CachingConfigurableNot cached
Progressive EnhancementNoYes (works without JS)

Dynamic Routes: Parameterized Segments

Single Parameter: [param]

// app/products/[id]/page.tsx
// Matches: /products/1, /products/abc, /products/my-product

interface ProductPageProps {
  params: Promise<{ id: string }>;
}

export default async function ProductPage({ params }: ProductPageProps) {
  const { id } = await params;
  const product = await fetchProduct(id);

  return <ProductDetail product={product} />;
}

Catch-All: [...param]

Captures multiple path segments as an array.

// app/docs/[...slug]/page.tsx
// Matches: /docs/a, /docs/a/b, /docs/a/b/c
// Does NOT match: /docs

interface DocsPageProps {
  params: Promise<{ slug: string[] }>;
}

export default async function DocsPage({ params }: DocsPageProps) {
  const { slug } = await params;
  // slug = ['a', 'b', 'c'] for /docs/a/b/c

  const path = slug.join('/');
  const doc = await fetchDoc(path);

  return <DocContent doc={doc} />;
}

Optional Catch-All: [[...param]]

Like catch-all but also matches the root.

// app/docs/[[...slug]]/page.tsx
// Matches: /docs, /docs/a, /docs/a/b, /docs/a/b/c

interface DocsPageProps {
  params: Promise<{ slug?: string[] }>;
}

export default async function DocsPage({ params }: DocsPageProps) {
  const { slug } = await params;
  // slug = undefined for /docs
  // slug = ['a', 'b'] for /docs/a/b

  if (!slug) {
    return <DocsIndex />;
  }

  const path = slug.join('/');
  const doc = await fetchDoc(path);

  return <DocContent doc={doc} />;
}

Static Generation with Dynamic Params

// app/blog/[slug]/page.tsx

// Generate static pages at build time
export async function generateStaticParams() {
  const posts = await fetchAllPosts();

  return posts.map((post) => ({
    slug: post.slug,
  }));
}

// Control behavior for params not returned by generateStaticParams
export const dynamicParams = true; // (default) Generate on-demand
// export const dynamicParams = false; // Return 404 for unknown params

Nested Dynamic Params:

// app/[category]/[product]/page.tsx

export async function generateStaticParams() {
  const categories = await fetchCategories();

  const paths = [];

  for (const category of categories) {
    const products = await fetchProductsByCategory(category.slug);

    for (const product of products) {
      paths.push({
        category: category.slug,
        product: product.slug,
      });
    }
  }

  return paths;
}

Route Groups: Organization Without URL Impact

Route groups use (folderName) syntax—parentheses are stripped from the URL.

Organizing by Feature/Team

app/
├── (marketing)/
│   ├── layout.tsx      # Marketing-specific layout
│   ├── page.tsx        # /
│   ├── about/
│   │   └── page.tsx    # /about
│   └── pricing/
│       └── page.tsx    # /pricing
│
├── (shop)/
│   ├── layout.tsx      # E-commerce layout with cart
│   ├── products/
│   │   └── page.tsx    # /products
│   └── cart/
│       └── page.tsx    # /cart
│
└── (app)/
    ├── layout.tsx      # Authenticated app layout
    ├── dashboard/
    │   └── page.tsx    # /dashboard
    └── settings/
        └── page.tsx    # /settings

Multiple Root Layouts

Different sections can have completely different HTML structures:

// app/(marketing)/layout.tsx
export default function MarketingLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className="marketing-theme">
        <MarketingHeader />
        {children}
        <MarketingFooter />
      </body>
    </html>
  );
}

// app/(app)/layout.tsx
export default function AppLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className="app-theme">
        <AppSidebar />
        <main>{children}</main>
      </body>
    </html>
  );
}

Important: When using multiple root layouts, navigating between them causes a full page reload since they're different HTML documents.

Selective Layout Application

Apply a layout to some routes but not others:

app/
├── layout.tsx              # Minimal root layout
├── page.tsx                # / (no extra layout)
│
├── (with-sidebar)/
│   ├── layout.tsx          # Adds sidebar
│   ├── dashboard/
│   │   └── page.tsx        # /dashboard (has sidebar)
│   └── analytics/
│       └── page.tsx        # /analytics (has sidebar)
│
└── checkout/
    └── page.tsx            # /checkout (no sidebar)

Private Folders: Colocation Safety

Prefix with underscore _ to exclude from routing entirely.

app/
├── dashboard/
│   ├── page.tsx
│   ├── _components/        # Not routable
│   │   ├── DashboardCard.tsx
│   │   ├── DashboardChart.tsx
│   │   └── index.ts
│   ├── _hooks/             # Not routable
│   │   └── useDashboardData.ts
│   ├── _lib/               # Not routable
│   │   └── dashboard-utils.ts
│   └── _types/             # Not routable
│       └── dashboard.types.ts

Why Use Private Folders:

  1. Clearly separate routing concerns from implementation
  2. Prevent accidental route creation
  3. Group related code near where it's used
  4. Future-proof against new Next.js file conventions

Parallel Routes: Simultaneous Route Rendering

Parallel routes render multiple pages in the same layout simultaneously using named slots.

Slot Convention: @slotName

app/
├── layout.tsx
├── page.tsx
├── @analytics/
│   ├── page.tsx            # Rendered in analytics slot
│   └── loading.tsx         # Slot-specific loading
├── @notifications/
│   ├── page.tsx            # Rendered in notifications slot
│   └── error.tsx           # Slot-specific error handling
└── default.tsx             # Fallback when slot doesn't match
// app/layout.tsx

interface LayoutProps {
  children: React.ReactNode;
  analytics: React.ReactNode;
  notifications: React.ReactNode;
}

export default function Layout({
  children,
  analytics,
  notifications,
}: LayoutProps) {
  return (
    <div className="app-layout">
      <main>{children}</main>
      <aside className="right-panel">
        <section className="analytics">{analytics}</section>
        <section className="notifications">{notifications}</section>
      </aside>
    </div>
  );
}

Conditional Slot Rendering

// app/layout.tsx

import { auth } from '@/lib/auth';

export default async function Layout({
  children,
  analytics,
  admin,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  admin: React.ReactNode;
}) {
  const session = await auth();

  return (
    <div>
      {children}
      {analytics}
      {session?.user?.role === 'admin' && admin}
    </div>
  );
}

default.tsx: Slot Fallbacks

When navigating to a route that doesn't have content for a slot, Next.js needs a fallback:

// app/@notifications/default.tsx

export default function NotificationsDefault() {
  // Render when parent route doesn't have a matching
  // notifications slot content
  return null; // or a placeholder
}

When default.tsx is needed:

  • Parallel route slots that don't have matching pages for all URL states
  • Prevents Next.js from showing 404 when slot content is missing

Intercepting Routes: Modal Patterns

Intercept routes render a different route's content in the current layout—commonly used for modals.

Interception Conventions

PatternMeaning
(.)folderSame level
(..)folderOne level up
(..)(..)folderTwo levels up
(...)folderFrom app root
app/
├── layout.tsx
├── page.tsx                    # Gallery grid
├── @modal/
│   ├── default.tsx             # No modal by default
│   └── (.)photos/
│       └── [id]/
│           └── page.tsx        # Photo in modal (intercepted)
└── photos/
    └── [id]/
        └── page.tsx            # Full photo page (direct navigation)
// app/layout.tsx

export default function Layout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <>
      {children}
      {modal}
    </>
  );
}

// app/@modal/(.)photos/[id]/page.tsx
// Intercepted route - renders in modal

import { Modal } from '@/components/Modal';

export default async function PhotoModal({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const photo = await fetchPhoto(id);

  return (
    <Modal>
      <img src={photo.url} alt={photo.title} />
      <h2>{photo.title}</h2>
    </Modal>
  );
}

// app/photos/[id]/page.tsx
// Direct navigation - full page

export default async function PhotoPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const photo = await fetchPhoto(id);

  return (
    <div className="photo-page">
      <img src={photo.url} alt={photo.title} />
      <h2>{photo.title}</h2>
      <PhotoMetadata photo={photo} />
      <RelatedPhotos photoId={id} />
    </div>
  );
}

Behavior:

  • Clicking a photo from gallery → Opens modal (intercepted), URL changes to /photos/123
  • Directly navigating to /photos/123 → Full page renders
  • Refreshing on /photos/123 → Full page renders
  • Closing modal → Returns to gallery, URL changes back

Metadata File Conventions

App Icons

app/
├── favicon.ico              # Browser tab icon
├── icon.png                 # App icon (also icon.svg, icon.ico)
├── icon.tsx                 # Generated icon
├── apple-icon.png           # Apple touch icon
└── apple-icon.tsx           # Generated Apple icon
// app/icon.tsx - Generate icons dynamically

import { ImageResponse } from 'next/og';

export const size = { width: 32, height: 32 };
export const contentType = 'image/png';

export default function Icon() {
  return new ImageResponse(
    (
      <div
        style={{
          fontSize: 24,
          background: 'linear-gradient(to bottom, #000, #333)',
          width: '100%',
          height: '100%',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          color: 'white',
          borderRadius: '6px',
        }}
      >
        A
      </div>
    ),
    size
  );
}

Open Graph Images

app/
├── opengraph-image.png      # Default OG image
├── opengraph-image.tsx      # Generated OG image
├── twitter-image.png        # Twitter card image
└── blog/
    └── [slug]/
        └── opengraph-image.tsx  # Per-post OG image
// app/blog/[slug]/opengraph-image.tsx

import { ImageResponse } from 'next/og';

export const runtime = 'edge';
export const alt = 'Blog post cover';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';

export default async function OGImage({
  params,
}: {
  params: { slug: string };
}) {
  const post = await fetchPost(params.slug);

  return new ImageResponse(
    (
      <div
        style={{
          background: 'linear-gradient(to bottom right, #1a1a2e, #16213e)',
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
          justifyContent: 'center',
          padding: '40px',
        }}
      >
        <h1 style={{ color: 'white', fontSize: '60px', textAlign: 'center' }}>
          {post.title}
        </h1>
        <p style={{ color: '#888', fontSize: '30px' }}>
          {post.author.name}
        </p>
      </div>
    ),
    size
  );
}

SEO Files

// app/sitemap.ts

import { MetadataRoute } from 'next';

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

  const blogUrls = posts.map((post) => ({
    url: `https://example.com/blog/${post.slug}`,
    lastModified: post.updatedAt,
    changeFrequency: 'weekly' as const,
    priority: 0.8,
  }));

  return [
    {
      url: 'https://example.com',
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 1,
    },
    {
      url: 'https://example.com/about',
      lastModified: new Date(),
      changeFrequency: 'monthly',
      priority: 0.5,
    },
    ...blogUrls,
  ];
}

// app/robots.ts

import { MetadataRoute } from 'next';

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      {
        userAgent: '*',
        allow: '/',
        disallow: ['/api/', '/admin/'],
      },
      {
        userAgent: 'Googlebot',
        allow: '/',
      },
    ],
    sitemap: 'https://example.com/sitemap.xml',
  };
}

Component Rendering Hierarchy

When Next.js renders a route, it follows a specific component hierarchy:

┌─────────────────────────────────────────────────────────────────────┐
│                    COMPONENT HIERARCHY                               │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  For route: /dashboard/settings                                     │
│                                                                     │
│  <RootLayout>                    ← app/layout.tsx                   │
│    <DashboardLayout>             ← app/dashboard/layout.tsx         │
│      <Template>                  ← app/dashboard/template.tsx       │
│        <ErrorBoundary>           ← app/dashboard/error.tsx          │
│          <Suspense>              ← app/dashboard/settings/loading   │
│            <ErrorBoundary>       ← app/dashboard/settings/error     │
│              <SettingsPage />    ← app/dashboard/settings/page      │
│            </ErrorBoundary>                                         │
│          </Suspense>                                                │
│        </ErrorBoundary>                                             │
│      </Template>                                                    │
│    </DashboardLayout>                                               │
│  </RootLayout>                                                      │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Key Points:

  • Layouts nest (outer to inner based on folder depth)
  • Each segment can have its own error/loading boundaries
  • Error boundaries only catch errors from children, not siblings or parents
  • Loading boundaries create Suspense for streaming

Project Organization Strategies

app/
├── layout.tsx
├── page.tsx
│
├── (features)/
│   ├── auth/
│   │   ├── login/
│   │   │   └── page.tsx
│   │   ├── register/
│   │   │   └── page.tsx
│   │   ├── _components/
│   │   │   ├── LoginForm.tsx
│   │   │   └── RegisterForm.tsx
│   │   ├── _hooks/
│   │   │   └── useAuth.ts
│   │   └── _lib/
│   │       └── auth-utils.ts
│   │
│   ├── dashboard/
│   │   ├── page.tsx
│   │   ├── _components/
│   │   ├── _hooks/
│   │   └── _lib/
│   │
│   └── settings/
│       ├── page.tsx
│       ├── profile/
│       │   └── page.tsx
│       └── _components/
│
├── _shared/
│   ├── components/
│   │   ├── ui/
│   │   └── layout/
│   ├── hooks/
│   ├── lib/
│   └── types/
│
└── api/
    └── [...route]/
        └── route.ts

Strategy 2: Layer-First (Traditional)

src/
├── app/
│   ├── layout.tsx
│   ├── page.tsx
│   ├── dashboard/
│   │   └── page.tsx
│   └── api/
│       └── users/
│           └── route.ts
│
├── components/
│   ├── ui/
│   │   ├── Button.tsx
│   │   ├── Input.tsx
│   │   └── Modal.tsx
│   ├── forms/
│   │   ├── LoginForm.tsx
│   │   └── ProfileForm.tsx
│   └── layout/
│       ├── Header.tsx
│       └── Footer.tsx
│
├── hooks/
│   ├── useAuth.ts
│   └── useLocalStorage.ts
│
├── lib/
│   ├── api.ts
│   ├── utils.ts
│   └── validations.ts
│
├── services/
│   ├── auth.service.ts
│   └── user.service.ts
│
└── types/
    ├── api.types.ts
    └── user.types.ts

Strategy 3: Domain-Driven (For Complex Domains)

app/
├── layout.tsx
├── page.tsx
│
├── (domains)/
│   ├── users/
│   │   ├── page.tsx              # /users
│   │   ├── [id]/
│   │   │   └── page.tsx          # /users/:id
│   │   ├── _domain/
│   │   │   ├── user.entity.ts
│   │   │   ├── user.repository.ts
│   │   │   └── user.service.ts
│   │   ├── _components/
│   │   └── _api/
│   │       └── route.ts
│   │
│   ├── orders/
│   │   ├── page.tsx
│   │   ├── _domain/
│   │   │   ├── order.entity.ts
│   │   │   ├── order.repository.ts
│   │   │   └── order.service.ts
│   │   └── _components/
│   │
│   └── products/
│       ├── page.tsx
│       ├── _domain/
│       └── _components/
│
└── _infrastructure/
    ├── database/
    ├── cache/
    └── messaging/

Best Practices and Common Patterns

1. Colocating Tests

app/
└── dashboard/
    ├── page.tsx
    ├── page.test.tsx            # Unit tests next to file
    ├── _components/
    │   ├── DashboardCard.tsx
    │   └── DashboardCard.test.tsx
    └── __tests__/               # Integration tests
        └── dashboard.integration.test.tsx

2. Barrel Exports for Clean Imports

// app/dashboard/_components/index.ts
export { DashboardCard } from './DashboardCard';
export { DashboardChart } from './DashboardChart';
export { DashboardHeader } from './DashboardHeader';

// Usage in page.tsx
import { DashboardCard, DashboardChart } from './_components';

3. Route Handler Organization

app/
└── api/
    ├── v1/
    │   ├── users/
    │   │   ├── route.ts           # /api/v1/users
    │   │   └── [id]/
    │   │       └── route.ts       # /api/v1/users/:id
    │   └── products/
    │       └── route.ts
    └── webhooks/
        ├── stripe/
        │   └── route.ts           # /api/webhooks/stripe
        └── github/
            └── route.ts           # /api/webhooks/github

4. Middleware for Route Protection

// 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-token');
  const isAuthPage = request.nextUrl.pathname.startsWith('/login');
  const isProtectedRoute = request.nextUrl.pathname.startsWith('/dashboard');

  if (isProtectedRoute && !token) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('redirect', request.nextUrl.pathname);
    return NextResponse.redirect(loginUrl);
  }

  if (isAuthPage && token) {
    return NextResponse.redirect(new URL('/dashboard', request.url));
  }

  return NextResponse.next();
}

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

Key Takeaways

  1. File-system is the router: Folder structure directly maps to URL structure. Understanding conventions eliminates routing configuration.

  2. Colocation is safe: Files without special names (page, layout, route, etc.) don't become routes—colocate fearlessly.

  3. Layouts persist, templates remount: Use layouts for stable UI shells, templates for animation or fresh state requirements.

  4. Error boundaries are hierarchical: Place error.tsx strategically to create appropriate error isolation zones.

  5. Route groups organize without URL impact: Use (groupName) to create logical groupings, multiple layouts, or team-based organization.

  6. Private folders opt out entirely: Use _folderName when you absolutely don't want folder to participate in routing.

  7. Parallel routes enable complex UIs: Slots (@slotName) render multiple pages simultaneously for dashboards, modals, and conditional content.

  8. Intercepting routes power modals: Combine (.)folder patterns with parallel routes for Instagram-style modal navigation.

  9. Metadata files are convention-based: Place sitemap.ts, robots.ts, and image files correctly for automatic SEO handling.

  10. Choose organization strategy early: Feature-first, layer-first, or domain-driven—pick one and be consistent across the project.

What did you think?

Related Posts

© 2026 Vidhya Sagar Thakur. All rights reserved.