Next.js fundamentals

How Next.js App Router Folders, Layouts, and Data-Loading Routes Fit Together

Follow one Next.js App Router example from folder structure to nested layouts, dynamic posts, query-driven data loading, and loading UI.

Editorial illustration for How Next.js App Router Folders, Layouts, and Data-Loading Routes Fit Together

A useful way to plan an App Router route is to separate three decisions: Which URL identifies the page? Which UI is shared? Which URL values affect its data? Folders answer the first question, layouts answer the second, and page props give a Server Component the parameters it needs for the third.

Turn URLs into folders

Each folder under app represents a URL segment. A page.tsx supplies the page for that path; a folder without a page does not, by itself, make a page publicly accessible. Here is a small blog with an index and individual posts:

app/
  layout.tsx                 → shared root layout
  blog/
    layout.tsx               → shared blog layout
    loading.tsx              → blog loading UI
    posts.ts                 → example data-loading functions
    page.tsx                 → /blog
    [slug]/
      page.tsx               → /blog/first-post, /blog/another-post, …

The bracketed folder is a dynamic segment: its slug value comes from that part of the path. The posts.ts file is colocated with the routes but does not create another URL. Next.js documents both the segment-to-URL mapping and the distinction between route files and colocated code in its App Router routing guide.

Put shared UI in layouts

The root layout is required and must render <html> and <body>. Both layouts receive their child page—or another layout—through children:

// app/layout.tsx
import type { ReactNode } from 'react';

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <header>Next.js Notes</header>
        {children}
      </body>
    </html>
  );
}
// app/blog/layout.tsx
import type { ReactNode } from 'react';
import Link from 'next/link';

export default function BlogLayout({ children }: { children: ReactNode }) {
  return (
    <section>
      <nav aria-label="Blog">
        <Link href="/blog">All posts</Link>{' · '}
        <Link href="/blog?topic=next">Next.js posts</Link>
      </nav>
      {children}
    </section>
  );
}

For either blog URL, the rendered nesting is root layout → blog layout → page. The root header and blog navigation belong to the layouts; the list or individual post belongs to the page. During navigation, shared layouts remain in place rather than rerendering, as described in the layouts and pages documentation.

Use a path for the post, a query for the view

A post slug identifies a resource, so /blog/first-post is a good fit for [slug]. A topic filter changes which posts appear on the index, so /blog?topic=next keeps the same page path and adds a query parameter. Pagination can follow the same query-string pattern.

Pages and layouts are Server Components by default. When a query value determines what data the page loads, read it from the page’s searchParams prop. This example keeps the data source deliberately small so the URL-to-data flow is visible:

// app/blog/posts.ts
const posts = [
  { slug: 'first-post', title: 'First post', topic: 'next' },
  { slug: 'another-post', title: 'Another post', topic: 'react' },
];

export async function loadAllPosts() {
  return posts;
}

export async function loadPostsByTopic(topic?: string) {
  return topic ? posts.filter((post) => post.topic === topic) : posts;
}

export async function loadPostBySlug(slug: string) {
  return posts.find((post) => post.slug === slug);
}
// app/blog/page.tsx
import Link from 'next/link';
import { loadPostsByTopic } from './posts';

export default async function BlogPage({
  searchParams,
}: {
  searchParams: Promise<{ topic?: string }>;
}) {
  const { topic } = await searchParams;
  const posts = await loadPostsByTopic(topic);

  return (
    <main>
      <h1>{topic ? `Posts about ${topic}` : 'All posts'}</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.slug}>
            <Link href={`/blog/${post.slug}`}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </main>
  );
}

Here, topic changes the server-loaded list. Reading searchParams in a Server Component page opts that page into dynamic rendering because the value comes from the incoming request. This says nothing about a particular fetch cache or revalidation policy; the example’s data functions simply read an in-file array.

There is a different choice when the full list has already been loaded and the query only controls filtering in the browser. A Client Component can use useSearchParams for that job. For example, this could be used instead of the server-side topic filter above, with posts supplied by its parent:

// app/blog/ClientPostFilter.tsx
'use client';

import { useSearchParams } from 'next/navigation';

type PostPreview = { slug: string; title: string; topic: string };

export default function ClientPostFilter({ posts }: { posts: PostPreview[] }) {
  const topic = useSearchParams().get('topic');
  const visible = topic
    ? posts.filter((post) => post.topic === topic)
    : posts;

  return <ul>{visible.map((post) => <li key={post.slug}>{post.title}</li>)}</ul>;
}

That distinction—searchParams for loading page data, useSearchParams for client-only use of query values—is the practical rule in the layouts and pages documentation.

Pre-render known post paths

The post page reads its identity from params, not from the index page’s topic query. generateStaticParams supplies the known slug values for build-time prerendering:

// app/blog/[slug]/page.tsx
import { loadAllPosts, loadPostBySlug } from '../posts';

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

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await loadPostBySlug(slug);

  return (
    <main>
      {post ? <h1>{post.title}</h1> : <p>Post not found.</p>}
    </main>
  );
}

The returned objects correspond to dynamic path segments, such as /blog/first-post. They are not query-string combinations: generateStaticParams describes post paths, while /blog?topic=next is a query-driven view of the index. The linking and navigating documentation describes generateStaticParams as the way to ensure eligible dynamic paths are generated at build time.

Give the blog a loading fallback

A loading.tsx in the blog folder provides UI while that route content is loading. Next.js places the page content inside a Suspense boundary and can show the fallback until the content is ready:

// app/blog/loading.tsx
export default function Loading() {
  return <p>Loading blog content…</p>;
}

To check the hierarchy, follow the links from /blog to a post and back, then try the topic link. With <Link> navigation, the shared header and blog navigation remain while the page content changes; if the next content is still loading, the loading fallback can take its place temporarily. That is the route structure doing its job, not a second copy of the layout.

Find a note

Search by topic, title, or keyword.