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

6. Получение данных

Получение данных — сердце любого приложения. В Next.js App Router это кардинально проще и мощнее, чем в обычном React. Никаких useEffect(() => { fetch... }, []) — данные приходят там, где нужны! 🎯

Обычный React: Next.js (App Router):
──────────────── ──────────────────────
useEffect async функция-компонент
→ setState(loading) → const data = await fetch(...)
→ fetch('/api/data') → return <div>{data}</div>
→ setState(data)
→ render с данными Сервер отдаёт готовый HTML!
→ клиент видит spinner
→ клиент видит данные

app/posts/page.tsx
export default async function PostsPage() {
// fetch прямо в компоненте — без useEffect!
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
const posts = await response.json();
return (
<main>
<h1>Посты ({posts.length})</h1>
<ul>
{posts.slice(0, 10).map((post: any) => (
<li key={post.id}>
<strong>{post.title}</strong>
<p>{post.body}</p>
</li>
))}
</ul>
</main>
);
}

Но это работает с любым источником данных:

// 1. Внешний API
const data = await fetch('https://api.example.com/data').then(r => r.json());
// 2. Прямой запрос к базе данных (ORM)
import { db } from '@/lib/prisma';
const users = await db.user.findMany({ where: { active: true } });
// 3. Файловая система
import { readFile } from 'fs/promises';
const content = await readFile('./data/posts.json', 'utf-8');
const posts = JSON.parse(content);
// 4. SDK/сторонние сервисы
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const products = await stripe.products.list({ limit: 10 });

Избегай водопадов! Запускай независимые запросы одновременно:

// ❌ ПЛОХО: Последовательные запросы (водопад)
export default async function Dashboard() {
const user = await getUser(); // 100ms
const posts = await getPosts(); // 100ms
const stats = await getStats(); // 100ms
// Итого: 300ms последовательно!
return <div>...</div>;
}
// ✅ ХОРОШО: Параллельные запросы с Promise.all
export default async function Dashboard() {
const [user, posts, stats] = await Promise.all([
getUser(), // ┐
getPosts(), // ├─ Все три запроса параллельно!
getStats(), // ┘
]);
// Итого: ~100ms (максимум из трёх)!
return (
<div>
<UserCard user={user} />
<PostsList posts={posts} />
<StatsGrid stats={stats} />
</div>
);
}
// Параллельные запросы с именованными переменными (читаемее)
export default async function DashboardPage() {
// Стартуем все запросы одновременно
const userPromise = db.user.findUnique({ where: { id: userId } });
const postsPromise = db.post.findMany({ where: { authorId: userId }, take: 5 });
const statsPromise = getAnalytics(userId);
// Ждём все
const [user, posts, stats] = await Promise.all([
userPromise,
postsPromise,
statsPromise,
]);
return <Dashboard user={user} posts={posts} stats={stats} />;
}

🔗 Последовательная загрузка (когда нужна)

Заголовок раздела «🔗 Последовательная загрузка (когда нужна)»

Иногда данные зависят друг от друга:

// Последовательно — когда результат одного запроса нужен для следующего
export default async function UserPostsPage({
params,
}: {
params: Promise<{ userId: string }>;
}) {
const { userId } = await params;
// Сначала получаем пользователя
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) notFound();
// Потом его посты (нужен user.teamId из первого запроса)
const posts = await db.post.findMany({
where: {
authorId: user.id,
teamId: user.teamId, // Зависит от первого запроса!
},
});
return (
<div>
<h1>Посты {user.name}</h1>
<PostsList posts={posts} />
</div>
);
}

Next.js расширяет встроенный fetch с поддержкой кеширования:

// 1. Кешировать навсегда (ISR / Static)
// Данные запрашиваются один раз при сборке, потом из кеша
const data = await fetch('https://api.example.com/static-data', {
cache: 'force-cache', // По умолчанию в Next.js 14 (изменилось в 15!)
});
// 2. Никогда не кешировать (SSR)
// Свежие данные при каждом запросе
const data = await fetch('https://api.example.com/live-data', {
cache: 'no-store',
});
// 3. Кешировать с периодическим обновлением (ISR)
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 }, // Обновлять каждый час
});
// 4. Кешировать с тегами (on-demand revalidation)
const posts = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }, // Можно сбросить тег программно!
});

Важно для Next.js 15: кеширование изменилось!

