DEV Community

Cover image for Dive into Next.js App Router: Building Dynamic, Nested, and Static Pages
Nik Bogachenkov
Nik Bogachenkov

Posted on

Dive into Next.js App Router: Building Dynamic, Nested, and Static Pages

Meet the App Router: A New Era of Routing in Next.js

Now that we've explored how server components work, it’s time to see them in action within the Next.js framework. Enter the App Router—the centerpiece of the new architecture in Next.js.

The App Router is designed to merge the best of both worlds: server and client components, all within a single route. This isn’t just an upgrade from the old Pages Router—it’s a complete overhaul that allows you to manage rendering more flexibly and significantly boost performance.

With the App Router, you can combine server-side rendering with client-side interactivity, reducing server requests and improving the overall performance of your application. Let’s dive into how it works and why this approach is a game-changer in routing.

A Brief History: Pages Router vs. App Router

When Next.js first gained popularity, the Pages Router became the go-to routing solution. Every file in the pages/ directory automatically received a corresponding URL—a simple, intuitive approach that allowed developers to quickly spin up applications. There was no need to think about routes: just add a file, and you were good to go.

But as projects grew and became more complex, some limitations started to appear:

  • Limited flexibility: The Pages Router offered a linear routing structure, which sometimes made it hard to manage code splitting and rendering efficiently.
  • State and rendering management: Even with support for SSR and SSG, the Pages Router required separate API endpoints for server-side data handling, adding unnecessary complexity when dealing with both client and server logic.

Despite these drawbacks, the Pages Router was convenient for those who wanted to get up and running quickly with Next.js.

App Router: A Different Approach

The introduction of the App Router brought a new wave of possibilities. It's important to understand that this isn’t just a replacement for the Pages Router—it’s a completely different approach to solving the same problems, with more flexible tools. With it, you can combine server and client components and dynamically split code without being limited by file structure.

Here are a few key changes:

  • Component flexibility: Now, each route is more than just a file—it’s a component that can merge server-side and client-side rendering depending on performance and SEO needs.
  • Server Actions: A new feature that lets you call server functions directly from components, simplifying data handling and eliminating the need for separate API endpoints.

So, the App Router isn’t necessarily the "better" choice, but it’s certainly more flexible. It gives you more control over rendering and state management, offering tools that are better suited for building complex and scalable applications.

App Router File Structure: Layouts, Pages, Error Handling, and More

file structure

Next.js’ App Router offers a more powerful and flexible system for managing page rendering and handling various user interface scenarios. Unlike the Pages Router, which had a straightforward approach to routing, the App Router introduces a set of files that help create a more sophisticated app structure, enhancing both performance and user interaction. Let’s break down the key file types used in the App Router.

layout.tsx — Page Layouts

Every route in the App Router can have its own layout component. This file defines the main structure of the page, which persists while navigating through nested routes. Layouts are perfect for defining persistent UI elements (like headers or footers) that remain unchanged while the page content swaps out.

Example of layout.tsx usage:

