Next.js 15: Dynamic routes and Static Site Generation (SSG)

Explore Next.js dynamic routing and Static Site Generation (SSG) through practical examples.

Daniel Chorągwicki

Frontend Developer

2024-12-17

#Frontend

In this article

Intro

Defining dynamic routes

Catch-all segments

Optional catch-all segments

Generating static pages

Conclusion

Intro

One of the key elements of any website or web application is routing. It enables users to navigate between different views and pages. Routing can be static or dynamic. In the case of static routing, the paths are predefined and do not change. Dynamic routing, on the other hand, is the practice of creating paths based on dynamic data and user interactions.

In this article, you will learn how to use dynamic routes in Next.js by utilizing the app router and which conventions to follow. We will also cover more advanced topics such as catch-all segments, static page generation (SSG), and the use of the generateStaticParams function.

Request a free Next.js consultation

Facing technological challenges? Contact us for a free consultation in just 1 step!

What do you want to talk about?

How can we contact you?

You consent to being contacted by e-mail for the purpose of handling this request.

Defining dynamic routes

Next.js allows the creation of dynamic paths using a convention with square brackets. Importantly (and this is a change compared to version 12 and its Pages Router), paths are created solely through folders.

1
2
3
4
app/
└── blog/
    └── [slug]/
        └── page.tsx

In this case, the [slug] folder is a dynamic segment of the article page path and is passed to the component as params with a name corresponding to the folder's name.

1
2
3
4
5
example url: `/blog/introduction-to-next-js`
params: { slug: 'introduction-to-next-js' }

example url: `/blog/developing-a-great-website-in-next-js`
params: { slug: 'developing-a-great-website-in-next-js' }
1
2
3
4
5
6
7
// src/app/blog/[slug]/page.tsx

export default function Article({ params }: { params: Promise<{ slug: string }> }) {
    const slug = (await params).slug;

	return <h1>Article slug: {slug}</h1>;
}

Parameters are passed not only to page as mentioned above but also to layout, route, as well as to functions like generateMetadata and generateStaticParams.

USEPARAMS

The above example pertains to server components and functions executed on the server side. If you want to use a parameter in a client component, you can use the useParams hook.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
'use client'
 
import { useParams } from 'next/navigation'
 
export const ExampleClientComponent = () => {
	const params = useParams<{ category: string; slug: string }>()
	
	// Route -> /blog/[category]/[slug]
	// URL -> /blog/next-js/introduction-to-next-js
	// `params` -> { category: 'next-js', slug: 'introduction-to-next-js' }
	console.log(params)
	
	// ...
}

NESTED DYNAMIC ROUTES

Dynamic paths can be nested as needed. Below is an example of a folder structure for a blog category page with the path /blog/frontend/2:

1
2
3
4
5
app/
└── blog/
    └── [category]/
        └── [pageNumber]/
            └── page.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/app/blog/[category]/[pageNumber]/page.tsx

export default function ArticleList({
    params,
}: {
    params: Promise<{ category: string; pageNumber: string }>;
}) {
    const category = (await params).category;
	const pageNumber = (await params).pageNumber;

    return (
        <>
            <h1>List category: {category}</h1>
            <p>Current page: {pageNumber}</p>
        </>
    );
}

Catch-all segments

Let's expand our example of an article list by adding a sub-category page. All blog paths will include a list of articles with pagination. We want the pagination to be reflected in the path. Here are possible examples of the sub-pages:

  • /blog/2
  • /blog/frontend/2
  • /blog/frontend/react/4

Using nesting, the file structure might look like this:

1
2
3
4
5
6
7
8
9
10
app/
└── blog/
    ├── [pageNumber]/
    │   └── page.tsx
    └── [category]/
        ├── [pageNumber]/
        │   └── page.tsx
        └── [subcategory]/
            └── [pageNumber]/
                └── page.tsx

