Dive into Next.js App Router: Building Dynamic, Nested, and Static Pages
Nik Bogachenkov
Posted on October 10, 2024
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
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>
);
}
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>;
}
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>;
}
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>;
}
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>
);
}
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));
}
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>;
}
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.
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 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>;
}
[...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]
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>;
}
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
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>
);
}
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
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>
);
}
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;
};
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
};
};
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
}));
}
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>;
}
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?
- generateStaticParams: This only generates static routes but does not cache the data for server components.
-
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();
}
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>
);
}
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.
Posted on October 10, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.