Урок 11

Практика: типичный проект

Собрали структуру и паттерны для типичного Next.js-проекта.

Структура

app/
├── layout.tsx
├── page.tsx
├── globals.css
├── (auth)/
│   ├── login/page.tsx
│   └── register/page.tsx
├── (dashboard)/
│   ├── layout.tsx
│   ├── dashboard/page.tsx
│   └── settings/page.tsx
├── posts/
│   ├── page.tsx
│   ├── loading.tsx
│   ├── [id]/
│   │   ├── page.tsx
│   │   └── not-found.tsx
│   └── new/page.tsx
├── api/
│   └── posts/route.ts
└── actions/
    └── posts.ts

lib/
├── db.ts
├── auth.ts
└── utils.ts

components/
├── ui/
│   ├── Button.tsx
│   └── Input.tsx
└── posts/
    ├── PostCard.tsx
    └── PostForm.tsx

Список с пагинацией

// app/posts/page.tsx
export default async function Posts({
    searchParams,
}: {
    searchParams: Promise<{ page?: string }>;
}) {
    const { page = '1' } = await searchParams;
    const currentPage = Number(page);

    const [posts, total] = await Promise.all([
        getPosts(currentPage),
        getPostsCount(),
    ]);

    const totalPages = Math.ceil(total / 10);

    return (
        <>
            <ul>
                {posts.map((post) => (
                    <PostCard key={post.id} post={post} />
                ))}
            </ul>
            <Pagination current={currentPage} total={totalPages} />
        </>
    );
}

Детальная страница

// app/posts/[id]/page.tsx
import { notFound } from 'next/navigation';

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

export default async function Post({ params }) {
    const { id } = await params;
    const post = await getPost(id);

    if (!post) notFound();

    return (
        <article>
            <h1>{post.title}</h1>
            <p>{post.body}</p>
        </article>
    );
}

Создание через Server Action

// app/actions/posts.ts
'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';

const schema = z.object({
    title: z.string().min(3),
    body: z.string().min(10),
});

export async function createPost(prevState, formData: FormData) {
    const result = schema.safeParse({
        title: formData.get('title'),
        body: formData.get('body'),
    });

    if (!result.success) {
        return { errors: result.error.flatten().fieldErrors };
    }

    const post = await db.post.create({ data: result.data });

    revalidatePath('/posts');
    redirect(`/posts/${post.id}`);
}

Форма

// app/posts/new/Form.tsx
'use client';

import { useActionState } from 'react';
import { createPost } from '@/app/actions/posts';

export function PostForm() {
    const [state, action, pending] = useActionState(createPost, null);

    return (
        <form action={action}>
            <div>
                <input name="title" />
                {state?.errors?.title && <p>{state.errors.title[0]}</p>}
            </div>
            <div>
                <textarea name="body" />
                {state?.errors?.body && <p>{state.errors.body[0]}</p>}
            </div>
            <button disabled={pending}>
                {pending ? 'Создание...' : 'Создать'}
            </button>
        </form>
    );
}

Дашборд с защитой

// app/(dashboard)/layout.tsx
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';

export default async function DashboardLayout({ children }) {
    const session = await auth();
    if (!session) redirect('/login');

    return (
        <div className="dashboard">
            <Sidebar user={session.user} />
            <main>{children}</main>
        </div>
    );
}

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;

Singleton, чтобы не создавать много клиентов в dev.

Переменные окружения

.env.local:

DATABASE_URL=postgresql://...
AUTH_SECRET=...
NEXT_PUBLIC_API_URL=http://localhost:3000
  • NEXT_PUBLIC_* — доступны на клиенте
  • Остальные — только на сервере

Итоги

  • Группы (auth), (dashboard)
  • Server Actions для мутаций
  • useActionState для состояния формы
  • Zod для валидации
  • Prisma singleton
  • Защита в layout