Assuming that each sub-page includes filters, a list, and pagination, a potential issue is that each page.tsx file may render a similar view, leading to excessive complexity and maintenance difficulties.

With Next.js, we can use the Catch-all Segments technique to handle all dynamic segments of the path with a single page.tsx file. We achieve this by adding an ellipsis before the name of the dynamic segment.

1
2
3
4
app/
└── blog/
    └── [...slug]/
        └── page.tsx

Parameters in the path will be passed to the file in the following way:

1
2
3
4
5
6
7
8
example url: `/blog/3`
params: { slug: ['3'] }

example url: `/blog/frontend/2`
params: { slug: ['frontend', '2'] }

example url: `/blog/frontend/react/4`
params: { slug: ['frontend', 'react', '4'] }

Here's an example of the Catch-all Segment component with simplified handling of slug, assuming that the last segment of the path is the page number for pagination:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src/app/blog/[...slug]/page.tsx

export default function ArticleList({
    params,
}: {
    params: Promise<{ slug: string[] }>;
}) {
  	const slug = (await params).slug
    let currentPage;

    slug.forEach((segment, index) => {
        if (index + 1 === slug.length && typeof segment === 'number') {
            currentPage = segment;
        } else {
            // Category and subcategory handling
        }
    });

    // ...
}

Optional catch-all segments

In the example above, we assume that a sub-page is displayed only if the final segment is a page number, so the default blog page would have the path /blog/1. In this case, the path /blog would return an error indicating that the page was not found.

If you want the /blog path to display the default list of articles on the first page of pagination, you can use Optional Catch-all Segments by adding extra square brackets to the dynamic segment.

1
2
3
4
app/
└── blog/
    └── [[...slug]]/
        └── page.tsx

At the moment, page.tsx also handles the /blog path without any parameters (by default showing the first page of pagination).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/app/blog/[[...slug]]/page.tsx

export default function ArticleList({
    params,
}: {
    params: Promise<{ slug?: string[] }>;
}) {
	const slug = (await params).slug
    let currentPage = 1;

    slug?.forEach((segment, index) => {
        // ...
    });

    // ...
}

Request a free Next.js consultation

Facing technological challenges? Contact us for a free consultation in just 1 step!

What do you want to talk about?

How can we contact you?

You consent to being contacted by e-mail for the purpose of handling this request.

Generating static pages

Dynamic pages are generated by fetching data from the backend. In the case of the Next.js framework, this is achieved by creating an asynchronous server component page and fetching data using the fetch API. Since the component renders on the server side, the request is made directly within the component body.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/app/blog/[category]/[subcategory]/page.tsx

export default async function ArticleList({
	params,
}: {
	params: Promise<{ category: string; subcategory: string }>;
}) {
  	const category = (await params).category
   	const subcategory = (await params).subcategory 

    const articles = await fetch(
		`[...]/articles?category=${category}&subcategory=${subcategory}`,
	).then((res) => res.json());

	// ...
}

In this case (excluding Next.js caching mechanisms, which you can read about here), the request to the backend will be made each time a user requests our page. Therefore, the user will have to wait until the page renders on the server side to receive clean HTML with the page content.

To avoid this waiting time, we can use the static site generation method to render the subpage not on user demand, but at the time of building the application. With this method, the server will already have pre-rendered HTML files, and the user will not experience delays related to server-side requests.

To achieve this, we need to implement the generateStaticParams function. We can do this in the page.tsx or layout.tsx file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src/app/blog/[category]/[subcategory]/page.tsx

export function generateStaticParams() {
	return [
		{ category: "frontend", subcategory: "next-js" },
		{ category: "frontend", subcategory: "react" },
		{ category: "frontend", subcategory: "framer-motion" },
		{ category: "backend", subcategory: "php" },
		{ category: "backend", subcategory: "node-js" },
	];
}

export default async function ArticleList({
	params,
}: {
	params: Promise<{ category: string; subcategory: string }>;
}) {
	// ...
}

