Вивчай

Dynamic Routes та getStaticPaths

Next.js Dynamic Routes — бібліотека з пронумерованими полицями де бібліотекар шукає потрібнуNext.js Dynamic Routes — бібліотека з пронумерованими полицями де бібліотекар шукає потрібну

У попередньому уроці ми навчилися отримувати дані через getStaticProps. Але як створити окрему SSG-сторінку для кожної статті блогу? Адже під час збірки Next.js повинен знати, які саме сторінки генерувати. Для цього існує getStaticPaths.


Проблема: динамічний SSG

Уяви, що у тебе блог з 50 статтями. Кожна стаття має свій URL: /blog/1, /blog/2, ..., /blog/50. З SSR (getServerSideProps) все просто -- сервер рендерить сторінку при кожному запиті. Але з SSG потрібно заздалегідь знати всі можливі шляхи.

getStaticPaths повертає масив всіх можливих параметрів, і Next.js генерує окрему HTML-сторінку для кожного з них.


getStaticPaths + getStaticProps

Ці дві функції працюють в парі:

// pages/blog/[id].tsx (Pages Router)
import type { GetStaticProps, GetStaticPaths, InferGetStaticPropsType } from "next";

interface Post {
  id: number;
  title: string;
  body: string;
}

// Крок 1: Вказати які шляхи генерувати
export const getStaticPaths: GetStaticPaths = async () => {
  const res = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=10");
  const posts: Post[] = await res.json();

  const paths = posts.map((post) => ({
    params: { id: String(post.id) }, // params мають бути рядками!
  }));

  return {
    paths,
    fallback: false, // 404 для неіснуючих шляхів
  };
};

// Крок 2: Отримати дані для конкретної сторінки
export const getStaticProps: GetStaticProps<{ post: Post }> = async ({ params }) => {
  const res = await fetch(
    `https://jsonplaceholder.typicode.com/posts/${params?.id}`
  );
  const post: Post = await res.json();

  return {
    props: { post },
  };
};

// Крок 3: Компонент сторінки
export default function BlogPostPage({
  post,
}: InferGetStaticPropsType<typeof getStaticProps>) {
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  );
}
Увага

Значення в params завжди мають бути рядками. Навіть якщо id -- це число, передавай String(post.id).

Що відбувається під час збірки:

  1. Next.js викликає getStaticPaths → отримує масив [{params: {id: "1"}}, {params: {id: "2"}}, ...]
  2. Для кожного шляху викликає getStaticProps з відповідними params
  3. Генерує 10 HTML-файлів: blog/1.html, blog/2.html, ..., blog/10.html

Fallback: що робити з неіснуючими сторінками?

Параметр fallback визначає, що відбувається, коли користувач відкриває шлях, якого немає в paths:

fallback: false

return {
  paths,
  fallback: false,
};

Будь-який шлях, якого немає в paths, повертає 404. Підходить, коли всі можливі шляхи відомі заздалегідь.

fallback: "blocking"

return {
  paths,
  fallback: "blocking",
};

Для невідомих шляхів Next.js генерує сторінку на льоту (SSR), потім кешує її як статичну:

  1. Перший запит до /blog/999 → сервер генерує HTML (як SSR)
  2. HTML кешується як статичний файл
  3. Наступні запити до /blog/999 → отримують кешовану статику

fallback: true

return {
  paths,
  fallback: true,
};

Схоже на "blocking", але браузер одразу отримує сторінку з fallback-станом (без даних), а потім дані підвантажуються:

import { useRouter } from "next/router";

export default function BlogPostPage({ post }: { post: Post }) {
  const router = useRouter();

  // Показуємо лоадер, поки дані генеруються
  if (router.isFallback) {
    return <div>Завантаження...</div>;
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  );
}
Порада

Використовуй fallback: "blocking" для більшості випадків -- це найпростіший варіант. fallback: true корисний, коли хочеш показати скелетон/лоадер одразу, не чекаючи генерації.


App Router: generateStaticParams