// app/products/layout.tsx
export default function ProductsLayout({ children }: { children: React.ReactNode }) {
  return (
    <div>
      <header>Products Header</header>
      <main>{children}</main>
      <footer>Products Footer</footer>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

In this example, when navigating between pages within /products, the header and footer stay fixed, while the content in main dynamically changes based on the route.

page.tsx — Page Component

Each page.tsx file is a component that renders for a specific route. It’s the primary file for outputting UI and data to a page.

Example of usage:

// app/products/page.tsx
export default function ProductsPage() {
  return <div>Products List</div>;
}
Enter fullscreen mode Exit fullscreen mode

loading.tsx — Loading Indicator

This file is used to display a loading indicator while data is being fetched. It improves UX by showing users that something is happening instead of leaving them with a blank screen.

Example:

// app/products/loading.tsx
export default function Loading() {
  return <div>Loading products...</div>;
}
Enter fullscreen mode Exit fullscreen mode

not-found.tsx — Not Found Page

This component renders when a route isn’t found. For example, if a user tries to access a product that doesn’t exist in the database, they’ll see a "Product not found" message.

Example:

// app/products/not-found.tsx
export default function NotFound() {
  return <div>Product not found</div>;
}
Enter fullscreen mode Exit fullscreen mode

error.tsx — Local Error Handling

If an error occurs while rendering a specific route, the error.tsx component will display instead of the usual UI. It helps handle local errors, such as data-fetching failures.

Example:

// app/products/error.tsx
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div>
      <p>Something went wrong: {error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

global-error.tsx — Global Error Handling

This component is for capturing errors at the application level. If an unexpected error occurs that isn’t tied to a specific route, global-error.tsx provides a user-friendly error message.

route.ts — API Endpoints

The route.ts file is used to create server-side API endpoints directly within the app directory. This allows you to manage server requests without leaving the routing structure.

Example:

// app/products/route.ts
export async function GET() {
  const products = await getProducts();
  return new Response(JSON.stringify(products));
}
Enter fullscreen mode Exit fullscreen mode

template.tsx — Layout Re-rendering

This file behaves similarly to layout.tsx, but with an important difference: it re-renders every time you navigate between routes, unlike layout, which stays static.

default.tsx — Parallel Route Fallback

When using a parallel route and a specific segment is missing, default.tsx serves as a fallback component to display the default UI.

Example:

// app/products/(category)/default.tsx
export default function DefaultCategory() {
  return <div>Please select a category</div>;
}
Enter fullscreen mode Exit fullscreen mode

This component is useful when no specific route is selected, and you need to show a default interface.

These files create a more modular and flexible app structure, giving developers greater control over rendering, state management, and error handling. The App Router is a powerful tool for building complex applications with optimized user interfaces, such as your e-commerce store, where different pages use various layouts and dynamic parameters.

The app Directory Structure: Grouping, Dynamic Routes, and Routing

The app/ directory in Next.js App Router provides a flexible and powerful system for organizing routes and components. Unlike the Pages Router, the App Router lets you structure your application more thoughtfully and efficiently using groupings, dynamic segments, private folders, and special routes. Let’s dive into the key elements of this system.

Route Grouping - (folder)

Route grouping using parentheses allows you to organize your code more logically without affecting the URL. This is particularly useful when you want to structure related components or routes without changing their final paths.

Route grouping

For instance, a (home) group organizes the homepage components without changing the main / route, while a (category) group organizes routes for product categories (like /products/electronics, /products/furniture), keeping your code clean and structured.

Dynamic Routes

Dynamic routes

Dynamic routes allow you to render pages with variable parameters in the URL. In Next.js, this is done using square brackets, making routing flexible and easy to work with.

[folder] — Dynamic Segment:

For example, the route /products/[slug] can be used to render a specific product page where slug is a dynamic parameter (like lamp).

// app/products/[slug]/page.tsx
export default function ProductPage({ params }: { params: { slug: string } }) {
return <div>Product slug: {params.slug}</div>;
}
Enter fullscreen mode Exit fullscreen mode

[...folder] — Catch-all Segment:

It captures multiple levels of the route. For instance, the route /products/[...slug] will handle /products/electronics/tv/123, capturing the parameters as ['electronics', 'tv', '123'].

[[…folder]] — Optional Catch-all Segment:

This handles routes both with and without parameters. For example, /products/[[...slug]] works for /products as well as /products/electronics/tv/123.

_folder - Private Folders

Folders prefixed with _ are excluded from routing, allowing you to store utilities or helper files without exposing them to public routes.

Next.js also supports parallel and intercepted routes, which we’ll cover in more detail in the next section.


Params & Search Params: Managing URLs on the Fly

When it comes to working with dynamic URLs in Next.js, the App Router provides two powerful tools—Params and Search Params. These mechanisms help you pass parameters through URLs and handle them in your components, making your pages more dynamic and interactive.

Params: Dynamic URL Segments

One of the most common scenarios is working with dynamic URL segments. Imagine you have a product page, and you need to pass the product ID through the URL. In App Router, you can easily achieve this using Params.

For example, if you have a route for a specific product:

/products/[id]
Enter fullscreen mode Exit fullscreen mode

You can now access this id inside your component:

// app/products/[id]/page.tsx
type ProductPageProps = {
  params: { id: string };
};

export default function ProductPage({ params }: ProductPageProps) {
  const { id } = params;

  return <div>Product ID: {id}</div>;
}
Enter fullscreen mode Exit fullscreen mode

When a user navigates to /products/123, the component receives id = 123, meaning you can easily fetch and display the correct product on the page. All you need is the parameter.

Search Params: Query Parameters in the URL

But what if you need to add filtering or sorting? That’s where search parameters, or Search Params, come into play. They let you pass additional data through the query string.

Here’s an example of a URL with search parameters:

/products?category=electronics&sort=price
Enter fullscreen mode Exit fullscreen mode

By using useSearchParams in the App Router, you can access these parameters:

// app/products/page.tsx
import { useSearchParams } from 'next/navigation';

export default function ProductsPage() {
  const searchParams = useSearchParams();
  const category = searchParams.get('category');
  const sort = searchParams.get('sort');

  return (
    <div>
      <h1>Products</h1>
      <p>Category: {category}</p>
      <p>Sort by: {sort}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

As a result, if a user navigates to /products?category=electronics&sort=price, the component displays the "electronics" category and sorts products by price. Simple and effective.

Combo: Params + Search Params

Now imagine a more complex scenario: you want to combine dynamic URL segments with search parameters. For example, on a product page, you need to pass both the product id and filter the reviews:

/products/[id]?filter=positive
Enter fullscreen mode Exit fullscreen mode

Here’s how you can implement this:

// app/products/[id]/page.tsx
import { useSearchParams } from 'next/navigation';

type ProductPageProps = {
  params: { id: string };
};

export default function ProductPage({ params }: ProductPageProps) {
  const searchParams = useSearchParams();
  const filter = searchParams.get('filter');

  return (
    <div>
      <h1>Product ID: {params.id}</h1>
      <p>Filter: {filter}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now your component handles both the dynamic id for the product and the filter for reviews. This way, users can see the product page with only positive reviews, just by passing parameters through the URL.

Full Control Over Every Aspect of the URL

The combination of Params and Search Params gives you incredible flexibility in managing URLs. It allows you to change displayed data on the fly, enhancing the user experience. Ultimately, the App Router in Next.js makes managing parameters an intuitive and powerful tool for building dynamic, responsive pages.

Static Rendering vs. ISR in Pages Router & App Router

When working with Pages Router and App Router in Next.js, the approaches to handling static data and incremental static regeneration (ISR) may appear similar, but there are key differences worth noting.

Let’s start with an example where we fetch product data using Supabase:

import { QueryData, createClient } from "@supabase/supabase-js";

export const getProduct = async ({ slug }: Params) => {
  const supabase = createClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );
  const productDetailedQuery = supabase
    .from("products")
    .select()
    .eq("slug", slug)
    .single();

  console.log("Fetching product");
  const { data, error } = await productDetailedQuery;

  if (error) {
    throw new Error(error.message);
  }
  return data as ProductWithCategory;
};
Enter fullscreen mode Exit fullscreen mode

Pages Router: Static Rendering and ISR

In the Pages Router, static methods like getStaticPaths and getStaticProps structure the process. It’s predictable and straightforward—you generate all possible routes ahead of time and cache the data at build time.

export const getStaticPaths = async () => {
  const { data: products } = await supabase
    .from("products")
    .select("slug");

  const paths = products.map(product => ({
    params: { slug: product.slug }
  }));

  return { paths, fallback: false };
};

export const getStaticProps: GetStaticProps<Props> = async ({ params }) => {
  const product = await getProduct({ slug: params.slug });

  return {
    props: { product },
    revalidate: 3600, // ISR: cache for 1 hour
  };
};
Enter fullscreen mode Exit fullscreen mode

Here, getStaticPaths creates all product routes at build time, and getStaticProps returns the product data for each route. The data is cached, and the page regenerates only after the revalidate interval has passed. This means users will not trigger a Supabase query every time they visit a statically generated page, as long as the cached version is still valid. Great for performance, but it requires pre-building all routes.

App Router: Flexibility and Dynamism

Now, let’s look at the App Router. It starts similarly by using generateStaticParams to generate static routes:

export const revalidate = 3600;

export async function generateStaticParams() {
  const { data: products } = await supabase
    .from("products")
    .select("slug");

  return products.map(({ slug }) => ({
    slug
  }));
}
Enter fullscreen mode Exit fullscreen mode

However, here's where things get interesting: In the App Router, server components are rendered on every request, even if the page has been statically generated.

// app/products/[slug]/page.tsx
export default async function ProductPage({ params }) {
  const product = await getProduct({ slug: params.slug });

  return <div>{product.name}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Every time a user visits a product page, the server component fetches the product data, and the Supabase query is triggered again, even if the page was previously statically generated. This is a major difference from the Pages Router.

Why does this happen?

  1. generateStaticParams: This only generates static routes but does not cache the data for server components.
  2. Server components: They are rendered server-side on each request. If ISR is enabled (via the revalidate option), it applies to the page itself, not the queries inside the server component.

How does this impact performance?

The App Router adds greater flexibility for developers, allowing dynamic route generation without the need to cache data upfront. However, unlike the Pages Router, ISR in the App Router primarily governs the rendering process, not the caching of database or API requests. This makes the approach more dynamic but may require extra optimization for repeated database queries.

In short, while Pages Router excels at static generation and caching, App Router leans towards more dynamic, real-time data fetching, which offers greater flexibility but can also introduce more overhead if not carefully optimized.

Caching Requests in the App Router: Why and How?

So, now you know how data handling in the App Router differs from the Pages Router. But what if you need to cache requests and control how often they're executed? Unlike Pages Router, where data could be cached during build time, App Router gives you more flexibility—which means we need to approach caching differently.

Let’s explore two popular ways to cache requests in the App Router.

1. Using the Next.js fetch API

Next.js offers built-in support for caching through its fetch API. By default, it caches data, but you can configure it with more precision. For example, to cache data for one hour, you can use the next: { revalidate: 3600 } option:

export async function getProduct({ slug }) {
  const response = await fetch(`https://api/products/${slug}`, {
    next: { revalidate: 3600 }, // Cache the data for one hour
  });

  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

It's simple: whenever a user requests the product page, Next.js checks if the revalidate time has passed. If it has, the request is made again; if not, the cached response is used. This is handy when you need data to refresh periodically but not on every single request.

2. The Experimental Approach: unstable_cache

If you need more granular control over caching, Next.js provides an experimental feature called unstable_cache. This allows you to explicitly cache certain requests and even assign tags for cache invalidation.

Here’s an example of caching product data using unstable_cache:

export default async function ProductPage({
  params: { slug },
}: {
  params: ProductPageParams;
}) {
  const getProductCached = unstable_cache(
    () => getProduct({ slug }),
    [slug],
    {
      tags: ["products"],
      revalidate: 3600,
    }
  );

  const product = await getProductCached();

  if (!product) {
    notFound();
  }

  return (
    <div className="py-4">
      <ProductDetails product={product} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

What’s happening here? The unstable_cache function caches the result of the request based on key parameters (in this case, slug). You can also set caching tags (like ["products"]), which gives you more flexibility when managing cache invalidation. The revalidate option controls how long the data stays cached.

Cache Control = Performance Control

Caching is a key part of performance optimization. It helps you avoid unnecessary API or database requests while ensuring that users get fresh data. With Next.js, you have full control over how and when data should refresh—whether you go with the built-in fetch caching or opt for the more flexible unstable_cache.

Now, you can manage caching in your App Router-powered app like a pro, saving resources and speeding up responses for your users!


Now that we’ve covered how the App Router structures pages and enhances navigation with components like layout.tsx and loading.tsx, it’s time to dive into more advanced routing features. Two of the key functions here are parallel routes and intercepting routes.

Parallel routes allow you to load and render multiple parts of the interface simultaneously, boosting performance and enabling more dynamic content handling. Intercepting routes take things even further, offering flexibility to control how content is displayed—like showing modal windows on top of an existing page.

In the next section, we’ll explore how parallel and intercepting routes work together to help you create intuitive, responsive interfaces with smooth navigation.

Top comments (0)