Перейти к основному содержимому

Next.js разработка

Полное руководство по разработке современных веб-приложений с Next.js.

Создание проекта

Инициализация

# Создание нового проекта
npx create-next-app@latest my-next-app --typescript --tailwind --eslint --app
cd my-next-app

# Установка дополнительных зависимостей
npm install @next/font lucide-react
npm install -D @types/node

Структура проекта (App Router)

app/
├── globals.css
├── layout.tsx # Корневой layout
├── page.tsx # Главная страница
├── loading.tsx # UI загрузки
├── error.tsx # UI ошибок
├── not-found.tsx # 404 страница
├── (auth)/ # Группа маршрутов
│ ├── login/
│ │ └── page.tsx
│ └── register/
│ └── page.tsx
├── blog/
│ ├── page.tsx # /blog
│ └── [slug]/
│ └── page.tsx # /blog/[slug]
└── api/ # API маршруты
├── auth/
│ └── route.ts
└── users/
└── route.ts

Маршрутизация

App Router

// app/layout.tsx
import { Inter } from 'next/font/google';
import './globals.css';

const inter = Inter({ subsets: ['latin'] });

export const metadata = {
title: 'My Next.js App',
description: 'Описание приложения',
};

export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ru">
<body className={inter.className}>
<nav className="bg-gray-800 text-white p-4">
<div className="container mx-auto">
<h1 className="text-xl font-bold">My App</h1>
</div>
</nav>
<main className="container mx-auto p-4">
{children}
</main>
</body>
</html>
);
}
// app/page.tsx
import Link from 'next/link';

export default function HomePage() {
return (
<div>
<h1 className="text-3xl font-bold mb-4">Добро пожаловать</h1>
<div className="space-x-4">
<Link
href="/blog"
className="bg-blue-500 text-white px-4 py-2 rounded"
>
Блог
</Link>
<Link
href="/about"
className="bg-green-500 text-white px-4 py-2 rounded"
>
О нас
</Link>
</div>
</div>
);
}

Динамические маршруты

// app/blog/[slug]/page.tsx
interface PageProps {
params: { slug: string };
searchParams: { [key: string]: string | string[] | undefined };
}

