Guide complet de récupération de données dans les Server Components Next.js : fetch, requêtes BDD et bonnes pratiques

La première fois que j’ai écrit un composant dans Next.js App Router, j’ai vu ce code :
async function Page() {
const data = await fetch('...')
return <div>{data}</div>
}
C’est tout ? Un simple await ? Pas de useEffect, pas de useState, pas de souci de conditions de course ?
J’étais un peu perdu. Habitué au modèle client React, on me dit soudain que « les composants peuvent être asynchrones » — comme si les règles avaient changé. Et la question qui trotte : fetch API ou requête directe en base ? fetch, ça ajoute une couche API ; base directe, et la peur d’exposer les clés côté client.
Si vous partagez ces doutes, cet article est pour vous. On voit la bonne approche pour la récupération de données dans les Server Components Next.js — quand utiliser fetch, quand interroger la base, comment écrire un composant async, comment contrôler le cache, et les pièges fréquents.
Bases de la récupération de données dans les Server Components
Pourquoi les Server Components peuvent await directement ?
La réponse : les Server Components s’exécutent côté serveur, pas dans le navigateur.
Évident, mais crucial. Les composants React classiques tournent dans le navigateur — pas d’accès direct à la base ou au système de fichiers. Les Server Components s’exécutent sur le serveur, donc tout ce qu’on faisait dans les routes API :
- Connexion directe à la base (Prisma, Drizzle, SQL natif)
- Lecture du système de fichiers (fichiers markdown, etc.)
- Appels à des services internes (sans souci de cross-origin)
- Variables d’environnement et secrets (jamais envoyés au client)
Ce code est donc totalement sûr :
// app/posts/page.tsx
import { db } from '@/lib/db'
async function PostsPage() {
// Requête directe — les clés ne partent pas vers le navigateur
const posts = await db.post.findMany()
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
export default PostsPage
Points clés :
- Composant déclaré
async function awaitdirectement sur la requête BDD- Pas de hooks React (
useState,useEffect, etc.) - Par défaut, rendu côté serveur — le client ne reçoit que le HTML
Trois principales façons de récupérer des données
Dans un Server Component, trois options :
1. fetch API
La plus familière — API externe ou votre propre Route Handler :
async function Page() {
const res = await fetch('https://api.example.com/data')
const data = await res.json()
return <div>{data.title}</div>
}
2. Requête directe en base
ORM ou client natif :
import { db } from '@/lib/db'
async function Page() {
const data = await db.posts.findFirst()
return <div>{data.title}</div>
}
3. Server Actions
Pour les mutations (formulaires, suppressions) — pas seulement lire, aussi modifier :
async function createPost(formData: FormData) {
'use server'
const title = formData.get('title')
await db.post.create({ data: { title } })
}
Lequel choisir ? On y revient plus bas.
fetch vs requête BDD — comment choisir ?
C’était ma grande hésitation au début avec App Router. Avec du recul, la réponse est assez claire.
Arbre de décision : 5 secondes
Trois questions :
- C’est un Server Component ? → Oui, continuez ; non (Client Component), passez à la question 3
- D’où viennent les données ?
- Votre base → requête directe
- API tierce → fetch
- Besoin depuis un Client Component ? → Créer une route API, puis fetch
C’est tout.
Pourquoi privilégier la base directe ?
La doc Next.js est claire : dans un Server Component, ne contournez pas par une route API — interrogez directement.
Raisons concrètes :
1. Un aller-retour HTTP en moins
// ❌ Détour : Server Component → API Route → Database
async function Page() {
const res = await fetch('/api/posts') // Appel HTTP
const posts = await res.json()
return <PostList posts={posts} />
}
// ✅ Direct : Server Component → Database
async function Page() {
const posts = await db.post.findMany() // Requête directe
return <PostList posts={posts} />
}
La seconde voie évite une couche — réponse plus rapide. « De combien ? » — cumulé, 100–200 ms de gain se voient.
2. Meilleure sécurité de typage
Avec TypeScript + Prisma/Drizzle, typage complet :
// Inférence automatique, autocomplétion parfaite
const post = await db.post.findFirst({
include: { author: true, comments: true }
})
// post.author.name ← typé
// post.comments[0].content ← idem
Avec fetch, types manuels ou as — plus d’erreurs.
3. Code plus simple
Pas de fichier API supplémentaire, pas de codes HTTP à gérer — moitié moins de code.
Quand l’API/fetch reste obligatoire
Trois cas :
Cas 1 : le Client Component a besoin des données
Impossible d’interroger la base depuis le navigateur — il faut un endpoint :
// app/api/posts/route.ts
export async function GET() {
const posts = await db.post.findMany()
return Response.json(posts)
}
// components/client-posts.tsx
'use client'
export function ClientPosts() {
const [posts, setPosts] = useState([])
useEffect(() => {
fetch('/api/posts')
.then(res => res.json())
.then(setPosts)
}, [])
return <div>{/* render posts */}</div>
}
Cas 2 : API publique pour d’autres services
App mobile, tiers — endpoints publics nécessaires.
Cas 3 : services tiers
GitHub API, OpenAI API, etc. — fetch direct :
async function Page() {
const res = await fetch('https://api.github.com/users/vercel')
const user = await res.json()
return <div>Followers: {user.followers}</div>
}
La bonne syntaxe async/await
Mode de base : d’une simplicité déconcertante
async function ProductPage({ params }: { params: { id: string } }) {
const product = await db.product.findUnique({
where: { id: params.id }
})
if (!product) {
return <div>Product not found</div>
}
return (
<div>
<h1>{product.name}</h1>
<p>{product.price}</p>
</div>
)
}
Pas de loading, pas de useEffect — on attend les données, on rend. Propre.
Parallèle vs séquentiel : écart de perf énorme
Piège que j’ai connu. Lequel est le plus rapide ?
// ❌ Séquentiel : lent
async function Page() {
const user = await db.user.findFirst() // 200 ms
const posts = await db.post.findMany() // +150 ms
const comments = await db.comment.findMany() // +100 ms
// Total 450 ms
return <Dashboard user={user} posts={posts} comments={comments} />
}
// ✅ Parallèle : rapide
async function Page() {
const [user, posts, comments] = await Promise.all([
db.user.findFirst(),
db.post.findMany(),
db.comment.findMany(),
])
// Total 200 ms (le plus lent)
return <Dashboard user={user} posts={posts} comments={comments} />
}
Plus de 2× d’écart ! Sans dépendance entre requêtes, Promise.all obligatoire.
Avec dépendance, séquentiel :
// Séquentiel obligatoire : la 2e dépend de la 1re
async function Page({ params }) {
const user = await db.user.findUnique({ where: { id: params.id } })
const posts = await db.post.findMany({ where: { authorId: user.id } })
return <Profile user={user} posts={posts} />
}
Suspense : contrôler l’expérience de chargement
« Les données arrivent côté serveur — l’utilisateur voit un écran blanc ? »
Oui, mais loading.js et Suspense améliorent ça.
Méthode 1 : fichier loading.js
// app/posts/loading.tsx
export default function Loading() {
return <div>Loading posts...</div>
}
// app/posts/page.tsx
async function PostsPage() {
const posts = await db.post.findMany()
return <PostList posts={posts} />
}
L’utilisateur voit d’abord « Loading posts… », puis le contenu.
Méthode 2 : Suspense manuel
import { Suspense } from 'react'
async function SlowComponent() {
const data = await slowQuery() // 3 s
return <div>{data}</div>
}
async function FastComponent() {
const data = await fastQuery() // 0,5 s
return <div>{data}</div>
}
export default function Page() {
return (
<div>
<FastComponent />
<Suspense fallback={<div>Loading...</div>}>
<SlowComponent />
</Suspense>
</div>
)
}
Erreur fréquente : mauvais emplacement de Suspense
// ❌ Suspense à l'intérieur du composant async — inefficace
async function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
{await slowQuery()}
</Suspense>
)
}
// ✅ Suspense enveloppe le composant async de l'extérieur
export default function Layout() {
return (
<Suspense fallback={<div>Loading...</div>}>
<SlowPage />
</Suspense>
)
}
Suspense doit être à l’extérieur du composant async pour intercepter la promesse.
Déduplication automatique des requêtes
Même requête appelée plusieurs fois dans un rendu — Next.js déduplique :
async function Header() {
const user = await db.user.findFirst() // Requête 1
return <div>{user.name}</div>
}
async function Sidebar() {
const user = await db.user.findFirst() // Requête 2 — pas réellement exécutée
return <div>{user.name}</div>
}
export default function Page() {
return (
<div>
<Header />
<Sidebar />
{/* Une seule requête BDD en pratique */}
</div>
)
}
Next.js mémorise le premier résultat. Même source dans plusieurs composants — pas de souci perf.
Stratégies de cache et revalidation
Next.js 15 a changé les règles — beaucoup de pièges.
Changement de valeur par défaut en Next.js 15
Avant (Next.js 14) : fetch avec cache: 'force-cache' par défaut.
Maintenant (Next.js 15) : cache: 'no-store' par défaut — pas de cache.
Pourquoi ? Trop de développeurs pensaient que les données étaient à jour alors qu’elles restaient en cache. Le défaut « sans cache » paraît plus intuitif.
Conséquence : en passant de 14 à 15, les pages peuvent ralentir — les endpoints autrefois mis en cache sont rappelés à chaque fois.
Trois stratégies de cache
1. Cache complet (sites statiques)
async function BlogPost({ slug }) {
const post = await fetch(`https://api.example.com/posts/${slug}`, {
cache: 'force-cache'
})
return <article>{post.content}</article>
}
Articles de blog, fiches produit, documentation — contenu peu changeant.
2. Pas de cache (données temps réel)
async function StockPrice() {
const price = await fetch('https://api.example.com/stock', {
cache: 'no-store'
})
return <div>Current price: {price}</div>
}
Cours boursiers, commentaires live, statut utilisateur.
3. Revalidation planifiée (ISR)
async function ProductList() {
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 60 }
})
return <div>{products.map(p => <Card key={p.id} {...p} />)}</div>
}
Listes produits, page d’accueil actualités — quelques secondes de retard acceptables.
Revalidation manuelle
Après une mutation (nouveau post), rafraîchir le cache immédiatement :
1. revalidatePath (toute la page)
'use server'
import { revalidatePath } from 'next/cache'
async function createPost(formData: FormData) {
await db.post.create({ data: {...} })
revalidatePath('/posts')
}
2. revalidateTag (par tag)
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }
})
return res.json()
}
'use server'
import { revalidateTag } from 'next/cache'
async function createPost() {
await db.post.create({ data: {...} })
revalidateTag('posts')
}
Gestion d’erreurs et optimisation
Ne pas laisser la page crasher
Échec de récupération — la page entière peut planter. À gérer.
Méthode 1 : try/catch
async function Page() {
try {
const data = await fetch('https://api.example.com/data')
if (!data.ok) throw new Error('Failed to fetch')
return <div>{data.title}</div>
} catch (error) {
return <div>Something went wrong. Please try again.</div>
}
}
Méthode 2 : fichier error.js
// app/posts/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error
reset: () => void
}) {
return (
<div>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</div>
)
}
Piège : redirect intercepté par try/catch
// ❌ redirect capturé par catch
async function Page() {
try {
const user = await getUser()
if (!user) redirect('/login')
} catch (error) {
return <div>Error</div>
}
}
// ✅ redirect hors du try/catch
async function Page() {
let user
try {
user = await getUser()
} catch (error) {
return <div>Error</div>
}
if (!user) redirect('/login')
}
Erreurs courantes et solutions
Erreur 1 : chemin relatif en fetch côté serveur
// ❌ Pas de base URL côté serveur
async function Page() {
const data = await fetch('/api/posts')
}
// ✅ URL absolue
async function Page() {
const data = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/posts`)
}
// ✅ Mieux : base directe
async function Page() {
const posts = await db.post.findMany()
}
Erreur 2 : oublier response.ok
// ❌ fetch ne lance pas d'erreur sur 404
async function Page() {
const res = await fetch('https://api.example.com/data')
const data = await res.json()
return <div>{data.title}</div>
}
// ✅ Vérifier le statut
async function Page() {
const res = await fetch('https://api.example.com/data')
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`)
}
const data = await res.json()
return <div>{data.title}</div>
}
Erreur 3 : appeler un Route Handler depuis un Server Component
// ❌ Contournement inutile
async function Page() {
const res = await fetch('/api/posts')
const posts = await res.json()
return <PostList posts={posts} />
}
// ✅ Requête directe
async function Page() {
const posts = await db.post.findMany()
return <PostList posts={posts} />
}
Cas pratique : page de blog
Article détail avec contenu, auteur et recommandations.
Structure
app/
posts/
[slug]/
page.tsx
loading.tsx
error.tsx
Code
// app/posts/[slug]/page.tsx
import { db } from '@/lib/prisma'
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
export default async function PostPage({
params,
}: {
params: { slug: string }
}) {
const [post, author] = await Promise.all([
db.post.findUnique({
where: { slug: params.slug },
}),
db.user.findFirst(),
])
if (!post) {
notFound()
}
return (
<article>
<h1>{post.title}</h1>
<AuthorCard author={author} />
<div>{post.content}</div>
<Suspense fallback={<div>Loading recommendations...</div>}>
<RecommendedPosts currentPostId={post.id} />
</Suspense>
</article>
)
}
function AuthorCard({ author }) {
return (
<div>
<img src={author.avatar} alt={author.name} />
<span>{author.name}</span>
</div>
)
}
async function RecommendedPosts({ currentPostId }: { currentPostId: string }) {
const recommended = await db.post.findMany({
where: {
id: { not: currentPostId },
published: true,
},
take: 3,
})
return (
<div>
<h3>You might also like</h3>
{recommended.map((post) => (
<a key={post.id} href={`/posts/${post.slug}`}>
{post.title}
</a>
))}
</div>
)
}
export const revalidate = 3600
export async function generateStaticParams() {
const posts = await db.post.findMany({
select: { slug: true },
})
return posts.map((post) => ({
slug: post.slug,
}))
}
// app/posts/[slug]/loading.tsx
export default function Loading() {
return (
<div>
<div className="skeleton h-12 w-3/4" />
<div className="skeleton h-4 w-1/4 mt-4" />
<div className="skeleton h-64 mt-8" />
</div>
)
}
// app/posts/[slug]/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error
reset: () => void
}) {
return (
<div>
<h2>Failed to load post</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>Try again</button>
</div>
)
}
Choix expliqués
- Base directe ? → Server Component, pas besoin de route API
- Parallèle article + auteur ? → Pas de dépendance, plus rapide
- Suspense pour recommandations ? → Secondaire, ne bloque pas le contenu principal
- revalidate: 3600 ? → Contenu stable, 1 h de cache suffit
Conclusion
L’essentiel :
- Privilégier la base directe dans un Server Component, sauf Client Component ou API tierce.
- async/await simple —
Promise.allen parallèle, Suspense pour le chargement. - Next.js 15 sans cache par défaut — choisir
force-cache,no-storeourevalidate. - Gérer les erreurs —
response.ok,error.tsx,redirecthors try/catch.
La récupération de données en Server Components est bien plus simple qu’en client : pas de loading, pas de conditions de course, pas d’annulation. Si vous hésitez sur App Router, ce seul point vaut le coup.
Sur votre prochain projet, lancez-vous — et revenez ici si besoin.
Flux complet de récupération de données dans les Server Components Next.js
De fetch vs requête BDD à la syntaxe async, stratégies de cache et gestion d'erreurs
⏱️ Estimated time: 1 hr
- 1
Step 1: Choisir fetch vs requête BDD
Requête directe (recommandé) :
• Plus rapide : moins d'appels API, latence plus basse
• Plus sûr : les clés BDD ne sont pas exposées au client
• Usage : base accessible côté serveur
Exemple :
```tsx
import { db } from '@/lib/db'
export default async function Page() {
const users = await db.user.findMany()
return <div>{users.map(u => <div key={u.id}>{u.name}</div>)}</div>
}
```
Utiliser fetch :
• APIs tierces
• Cas cross-origin
• Attention : Next.js 15 ne met pas en cache par défaut
Exemple :
```tsx
export default async function Page() {
const res = await fetch('https://api.example.com/data', {
cache: 'force-cache'
})
const data = await res.json()
return <div>{data}</div>
}
```
Conseil : privilégier la base directe ; fetch seulement pour APIs tierces - 2
Step 2: Syntaxe async/await des composants
Marquer async :
```tsx
export default async function Page() {
const data = await fetchData()
return <div>{data}</div>
}
```
Parallèle (Promise.all) :
```tsx
export default async function Page() {
const [users, posts] = await Promise.all([
fetchUsers(),
fetchPosts()
])
return <div>...</div>
}
```
Suspense pour le chargement :
```tsx
import { Suspense } from 'react'
export default function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<UserList />
</Suspense>
)
}
async function UserList() {
const users = await fetchUsers()
return <div>{users.map(...)}</div>
}
```
Points clés :
• Server Components peuvent être async
• Promise.all pour le parallèle
• Suspense pour l'expérience de chargement - 3
Step 3: Configurer la stratégie de cache
Next.js 15 : pas de cache par défaut, configuration explicite :
Sans cache (temps réel) :
```tsx
fetch(url, { cache: 'no-store' })
```
Cache permanent (statique) :
```tsx
fetch(url, { cache: 'force-cache' })
```
Mise à jour planifiée (ISR) :
```tsx
fetch(url, { next: { revalidate: 3600 } })
```
Conseils :
• Temps réel → cache: 'no-store'
• Statique → cache: 'force-cache'
• Mises à jour fréquentes sans temps réel → revalidate
Next.js 15 : explicite > implicite — réfléchir à ce qui doit être mis en cache. - 4
Step 4: Gestion d'erreurs
Vérifier response.ok :
```tsx
const res = await fetch(url)
if (!res.ok) {
throw new Error('Failed to fetch')
}
const data = await res.json()
```
Filet error.tsx :
```tsx
// app/page/error.tsx
'use client'
export default function Error({ error, reset }) {
return (
<div>
<h2>Erreur : {error.message}</h2>
<button onClick={reset}>Réessayer</button>
</div>
)
}
```
Attention redirect :
```tsx
// ❌ redirect dans try/catch
try {
if (!user) redirect('/login')
} catch (e) {
// redirect lance une erreur capturée ici
}
// ✅ redirect hors try/catch
if (!user) redirect('/login')
try {
// autre logique
} catch (e) {
// gestion d'erreurs
}
```
Points clés :
• Vérifier response.ok
• error.tsx en filet
• redirect hors try/catch
FAQ
Pourquoi les Server Components peuvent await directement ?
Ils peuvent :
• Se connecter directement à la base (Prisma, Drizzle, SQL natif)
• Lire le système de fichiers (fichiers markdown, etc.)
• Appeler des services internes (sans cross-origin)
• Accéder aux variables d'environnement et secrets (non exposés au client)
Code sûr :
```tsx
import { db } from '@/lib/db'
export default async function Page() {
const users = await db.user.findMany()
return <div>{users.map(...)}</div>
}
```
Avantages :
• Pas de useEffect, useState
• Pas de conditions de course
• Pas de gestion de loading
• Récupération bien plus simple qu'en client
Quand utiliser fetch, quand interroger la base directement ?
• Plus rapide : serveur → BDD <10 ms vs client → serveur 100 ms+
• Plus sûr : clés non exposées
• Usage : base accessible côté serveur
fetch :
• APIs tierces
• Cross-origin
• Architecture API déjà en place
Conseil :
• Privilégier la base directe
• fetch seulement pour APIs tierces
• Éviter fetch('/api/...') depuis un Server Component
Comparaison :
```tsx
// ❌ Anti-pattern
const res = await fetch('/api/users')
const users = await res.json()
// ✅ Correct
const users = await db.user.findMany()
```
Comment écrire un composant async ?
```tsx
export default async function Page() {
const data = await fetchData()
return <div>{data}</div>
}
```
Parallèle :
```tsx
export default async function Page() {
const [users, posts] = await Promise.all([
fetchUsers(),
fetchPosts()
])
return <div>...</div>
}
```
Suspense :
```tsx
import { Suspense } from 'react'
export default function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<UserList />
</Suspense>
)
}
async function UserList() {
const users = await fetchUsers()
return <div>{users.map(...)}</div>
}
```
Points clés : async direct, Promise.all, Suspense
Comment configurer le cache en Next.js 15 ?
Temps réel :
```tsx
fetch(url, { cache: 'no-store' })
```
Statique :
```tsx
fetch(url, { cache: 'force-cache' })
```
ISR :
```tsx
fetch(url, { next: { revalidate: 3600 } })
```
Conseils :
• Temps réel → no-store
• Statique → force-cache
• Fréquent sans temps réel → revalidate
Changement breaking à la migration 14 → 15.
Comment gérer les erreurs dans les Server Components ?
```tsx
const res = await fetch(url)
if (!res.ok) {
throw new Error('Failed to fetch')
}
const data = await res.json()
```
error.tsx :
```tsx
'use client'
export default function Error({ error, reset }) {
return (
<div>
<h2>Erreur : {error.message}</h2>
<button onClick={reset}>Réessayer</button>
</div>
)
}
```
redirect :
```tsx
// ❌ dans try/catch
try {
if (!user) redirect('/login')
} catch (e) {}
// ✅ hors try/catch
if (!user) redirect('/login')
```
Points clés : response.ok, error.tsx, redirect hors try/catch
11 min de lecture · Publié le: 19 déc. 2025 · Mis à jour le: 27 juil. 2026
Guide complet Next.js
Si vous arrivez depuis la recherche, le plus rapide est de passer à l’article précédent ou suivant de cette série.
Précédent
Pièges courants du Next.js App Router et solutions : 8 retours d'expérience pour éviter les faux pas
De la récupération de données à la gestion d'erreurs : 14 pièges fréquents du Next.js App Router et leurs solutions. Server Components, Client Components, cache, migration — retours terrain pour éviter 80 % des erreurs courantes.
Partie 7 sur 51
Suivant
Next.js SSR vs SSG vs ISR : guide pour choisir sa stratégie de rendu
Vous hésitez entre SSR, SSG et ISR dans Next.js ? Comparaison par scénarios, arbre de décision et solutions aux problèmes courants (ISR qui ne s'applique pas, premier affichage lent).
Partie 9 sur 51



Commentaires
Connectez-vous avec GitHub pour laisser un commentaire