Above, we defined an array of objects with category and subcategory. During the build, they will be passed as params to the ArticleList component, and then fetched via the API to generate 5 pre-rendered HTML files.

GENERATING ONLY PART OF THE PAGES

If there are other categories not included in the explicitly defined parameters above, they will be rendered server-side on demand, just as when generateStaticParams is not used.

However, if we want to limit the number of subpages to only the 5 defined above, we can use a special route segment configuration option called dynamicParams, which makes any parameters outside those generated by generateStaticParams return a 404 error.

1
2
3
4
5
6
7
// src/app/blog/[category]/[subcategory]/page.tsx

export const dynamicParams = false;

export function generateStaticParams() {
	// ...
}

ASYNCHRONOUS generateStaticParams

If we want to generate static parameters on the server side using dynamic data from the backend, we can use generateStaticParams as an asynchronous function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/app/blog/[category]/[subcategory]/page.tsx

export async function generateStaticParams() {
	const subcategories = await fetch(`[...]/subcategories`).then((res) =>
		res.json(),
	);

	return subcategories.map((subcategory) => {
		return {
			category: subcategory.parent.slug,
			subcategory: subcategory.slug,
		};
	});
}

// ...

A downside of this approach – especially with a large number of subpages – can be a long build time. The Next.js creators addressed this by reducing compilation time through intelligent data fetching. Requests in generateStaticParams are automatically memoized, meaning that requests with the same arguments in different generateStaticParams will only be executed once.

generateStaticParams IN LAYOUT

Optionally, generateStaticParams accepts parameters returned from generateStaticParams invoked in a parent component. Importantly, this function is executed in the child component for each parameter generated in the parent.

Let’s consider a hypothetical situation where we want to create a layout with a different primary color depending on the category. In the page for the list, we want to fetch articles filtered by subcategories. In this case, we can split the parameter generation into two places.

In layout.tsx, we fetch the categories:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/app/blog/[category]/layout.tsx

export async function generateStaticParams() {
	const categories = await fetch(`[...]/categories`).then((res) =>
		res.json(),
	);

	return categories.map((category) => {
		return {
			category: category.slug,
		};
	});
}

export default async function Layout({
	params,
}: {
	params: Promise<{ category: string }>;
}) {
	const category = (await params).category

    const color = COLORS[category];

	// ...
}

In page.tsx, for the subcategories, we fetch articles for the list using the categories that were previously generated:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// src/app/blog/[category]/[subcategory]/page.tsx

export async function generateStaticParams(params: { category }) {
	const subcategories = await fetch(
		`[...]/subcategories?category=${category}`,
	).then((res) => res.json());

	return subcategories.map((subcategory) => {
		return {
			subcategory: subcategory.slug,
		};
	});
}

export default async function ArticleList({
	params,
}: {
	params: Promise<{ category: string; subcategory: string }>;
}) {
	// ...
}

Conclusion

The article discussed basic conventions as well as more advanced techniques for working with dynamic routes in Next.js 15. Understanding this topic and leveraging Next.js routing capabilities allows for building scalable web applications and complex websites. By using methods to generate dynamic subpages at build time, we can significantly improve loading times and enhance SEO.

Daniel Chorągwicki

Frontend Developer

Share this post

Related posts

Frontend

Next.js 15 has been officially released!

Frontend

How to create a mobile app from a web app in just a few hours

Frontend

Pigment CSS - new library from Material UI

Frontend

React 19 has been officially announced!

Frontend

Next.js 14: Exploring fundamental concepts

Frontend

Website Accessibility

Want to light up your ideas with us?

Kickstart your new project with us in just 1 step!

Dev and Deliver

sp. z o.o. sp. k.

    Address

  • Józefitów 8
  • 30-039 Cracow, Poland

    NIP

  • 9452214307
  • Regon

  • 368739409
  • KRS

  • 94552994