Практика: типичный проект
Собрали структуру и паттерны для типичного 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