// Next.js 14 — кешировалось по умолчанию
const data = await fetch('/api/data'); // → force-cache
// Next.js 15 — НЕ кешируется по умолчанию!
const data = await fetch('/api/data'); // → no-store
// Если нужен кеш — указывай явно:
const data = await fetch('/api/data', { cache: 'force-cache' });
// или
const data = await fetch('/api/data', { next: { revalidate: 60 } });

🔁 Ревалидация: Обновление кешированных данных

Заголовок раздела «🔁 Ревалидация: Обновление кешированных данных»
app/blog/page.tsx
// Метод 1: Временная ревалидация
export const revalidate = 3600; // Страница обновляется каждый час
export default async function BlogPage() {
const posts = await fetch('https://api.blog.com/posts').then(r => r.json());
return <PostsList posts={posts} />;
}
// Метод 2: Теговая ревалидация (on-demand)
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const { tag, secret } = await request.json();
// Проверяем секрет (безопасность!)
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
revalidateTag(tag); // Сбросить кеш для этого тега
return Response.json({ revalidated: true, tag });
}
// Webhook от CMS: когда обновляется контент → POST /api/revalidate { tag: 'posts' }

Streaming позволяет отправлять HTML по частям, не дожидаясь всех данных:

app/dashboard/page.tsx
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div>
<h1>Дашборд</h1>
{/* Быстрые данные — рендерятся сразу */}
<UserGreeting />
{/* Медленные данные — не блокируют страницу */}
<Suspense fallback={<StatsSkeleton />}>
<SlowStats /> {/* Загружается параллельно! */}
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart /> {/* Загружается параллельно! */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<TransactionsTable /> {/* Загружается параллельно! */}
</Suspense>
</div>
);
}
// Каждый компонент загружает свои данные независимо!
async function SlowStats() {
const stats = await getStats(); // Медленный запрос
return <StatsGrid stats={stats} />;
}
async function RevenueChart() {
const revenue = await getRevenue(); // Другой медленный запрос
return <Chart data={revenue} />;
}
Без Streaming:
[===========================] 3s → Страница готова
С Streaming:
[=====] 0.5s → Базовый HTML
[=========] 1s → SlowStats готов
[==============] 1.5s → RevenueChart готов
[====================] 2s → TransactionsTable готов

Для ORM и прямых запросов к БД:

import { unstable_cache } from 'next/cache';
// Кешируем функцию с тегами
const getCachedPosts = unstable_cache(
async (userId: string) => {
return await db.post.findMany({
where: { authorId: userId },
include: { author: true },
});
},
['user-posts'], // ключ кеша
{
revalidate: 3600, // обновлять каждый час
tags: ['posts'], // тег для on-demand сброса
}
);
// Использование
export default async function UserPostsPage({ params }) {
const { userId } = await params;
const posts = await getCachedPosts(userId); // Кешируется!
return <PostsList posts={posts} />;
}

Формы с данными с сервера:

app/actions.ts
'use server';
export async function searchPosts(prevState: any, formData: FormData) {
const query = formData.get('query') as string;
if (!query || query.length < 2) {
return { error: 'Введите минимум 2 символа', posts: [] };
}
const posts = await db.post.findMany({
where: {
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ content: { contains: query, mode: 'insensitive' } },
],
},
take: 10,
});
return { posts, query, error: null };
}
// app/search/page.tsx
'use client';
import { useActionState } from 'react'; // React 19
import { searchPosts } from '../actions';
export default function SearchPage() {
const [state, formAction, isPending] = useActionState(searchPosts, {
posts: [],
error: null,
query: '',
});
return (
<div>
<form action={formAction}>
<input name="query" placeholder="Поиск..." />
<button type="submit" disabled={isPending}>
{isPending ? 'Ищем...' : 'Найти'}
</button>
</form>
{state.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state.posts.map(post => (
<div key={post.id}>{post.title}</div>
))}
</div>
);
}

  1. fetch() в Server Components — async/await прямо в компоненте
  2. Promise.all() — параллельные запросы, избегай водопадов
  3. Кешированиеcache: 'force-cache' / next: { revalidate: N }
  4. Теговая ревалидация — on-demand сброс кеша
  5. Suspense + Streaming — не жди медленных данных, стримь HTML
  6. unstable_cache — кеш для ORM и не-fetch запросов