Пошук уроків, статей та іншого контенту
Блог із публічним списком статей, сторінкою окремого поста, Server Actions для створення контенту та готовою SEO-конфігурацією.
Вимоги
Етапи
Продумано структуру маршрутів App Router — список статей, сторінка окремого поста, форма створення — і яка частина рендериться на сервері, а яка потребує клієнтської інтерактивності.
Модель Post описана в Prisma-схемі й застосована міграцією.
Схема повторює ідею з уроку «Таблиці: створення схеми даних» курсу PostgreSQL — Prisma лише генерує відповідний SQL DDL за цим декларативним описом:
prisma/schema.prisma
model Post {
id String @id @default(uuid())
title String
slug String @unique
excerpt String
content Json
published Boolean @default(false)
createdAt DateTime @default(now())
}Цей проєкт — практичний capstone, що зводить докупи курси Next.js, PostgreSQL і основи Node.js в одному застосунку: схема даних у PostgreSQL, доступ до неї через Prisma, читання даних у Server Components, запис — через Server Actions, і SEO-метадані для кожної сторінки.
Не обов'язково будувати повноцінну систему автентифікації для цього проєкту — форму створення статті можна вважати «внутрішньою» сторінкою; головна мета тут — Server Actions, валідація й читання/запис даних, а не авторизація (яка вже розібрана окремо в статті «JWT Authentication» і в курсі NestJS).
Продумайте архітектуру маршрутів: список статей, сторінка окремого поста, форма створення.
Опишіть модель Post у Prisma-схемі та застосуйте міграцію.
Підключіть єдиний, перевикористовуваний клієнт Prisma до застосунку.
Реалізуйте список статей і сторінку окремого поста як Server Components.
Додайте Server Action для створення статті з формою.
Валідуйте вхідні дані форми на сервері перед записом у базу.
Розбийте список статей на сторінки курсорною пагінацією.
Додайте динамічні метадані (generateMetadata) для кожної сторінки статті.
Підготуйте проєкт до розгортання: змінні середовища, перевірка білда, застосування міграцій.
Пройдіть курси Next.js і PostgreSQL перед цим проєктом, якщо ще не робили цього, — зокрема уроки про Server Actions, отримання даних у Server Components та метадані/SEO, і урок про таблиці в курсі PostgreSQL.
Підказки
Тримайте Post.slug унікальним (UNIQUE-обмеження в схемі) і генеруйте його з заголовка при створенні, а не покладайтесь, що автор форми завжди введе коректний, унікальний slug вручну.
revalidatePath("/blog") після створення статті — інакше список статей і далі показуватиме закешовану версію без нового поста.
Для курсорної пагінації сортуйте за (createdAt, id) разом, а не лише за createdAt — це гарантує стабільний, однозначний порядок навіть якщо дві статті створені в ту саму мілісекунду.
bash
npx prisma migrate dev --name init_postcontent має тип Json, а не String, — так само, як Lesson.content і Article.content уже в цьому проєкті: структурований формат (наприклад, той самий Tiptap JSON) гнучкіший за суцільний текст чи HTML-рядок.
Єдиний, перевикористовуваний екземпляр PrismaClient підключений до застосунку.
У розробці Next.js гаряче перезавантаження модулів може створювати новий PrismaClient (і нове підключення до бази даних) на кожну зміну файлу — типове рішення кешує єдиний екземпляр у глобальному об'єкті, щоб уникнути вичерпання пулу з'єднань бази даних:
src/server/db/client.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}Головна сторінка блогу показує список опублікованих статей, кожна веде на власну сторінку.
Обидві сторінки — Server Components: дані читаються напряму, без useEffect/useState (курс Next.js, урок про отримання даних у Server Components).
app/blog/page.tsx
import { prisma } from "@/server/db/client";
export default async function BlogPage() {
const posts = await prisma.post.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
take: 10,
});
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<a href={`/blog/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
);
}app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { prisma } from "@/server/db/client";
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params; // params — Promise, починаючи з Next.js 15
const post = await prisma.post.findUnique({ where: { slug } });
if (!post || !post.published) notFound();
return <article><h1>{post.title}</h1></article>;
}Форма створення поста зберігає дані на сервері без окремого API-ендпоінта.
Та сама ідея, що й в уроці «Server Actions: мутації без API-роутів» курсу Next.js — функція з "use server", викликана напряму з форми:
app/blog/new/actions.ts
"use server";
import { prisma } from "@/server/db/client";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const title = formData.get("title");
const content = formData.get("content");
// Валідація — детально в наступному кроці
await prisma.post.create({
data: {
title: title as string,
slug: slugify(title as string),
excerpt: (content as string).slice(0, 160),
content: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: content }] }] },
published: true,
},
});
revalidatePath("/blog"); // без цього список статей і далі показує старий кеш
}app/blog/new/page.tsx
import { createPost } from "./actions";
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" placeholder="Заголовок" required />
<textarea name="content" placeholder="Текст статті" required />
<button type="submit">Опублікувати</button>
</form>
);
}Дані форми перевіряються на сервері перед записом у базу — незалежно від клієнтської валідації.
Server Action можна викликати не лише з форми — вхідні дані так само потребують повної серверної перевірки, як і будь-який традиційний API-ендпоінт:
app/blog/new/actions.ts — валідація
import { z } from "zod";
const createPostSchema = z.object({
title: z.string().min(3).max(200),
content: z.string().min(10),
});
export async function createPost(formData: FormData) {
const parsed = createPostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
});
if (!parsed.success) {
throw new Error("Некоректні дані статті");
}
const { title, content } = parsed.data;
// ...prisma.post.create(...) з уже перевіреними title/content
}Список статей розбитий на сторінки курсорною пагінацією — за (createdAt, id), як описано в статті «Pagination: offset vs cursor».
Кожна сторінка статті має власні title/description для пошукових систем і соцмереж.
Та сама generateMetadata функція, що й в уроці «Метадані та SEO» курсу Next.js, — заголовок і опис виводяться з даних конкретної статті:
app/blog/[slug]/page.tsx — метадані
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await prisma.post.findUnique({ where: { slug } });
if (!post) return {};
return {
title: post.title,
description: post.excerpt,
openGraph: { title: post.title, description: post.excerpt },
};
}Змінні середовища винесені з коду, білд проходить без помилок, міграції готові до застосування в продакшн-базі даних (npx prisma migrate deploy, а не migrate dev).