Урок 10

Практика: 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 и revalidatePath
  • bind для передачи ID
  • Prisma для данных
  • useActionState для формы
  • API-роут для внешних клиентов