У сучасному App Router замість getStaticPaths використовується generateStaticParams -- простіша та елегантніша функція:

// src/app/blog/[slug]/page.tsx (App Router)

interface Post {
  id: number;
  title: string;
  body: string;
}

// Замість getStaticPaths
export async function generateStaticParams() {
  const res = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=10");
  const posts: Post[] = await res.json();

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

// Замість getStaticProps -- просто async компонент
export default async function BlogPostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const res = await fetch(
    `https://jsonplaceholder.typicode.com/posts/${slug}`
  );
  const post: Post = await res.json();

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  );
}
Інфо

В App Router немає потреби у getStaticProps -- компонент сам є асинхронним і може отримувати дані напряму. Це набагато простіше!


Catch-all Routes

Для вкладених шляхів довільної глибини використовуй [...slug]:

src/app/
  docs/
    [...slug]/
      page.tsx          → /docs/a, /docs/a/b, /docs/a/b/c
// src/app/docs/[...slug]/page.tsx
export default async function DocsPage({
  params,
}: {
  params: Promise<{ slug: string[] }>;
}) {
  const { slug } = await params;
  // /docs/react/hooks/useState → slug = ["react", "hooks", "useState"]

  return (
    <div>
      <h1>Документація</h1>
      <p>Шлях: {slug.join(" / ")}</p>
    </div>
  );
}

Optional Catch-all: [[...slug]]

Подвійні дужки роблять параметр опціональним -- маршрут спрацює і без сегменту:

src/app/
  docs/
    [[...slug]]/
      page.tsx          → /docs, /docs/a, /docs/a/b
export default async function DocsPage({
  params,
}: {
  params: Promise<{ slug?: string[] }>;
}) {
  const { slug } = await params;
  // /docs → slug = undefined
  // /docs/intro → slug = ["intro"]

  if (!slug) {
    return <h1>Головна сторінка документації</h1>;
  }

  return <h1>Документація: {slug.join(" / ")}</h1>;
}

Практичний приклад: блог з MDX

Уяви, що статті зберігаються як MDX-файли:

content/
  posts/
    hello-world.mdx
    nextjs-intro.mdx
    react-hooks.mdx
// src/lib/posts.ts
import fs from "fs";
import path from "path";
import matter from "gray-matter";

const postsDirectory = path.join(process.cwd(), "content/posts");

export interface PostMeta {
  slug: string;
  title: string;
  date: string;
  description: string;
}

export function getAllPostSlugs(): string[] {
  const fileNames = fs.readdirSync(postsDirectory);
  return fileNames.map((name) => name.replace(/\.mdx$/, ""));
}

export function getPostBySlug(slug: string): { meta: PostMeta; content: string } {
  const fullPath = path.join(postsDirectory, `${slug}.mdx`);
  const fileContents = fs.readFileSync(fullPath, "utf8");
  const { data, content } = matter(fileContents);

  return {
    meta: {
      slug,
      title: data.title,
      date: data.date,
      description: data.description,
    },
    content,
  };
}
// src/app/blog/[slug]/page.tsx
import { getAllPostSlugs, getPostBySlug } from "@/lib/posts";

export async function generateStaticParams() {
  const slugs = getAllPostSlugs();
  return slugs.map((slug) => ({ slug }));
}

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

  return (
    <article>
      <h1>{meta.title}</h1>
      <time>{meta.date}</time>
      <div>{content}</div>
    </article>
  );
}

Підсумок

  • getStaticPaths (Pages Router) вказує Next.js, які динамічні сторінки генерувати під час збірки
  • fallback: false (404), "blocking" (SSR + кеш), true (з лоадером)
  • generateStaticParams (App Router) -- сучасна заміна getStaticPaths
  • Catch-all routes [...slug] -- для шляхів довільної глибини
  • Optional catch-all [[...slug]] -- те ж, але необов'язковий сегмент

Що далі?

У наступному уроці вивчимо API Routes -- як створювати серверні endpoints прямо в Next.js, без окремого бекенду.

Інфо

Корисні посилання: