Реальное приложение: структура и блог

Складываем изученное в осмысленную структуру и тут же собираем из неё работающий мини-блог.

В реальном проекте app/ хранит только маршруты, переиспользуемый код выносят в components/ и lib/, а собранный блог показывает, как серверные и клиентские части стыкуются.

Раскладка проекта

Удобный и распространённый вариант — маршруты в app/, остальное рядом:

my-app/
├─ app/
│  ├─ blog/
│  │  ├─ page.tsx          → /blog
│  │  └─ [slug]/page.tsx   → /blog/:slug
│  └─ layout.tsx           ← корневой layout
├─ components/             ← переиспользуемые UI-компоненты
├─ lib/                    ← логика: доступ к данным, утилиты
└─ public/                 ← статика

Папку с именем на подчёркивание (_components) Next.js не считает маршрутом, а алиас @/ избавляет от длинных относительных путей (import { getPosts } from "@/lib/posts").

Доступ к данным в lib/

Логику получения статей выносим в lib/, чтобы страницы оставались простыми:

// lib/posts.ts
const posts = [
  { slug: "hello", title: "Привет, мир", body: "Первый пост" },
  { slug: "next", title: "Учим Next.js", body: "App Router рулит" },
];

export async function getAllPosts() { return posts; }
export async function getPost(slug) {
  return posts.find((p) => p.slug === slug);
}

Список статей — серверный компонент

Страница /blog грузит данные прямо в теле и рендерит ссылки:

// app/blog/page.tsx — серверный
import Link from "next/link";
import { getAllPosts } from "@/lib/posts";

export default async function BlogPage() {
  const posts = await getAllPosts();
  return (
    <ul>
      {posts.map((p) => (
        <li key={p.slug}>
          <Link href={"/blog/" + p.slug}>{p.title}</Link>
        </li>
      ))}
    </ul>
  );
}

Статья — динамический сегмент + метаданные

// app/blog/[slug]/page.tsx — серверный
import { getPost } from "@/lib/posts";
import LikeButton from "./LikeButton";

export async function generateMetadata({ params }) {
  const post = await getPost(params.slug);
  return { title: post.title };
}

export default async function PostPage({ params }) {
  const post = await getPost(params.slug);
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
      <LikeButton />
    </article>
  );
}

Лайк — клиентский остров

Интерактив крошечный и вынесен в отдельный клиентский компонент, чтобы остальная страница осталась серверной:

"use client";
import { useState } from "react";

export default function LikeButton() {
  const [likes, setLikes] = useState(0);
  return <button onClick={() => setLikes(likes + 1)}>❤ {likes}</button>;
}

Как это работает вместе

Соберём поток данных от запроса до страницы на JS-модели:

const steps = [
  "запрос /blog/next",
  "серверный PostPage грузит статью из lib",
  "generateMetadata ставит title",
  "HTML с готовой статьёй едет в браузер",
  "клиентский LikeButton оживает после гидратации",
];
steps.forEach((s, i) => console.log((i + 1) + ". " + s));

Вывод:

1. запрос /blog/next
2. серверный PostPage грузит статью из lib
3. generateMetadata ставит title
4. HTML с готовой статьёй едет в браузер
5. клиентский LikeButton оживает после гидратации

Итог

  • В app/ держите только маршруты; общий код — в components/ и lib/, импорт через алиас @/.
  • Список и статья — серверные компоненты; адрес статьи — динамический сегмент [slug] + generateMetadata.
  • Интерактив (лайк) — маленький клиентский остров поверх серверной страницы.
Проверьте себя
1. Что должно лежать в папке app/ в хорошо организованном проекте?
AВсе компоненты и утилиты сразу
BТолько маршруты; переиспользуемый код — в components/ и lib/
CТолько статика
DКонфигурационные файлы
2. Каким компонентом удобнее всего сделать список статей блога?
AКлиентским с useEffect для загрузки
BСерверным — он грузит данные прямо в теле и отдаёт готовый HTML
CRoute Handler
DТолько статическим HTML-файлом
3. Почему кнопку лайка вынесли в отдельный клиентский компонент?
AТак требует ESLint
BЧтобы остальная страница осталась серверной, а интерактив был маленьким островом
CКлиентские компоненты грузятся быстрее
DИначе не работает generateMetadata
4. Что задаёт уникальный заголовок (title) для каждой статьи?
AОбъект metadata в layout
BФункция generateMetadata, получающая params
CАтрибут title у article
DФайл loading.tsx
Поддержать проект