async function getPost(slug: string) {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${slug}`, {
next: { revalidate: 3600 } // Кеширование на 1 час
});

if (!res.ok) {
throw new Error('Failed to fetch post');
}

return res.json();
}

export async function generateMetadata({ params }: PageProps) {
const post = await getPost(params.slug);

return {
title: post.title,
description: post.body.substring(0, 160),
};
}

export default async function BlogPost({ params }: PageProps) {
const post = await getPost(params.slug);

return (
<article className="max-w-2xl mx-auto">
<h1 className="text-3xl font-bold mb-4">{post.title}</h1>
<p className="text-gray-600 mb-8">{post.body}</p>

<Link
href="/blog"
className="text-blue-500 hover:underline"
>
← Назад к блогу
</Link>
</article>
);
}

// Статическая генерация путей
export async function generateStaticParams() {
const posts = await fetch('https://jsonplaceholder.typicode.com/posts')
.then((res) => res.json());

return posts.slice(0, 10).map((post: any) => ({
slug: post.id.toString(),
}));
}

API Routes

REST API

// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';

interface User {
id: number;
name: string;
email: string;
}

let users: User[] = [
{ id: 1, name: 'John Doe', email: 'john@example.com' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' },
];

export async function GET() {
return NextResponse.json(users);
}

export async function POST(request: NextRequest) {
try {
const body = await request.json();
const newUser: User = {
id: users.length + 1,
name: body.name,
email: body.email,
};

users.push(newUser);

return NextResponse.json(newUser, { status: 201 });
} catch (error) {
return NextResponse.json(
{ error: 'Invalid request body' },
{ status: 400 }
);
}
}
// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';

interface RouteParams {
params: { id: string };
}

export async function GET(
request: NextRequest,
{ params }: RouteParams
) {
const id = parseInt(params.id);
const user = users.find(u => u.id === id);

if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}

return NextResponse.json(user);
}

export async function PUT(
request: NextRequest,
{ params }: RouteParams
) {
const id = parseInt(params.id);
const userIndex = users.findIndex(u => u.id === id);

if (userIndex === -1) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}

const body = await request.json();
users[userIndex] = { ...users[userIndex], ...body };

return NextResponse.json(users[userIndex]);
}

export async function DELETE(
request: NextRequest,
{ params }: RouteParams
) {
const id = parseInt(params.id);
const userIndex = users.findIndex(u => u.id === id);

if (userIndex === -1) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}

users.splice(userIndex, 1);

return NextResponse.json({ success: true });
}

Middleware

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
// Проверка аутентификации
const token = request.cookies.get('auth-token')?.value;

if (request.nextUrl.pathname.startsWith('/dashboard')) {
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
}

// CORS заголовки для API
if (request.nextUrl.pathname.startsWith('/api/')) {
const response = NextResponse.next();
response.headers.set('Access-Control-Allow-Origin', '*');
response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
return response;
}

return NextResponse.next();
}

export const config = {
matcher: ['/dashboard/:path*', '/api/:path*'],
};

Серверные компоненты

Server Components

// app/posts/page.tsx (Server Component)
async function getPosts() {
const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
next: { revalidate: 60 } // Revalidate every minute
});

if (!res.ok) {
throw new Error('Failed to fetch posts');
}

return res.json();
}

export default async function PostsPage() {
const posts = await getPosts();

return (
<div>
<h1 className="text-2xl font-bold mb-6">Все посты</h1>
<div className="grid gap-4">
{posts.map((post: any) => (
<div key={post.id} className="border p-4 rounded">
<h2 className="font-semibold">{post.title}</h2>
<p className="text-gray-600">{post.body}</p>
<Link
href={`/posts/${post.id}`}
className="text-blue-500 hover:underline"
>
Читать далее
</Link>
</div>
))}
</div>
</div>
);
}

Client Components

// components/SearchPosts.tsx
'use client';

import { useState, useEffect } from 'react';

interface Post {
id: number;
title: string;
body: string;
}

export default function SearchPosts() {
const [query, setQuery] = useState('');
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(false);

useEffect(() => {
if (!query) {
setPosts([]);
return;
}

const searchPosts = async () => {
setLoading(true);
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await response.json();
setPosts(data);
} catch (error) {
console.error('Error searching posts:', error);
} finally {
setLoading(false);
}
};

const timer = setTimeout(searchPosts, 300);
return () => clearTimeout(timer);
}, [query]);

return (
<div className="mb-8">
<input
type="text"
placeholder="Поиск постов..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full p-2 border rounded"
/>

{loading && <p className="mt-2">Поиск...</p>}

{posts.length > 0 && (
<div className="mt-4 space-y-2">
{posts.map((post) => (
<div key={post.id} className="p-2 border rounded">
<h3 className="font-medium">{post.title}</h3>
<p className="text-sm text-gray-600">{post.body.substring(0, 100)}...</p>
</div>
))}
</div>
)}
</div>
);
}

Стилизация

Tailwind CSS с Next.js

// tailwind.config.js
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
500: '#3b82f6',
900: '#1e3a8a',
},
},
},
},
plugins: [
require('@tailwindcss/forms'),
require('@tailwindcss/typography'),
],
}

CSS Modules

/* components/Button.module.css */
.button {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
border: none;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}

.primary {
background-color: #3b82f6;
color: white;
}

.primary:hover {
background-color: #2563eb;
}

.secondary {
background-color: #6b7280;
color: white;
}

.disabled {
opacity: 0.5;
cursor: not-allowed;
}
// components/Button.tsx
import styles from './Button.module.css';
import { ButtonHTMLAttributes } from 'react';

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary';
isLoading?: boolean;
}

export default function Button({
children,
variant = 'primary',
isLoading,
className,
...props
}: ButtonProps) {
return (
<button
className={`${styles.button} ${styles[variant]} ${isLoading ? styles.disabled : ''} ${className}`}
disabled={isLoading || props.disabled}
{...props}
>
{isLoading ? 'Загрузка...' : children}
</button>
);
}

Аутентификация

NextAuth.js

npm install next-auth
// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';
import GoogleProvider from 'next-auth/providers/google';

const handler = NextAuth({
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' }
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}

// Здесь должна быть проверка пользователя в базе данных
const user = await verifyUser(credentials.email, credentials.password);

if (user) {
return {
id: user.id,
email: user.email,
name: user.name,
};
}

return null;
},
}),
],
session: {
strategy: 'jwt',
},
pages: {
signIn: '/login',
signOut: '/logout',
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (token) {
session.user.id = token.id as string;
}
return session;
},
},
});

export { handler as GET, handler as POST };
// components/AuthProvider.tsx
'use client';

import { SessionProvider } from 'next-auth/react';

export default function AuthProvider({
children,
}: {
children: React.ReactNode;
}) {
return <SessionProvider>{children}</SessionProvider>;
}
// app/login/page.tsx
'use client';

import { signIn, useSession } from 'next-auth/react';
import { redirect } from 'next/navigation';
import { useState } from 'react';

export default function LoginPage() {
const { data: session } = useSession();
const [loading, setLoading] = useState(false);

if (session) {
redirect('/dashboard');
}

const handleGoogleSignIn = async () => {
setLoading(true);
await signIn('google', { callbackUrl: '/dashboard' });
};

return (
<div className="max-w-md mx-auto mt-8">
<h1 className="text-2xl font-bold mb-6">Вход в систему</h1>

<button
onClick={handleGoogleSignIn}
disabled={loading}
className="w-full bg-red-500 text-white p-3 rounded hover:bg-red-600 disabled:opacity-50"
>
{loading ? 'Загрузка...' : 'Войти через Google'}
</button>
</div>
);
}

База данных

Prisma

npm install prisma @prisma/client
npx prisma init
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}

datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

model User {
id String @id @default(cuid())
email String @unique
name String?
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

model Post {
id String @id @default(cuid())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// lib/prisma.ts
import { PrismaClient } from '@prisma/client';

const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};

export const prisma = globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
// app/api/posts/route.ts
import { prisma } from '@/lib/prisma';
import { NextResponse } from 'next/server';

export async function GET() {
try {
const posts = await prisma.post.findMany({
include: {
author: {
select: {
name: true,
email: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
});

return NextResponse.json(posts);
} catch (error) {
return NextResponse.json(
{ error: 'Failed to fetch posts' },
{ status: 500 }
);
}
}

export async function POST(request: Request) {
try {
const { title, content, authorId } = await request.json();

const post = await prisma.post.create({
data: {
title,
content,
authorId,
},
include: {
author: {
select: {
name: true,
email: true,
},
},
},
});

return NextResponse.json(post, { status: 201 });
} catch (error) {
return NextResponse.json(
{ error: 'Failed to create post' },
{ status: 500 }
);
}
}

Развертывание

Vercel

// vercel.json
{
"buildCommand": "npm run build",
"outputDirectory": ".next",
"framework": "nextjs",
"regions": ["fra1"],
"env": {
"DATABASE_URL": "@database-url",
"NEXTAUTH_SECRET": "@nextauth-secret"
}
}

Docker

# Dockerfile
FROM node:18-alpine AS base

# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app

COPY package.json package-lock.json* ./
RUN npm ci

# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

RUN npm run build

# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app

ENV NODE_ENV production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public

COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT 3000

CMD ["node", "server.js"]
# docker-compose.yml
version: '3.8'

services:
nextjs:
build: .
ports:
- '3000:3000'
environment:
- DATABASE_URL=postgresql://user:password@postgres:5432/mydb
- NEXTAUTH_SECRET=your-secret-key
depends_on:
- postgres

postgres:
image: postgres:15
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: mydb
volumes:
- postgres_data:/var/lib/postgresql/data

volumes:
postgres_data:

Тестирование

Jest + Testing Library

// __tests__/components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import Button from '@/components/Button';

describe('Button', () => {
test('renders button with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});

test('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);

fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});

test('shows loading state', () => {
render(<Button isLoading>Submit</Button>);
expect(screen.getByText('Загрузка...')).toBeInTheDocument();
expect(screen.getByRole('button')).toBeDisabled();
});
});

E2E тестирование с Playwright

// tests/auth.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Authentication', () => {
test('should login successfully', async ({ page }) => {
await page.goto('/login');

await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'password123');
await page.click('button[type="submit"]');

await expect(page).toHaveURL('/dashboard');
await expect(page.locator('h1')).toContainText('Dashboard');
});

test('should show error for invalid credentials', async ({ page }) => {
await page.goto('/login');

await page.fill('[name="email"]', 'wrong@example.com');
await page.fill('[name="password"]', 'wrongpassword');
await page.click('button[type="submit"]');

await expect(page.locator('.error')).toContainText('Invalid credentials');
});
});