Реальное приложение: структура и блог
Складываем изученное в осмысленную структуру и тут же собираем из неё работающий мини-блог.
В реальном проекте
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