Практика: CRUD-приложение
Собрали типичный CRUD на Next.js с Server Actions, Prisma и кешированием.
Схема
model Post {
id Int @id @default(autoincrement())
title String
slug String @unique
body String
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Клиент БД
// lib/db.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const db = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
Действия
// app/posts/actions.ts
'use server';
import { db } from '@/lib/db';
import { revalidatePath, revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
const PostSchema = z.object({
title: z.string().min(3, 'Минимум 3 символа').max(200),
body: z.string().min(10, 'Минимум 10 символов'),
published: z.coerce.boolean().default(false),
});
function slugify(text: string) {
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
}
export async function createPost(prevState, formData: FormData) {
const result = PostSchema.safeParse({
title: formData.get('title'),
body: formData.get('body'),
published: formData.get('published') === 'on',
});
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
const post = await db.post.create({
data: {
...result.data,
slug: slugify(result.data.title),
},
});
revalidateTag('posts');
revalidatePath('/posts');
redirect(`/posts/${post.id}`);
}
export async function updatePost(id: number, prevState, formData: FormData) {
const result = PostSchema.safeParse({
title: formData.get('title'),
body: formData.get('body'),
published: formData.get('published') === 'on',
});
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
await db.post.update({
where: { id },
data: { ...result.data, slug: slugify(result.data.title) },
});
revalidateTag('posts');
revalidatePath(`/posts/${id}`);
revalidatePath('/posts');
redirect(`/posts/${id}`);
}
export async function deletePost(id: number) {
await db.post.delete({ where: { id } });
revalidateTag('posts');
revalidatePath('/posts');
}
Список
// app/posts/page.tsx
import { db } from '@/lib/db';
import Link from 'next/link';
import { deletePost } from './actions';
export const revalidate = 60;
export default async function Posts() {
const posts = await db.post.findMany({
orderBy: { createdAt: 'desc' },
});
return (
<div>
<div>
<h1>Посты</h1>
<Link href="/posts/new">Создать</Link>
</div>
{posts.length === 0 ? (
<p>Постов пока нет</p>
) : (
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link href={`/posts/${post.id}`}>{post.title}</Link>
<span>{post.published ? 'Опубликован' : 'Черновик'}</span>
<Link href={`/posts/${post.id}/edit`}>Изменить</Link>
<form action={deletePost.bind(null, post.id)}>
<button>Удалить</button>
</form>
</li>
))}
</ul>
)}
</div>
);
}
Просмотр
// app/posts/[id]/page.tsx
import { db } from '@/lib/db';
import { notFound } from 'next/navigation';
import Link from 'next/link';
export async function generateMetadata({ params }) {
const { id } = await params;
const post = await db.post.findUnique({ where: { id: Number(id) } });
return { title: post?.title ?? 'Не найдено' };
}
export default async function Post({ params }) {
const { id } = await params;
const post = await db.post.findUnique({ where: { id: Number(id) } });
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
<Link href={`/posts/${id}/edit`}>Редактировать</Link>
<Link href="/posts">К списку</Link>
</article>
);
}
Создание
// app/posts/new/page.tsx
import { PostForm } from '../_components/PostForm';
import { createPost } from '../actions';
export default function NewPost() {
return (
<div>
<h1>Новый пост</h1>
<PostForm action={createPost} />
</div>
);
}
Форма
// app/posts/_components/PostForm.tsx
'use client';
import { useActionState } from 'react';
interface Post {
title: string;
body: string;
published: boolean;
}
export function PostForm({ action, post }: { action: any; post?: Post }) {
const [state, formAction, pending] = useActionState(action, null);
return (
<form action={formAction}>
<div>
<label htmlFor="title">Заголовок</label>
<input
id="title"
name="title"
defaultValue={post?.title}
required
/>
{state?.errors?.title && <p>{state.errors.title[0]}</p>}
</div>
<div>
<label htmlFor="body">Текст</label>
<textarea
id="body"
name="body"
defaultValue={post?.body}
required
/>
{state?.errors?.body && <p>{state.errors.body[0]}</p>}
</div>
<label>
<input
type="checkbox"
name="published"
defaultChecked={post?.published}
/>
Опубликовать
</label>
<button disabled={pending}>{pending ? 'Сохранение...' : 'Сохранить'}</button>
</form>
);
}
Редактирование
// app/posts/[id]/edit/page.tsx
import { db } from '@/lib/db';
import { notFound } from 'next/navigation';
import { PostForm } from '../../_components/PostForm';
import { updatePost } from '../../actions';
export default async function EditPost({ params }) {
const { id } = await params;
const post = await db.post.findUnique({ where: { id: Number(id) } });
if (!post) notFound();
const actionWithId = updatePost.bind(null, post.id);
return (
<div>
<h1>Редактирование</h1>
<PostForm action={actionWithId} post={post} />
</div>
);
}
API-роут для мобильных
// app/api/posts/route.ts
export async function GET() {
const posts = await db.post.findMany();
return Response.json(posts);
}
Итоги
- Server Actions для CRUD
- Zod для валидации
revalidateTagиrevalidatePathbindдля передачи ID- Prisma для данных
useActionStateдля формы- API-роут для внешних клиентов