DéveloppementRead Article

Les 7 pièges de performance qui plombent ton app Next.js (et comment les éviter)

Landry Bella's image

Les 7 pièges de performance qui plombent ton app Next.js (et comment les éviter)

Introduction

Tu as build une app Next.js magnifique. Le design est clean, les fonctionnalités sont au top, tout fonctionne nickel en local.

Puis tu déploies en production... et c'est le drame.

Pages qui chargent en 5 secondes. Score Lighthouse dans le rouge. Utilisateurs qui abandonnent avant même de voir ton contenu. Ton client ou ton manager qui te demande "mais pourquoi c'est si lent ?"

Le truc ? Next.js est ultra-performant out of the box... mais franchement, il ne peut pas corriger une mauvaise implémentation tout seul.

Dans cet article, je décortique les 7 pièges de performance que je vois systématiquement dans les apps Next.js, et surtout : comment les corriger avec des exemples de code concrets.

Prêt à transformer ton app lente en fusée ? Allons-y.

Piège #1 : Le bundle JavaScript obèse (et invisible)

Le problème

Voilà LE tueur silencieux numéro 1. Ton app pèse 2 MB de JavaScript parce que tu as importé Lodash en entier, chargé toute la librairie d'icônes, et inclus des dépendances que tu n'utilises même pas.

Une app avec un bundle lourd provoque des temps de chargement lents, des sessions ratées et un taux de rebond qui explose. Chaque kilobyte supplémentaire ralentit le parsing JavaScript et retarde le Time to Interactive. Et si en plus tes utilisateurs ont une connexion moyenne, là c'est vraiment compliqué.

La solution : Analyse et nettoyage

Étape 1 : Visualise ton bundle

bash
# Installe le bundle analyzer
npm install --save-dev @next/bundle-analyzer

# Configure next.config.js
javascript
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
})

/** @type {import('next').NextConfig} */
const nextConfig = {
  // Ta config existante
}

module.exports = withBundleAnalyzer(nextConfig)
bash
# Lance l'analyse
ANALYZE=true npm run build

Étape 2 : Corrige les imports

javascript
// ❌ MAUVAIS : Import toute la lib (500kb)
import _ from 'lodash'
import { Camera, User, Settings } from 'lucide-react'

// ✅ BON : Import uniquement ce dont tu as besoin
import debounce from 'lodash/debounce'
import { Camera } from 'lucide-react/dist/esm/icons/camera'

Étape 3 : Active l'optimisation automatique

javascript
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    // Next.js optimise automatiquement ces packages
    optimizePackageImports: [
      'lucide-react',
      '@mui/icons-material',
      'recharts',
      'date-fns'
    ],
  },
}

module.exports = nextConfig

Impact réel : Réduction de 30-60% du bundle size en une heure de travail. Tu vois le gain là ?

Piège #2 : Client Components partout (alors que tu n'en as pas besoin)

Le problème

Écoute, depuis l'App Router, tout est Server Component par défaut. Mais dès que tu ajoutes 'use client' en haut d'un fichier... BOOM : tout ce qu'il contient est envoyé au client, même si 80% du code pourrait tourner sur le serveur.

javascript
// ❌ MAUVAIS : Tout ce fichier devient client-side
'use client'

import { parse } from 'marked' // Librairie lourde pour Markdown
import { PrismLight } from 'react-syntax-highlighter' // Encore plus lourd

export default function BlogPost({ content }) {
  // Parse du markdown côté client = bundle lourd
  const html = parse(content)
  
  return <div dangerouslySetInnerHTML={{ __html: html }} />
}

La solution : Séparer logique serveur et client

javascript
// ✅ BON : Server Component qui parse côté serveur
// app/blog/[slug]/page.tsx
import { parse } from 'marked'
import { BlogContent } from './BlogContent'

export default async function BlogPost({ params }) {
  // Parse sur le serveur - ZERO impact sur le bundle client
  const post = await getPost(params.slug)
  const html = parse(post.content)
  
  return <BlogContent html={html} />
}
javascript
// ✅ BON : Client Component minimal pour l'interactivité
// app/blog/[slug]/BlogContent.tsx
'use client'

import { useState } from 'react'

export function BlogContent({ html }) {
  const [likes, setLikes] = useState(0)
  
  return (
    <div>
      <article dangerouslySetInnerHTML={{ __html: html }} />
      <button onClick={() => setLikes(likes + 1)}>
        ❤️ {likes}
      </button>
    </div>
  )
}

Règle d'or : Les composants lourds qui transforment des données en UI (syntax highlighting, charts, markdown) devraient tourner en Server Components. Garde 'use client' uniquement pour l'interactivité réelle (clicks, inputs, states). C'est aussi simple que ça.

Piège #3 : Dynamic Imports oubliés pour les composants lourds

Le problème

Tu importes un composant lourd (chart, éditeur rich-text, map) de façon normale. Résultat : il est chargé immédiatement, même si l'utilisateur ne le voit jamais ou ne scrolle pas jusque-là.

javascript
// ❌ MAUVAIS : Chart.js chargé immédiatement (50kb)
import { Line } from 'react-chartjs-2'

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Line data={chartData} /> {/* 50kb pour un truc en bas de page */}
    </div>
  )
}

La solution : Dynamic Imports avec chargement différé

javascript
// ✅ BON : Charge uniquement quand nécessaire
import dynamic from 'next/dynamic'

// Composant chargé de façon asynchrone
const Chart = dynamic(() => import('../components/Chart'), {
  loading: () => <div className="animate-pulse bg-gray-200 h-64 rounded" />,
  ssr: false, // Désactive SSR si le composant utilise window/document
})

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      {/* Chart ne charge que quand ce composant s'affiche */}
      <Chart data={chartData} />
    </div>
  )
}

Pour les composants conditionnels :

javascript
// ✅ BON : Charge SEULEMENT si l'utilisateur clique
'use client'

import dynamic from 'next/dynamic'
import { useState } from 'react'

const HeavyModal = dynamic(() => import('./HeavyModal'))

export default function Page() {
  const [showModal, setShowModal] = useState(false)

  return (
    <>
      <button onClick={() => setShowModal(true)}>
        Ouvrir Modal
      </button>
      
      {/* Modal chargé UNIQUEMENT quand showModal = true */}
      {showModal && <HeavyModal onClose={() => setShowModal(false)} />}
    </>
  )
}

Impact : Réduction de 50-70% du JavaScript initial chargé. Franchement, c'est du gain facile.

Piège #4 : Images non optimisées (le crime capital)

Le problème

Tu utilises des balises <img> classiques. Tes images pèsent 2 MB en JPEG. Elles ne sont pas lazy-loadées. Elles provoquent du layout shift. C'est vraiment pas joli.

Et bon, si en plus tes utilisateurs sont sur mobile avec une connexion moyenne, là c'est carrément l'enfer pour eux.

javascript
// ❌ MAUVAIS : Tout ce qu'il ne faut PAS faire
export default function Hero() {
  return (
    <img 
      src="/hero.jpg"  // 2.5 MB, format lourd
      alt="Hero"
      // Pas de lazy loading, pas d'optimisation, pas de responsive
    />
  )
}

La solution : Le composant Image de Next.js

javascript
// ✅ BON : Optimisation automatique
import Image from 'next/image'

export default function Hero() {
  return (
    <Image
      src="/hero.jpg"
      alt="Hero image"
      width={1200}
      height={600}
      priority // Pour les images above-the-fold
      placeholder="blur" // Effet de flou pendant le chargement
      blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..." // Généré auto
      sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
      className="rounded-lg"
    />
  )
}

Pour les images externes :

javascript
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'images.unsplash.com',
      },
      {
        protocol: 'https',
        hostname: 'cdn.example.com',
      },
    ],
    formats: ['image/webp', 'image/avif'], // Formats modernes
    deviceSizes: [640, 750, 828, 1080, 1200, 1920],
  },
}

module.exports = nextConfig

Ce que Next.js fait automatiquement :

- Conversion en WebP/AVIF (réduction de 50-80% du poids)

- Lazy loading natif

- Responsive images avec srcset

- Prévention du layout shift

- Cache optimisé

Voilà comment faire. C'est simple et l'impact est énorme.

Piège #5 : Fetching de données qui bloque le rendu

Le problème

Tu fetch toutes les données en série, une après l'autre. Le serveur attend 3 secondes avant d'envoyer quoi que ce soit au client. L'utilisateur regarde un écran blanc pendant tout ce temps.

javascript
// ❌ MAUVAIS : Fetch en série (3 secondes cumulées)
export default async function Dashboard() {
  const user = await fetchUser()      // 1 seconde
  const posts = await fetchPosts()    // 1 seconde
  const comments = await fetchComments() // 1 seconde
  
  return <div>...</div> // Rien affiché pendant 3 secondes
}

La solution : Parallélisation et Streaming

Étape 1 : Fetch en parallèle

javascript
// ✅ BON : Fetch en parallèle (1 seconde max)
export default async function Dashboard() {
  // Toutes les requêtes démarrent en même temps
  const [user, posts, comments] = await Promise.all([
    fetchUser(),
    fetchPosts(),
    fetchComments(),
  ])
  
  return <div>...</div> // Tout arrive en 1 seconde
}

Étape 2 : Streaming avec Suspense

javascript
// ✅ EXCELLENT : Affichage progressif avec Suspense
import { Suspense } from 'react'

export default function Dashboard() {
  return (
    <div>
      {/* Header affiché immédiatement */}
      <header>Dashboard</header>
      
      {/* Contenu streamé progressivement */}
      <Suspense fallback={<UserSkeleton />}>
        <UserProfile />
      </Suspense>
      
      <Suspense fallback={<PostsSkeleton />}>
        <PostsList />
      </Suspense>
      
      <Suspense fallback={<CommentsSkeleton />}>
        <CommentsList />
      </Suspense>
    </div>
  )
}

// Chaque composant fetch ses propres données
async function UserProfile() {
  const user = await fetchUser() // 1s
  return <div>{user.name}</div>
}

async function PostsList() {
  const posts = await fetchPosts() // 1s
  return <ul>{posts.map(...)}</ul>
}

Résultat : L'utilisateur voit du contenu immédiatement au lieu de fixer un écran blanc. La perception de vitesse est complètement transformée. Tu comprends le truc là ?

Piège #6 : Pas de Cache (ou cache mal configuré)

Le problème

Tes données changent rarement, mais tu les fetch à chaque requête. Ton serveur croule, tes utilisateurs attendent, et ton hébergeur te facture cher à la fin du mois. Si en plus tu es sur un plan avec des limites de bande passante, là c'est vraiment problématique.

La solution : Cache intelligent

Pour les données statiques :

javascript
// ✅ BON : Static Generation avec revalidation
export default async function BlogPost({ params }) {
  const post = await fetchPost(params.slug)
  
  return <article>{post.content}</article>
}

// Génère statiquement + revalide toutes les heures
export const revalidate = 3600 // 1 heure en secondes

Pour les données dynamiques mais cachées :

javascript
// ✅ BON : Cache avec Next.js 15+
import { unstable_cache } from 'next/cache'

const getCachedPosts = unstable_cache(
  async () => {
    return await fetchPosts()
  },
  ['posts'], // Cache key
  { 
    revalidate: 60, // Revalide après 1 minute
    tags: ['posts'] // Pour invalidation manuelle
  }
)

export default async function PostsList() {
  const posts = await getCachedPosts()
  return <div>...</div>
}

Cache API avec headers :

javascript
// app/api/data/route.ts
export async function GET() {
  const data = await fetchData()
  
  return Response.json(data, {
    headers: {
      'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=30',
    },
  })
}

Bon, franchement, le cache c'est ton meilleur ami pour la performance ET pour réduire tes coûts. Ne l'oublie pas.

Piège #7 : Fonts qui provoquent du FOUT/FOIT

Le problème

Tes polices custom chargent depuis Google Fonts. Pendant ce temps, le texte est invisible (FOIT) ou s'affiche avec une police système puis change brutalement (FOUT). Layout shift garanti. L'utilisateur voit le contenu sauter dans tous les sens.

La solution : next/font

javascript
// ✅ BON : Fonts optimisées automatiquement
import { Inter, Roboto_Mono } from 'next/font/google'

// Configuration des fonts
const inter = Inter({
  subsets: ['latin'],
  display: 'swap', // Affiche immédiatement avec fallback
  variable: '--font-inter',
})

const robotoMono = Roboto_Mono({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-roboto-mono',
})

// Layout racine
export default function RootLayout({ children }) {
  return (
    <html lang="fr" className={`${inter.variable} ${robotoMono.variable}`}>
      <body className="font-sans">{children}</body>
    </html>
  )
}
css
/* globals.css - Utilise les variables CSS */
body {
  font-family: var(--font-inter), system-ui, sans-serif;
}

code {
  font-family: var(--font-roboto-mono), monospace;
}

Ce que `next/font` fait automatiquement :

- Self-hosting des fonts (zéro requête externe)

- Preload automatique

- Font subsetting (uniquement les caractères utilisés)

- Zero layout shift garanti

- Fallback instant avec font-display: swap

Pour les fonts custom locales :

javascript
import localFont from 'next/font/local'

const myFont = localFont({
  src: './fonts/MyCustomFont.woff2',
  display: 'swap',
  variable: '--font-custom',
})

Comment mesurer tes progrès : Les Core Web Vitals

Maintenant que tu corriges ces pièges, comment mesurer l'impact réel ?

Les 3 métriques critiques

Concentre-toi sur les Core Web Vitals : LCP (vitesse de chargement), INP (réactivité) et CLS (stabilité visuelle).

LCP (Largest Contentful Paint) - Cible : < 2.5s

- Mesure le temps avant affichage du plus gros élément

- Corrigé par : images optimisées, cache, Server Components

INP (Interaction to Next Paint) - Cible : < 200ms

- Mesure la réactivité aux interactions

- Corrigé par : bundle size réduit, moins de JavaScript client

CLS (Cumulative Layout Shift) - Cible : < 0.1

- Mesure la stabilité visuelle pendant le chargement

- Corrigé par : Image avec width/height, fonts optimisées

Outils de mesure

bash
# Lighthouse dans Chrome DevTools (Cmd+Shift+I)
# Performance tab → Run audit

# Ou en ligne de commande
npm install -g lighthouse
lighthouse https://ton-site.com --view

En production réelle :

javascript
// app/layout.tsx - Web Vitals tracking
'use client'

import { useReportWebVitals } from 'next/web-vitals'

export function WebVitals() {
  useReportWebVitals((metric) => {
    // Envoie vers ton analytics
    console.log(metric)
    
    // Exemple avec Google Analytics
    window.gtag('event', metric.name, {
      value: Math.round(metric.value),
      event_label: metric.id,
    })
  })
}

Checklist : Audit rapide de ton app Next.js

Avant de déployer, vérifie ces points :

Bundle Size

- [ ] Analyse avec @next/bundle-analyzer

- [ ] Aucune lib >100kb sans raison valable

- [ ] Imports optimisés (pas de full lib imports)

- [ ] Dynamic imports pour composants lourds

Images

- [ ] Toutes les images utilisent next/image

- [ ] Attribut priority sur images above-the-fold

- [ ] Formats modernes (WebP/AVIF) configurés

- [ ] Domaines externes autorisés dans next.config.js

Composants

- [ ] Server Components par défaut

- [ ] 'use client' uniquement pour interactivité

- [ ] Suspense pour le streaming de contenu

Data Fetching

- [ ] Requêtes parallèles avec Promise.all()

- [ ] Cache configuré (revalidate, tags)

- [ ] Pas de fetch bloquant en série

Fonts

- [ ] next/font pour toutes les polices

- [ ] display: swap configuré

- [ ] Variables CSS utilisées

Métriques

- [ ] LCP < 2.5s

- [ ] INP < 200ms

- [ ] CLS < 0.1

- [ ] Lighthouse score > 90

Conclusion : La performance, c'est du craft

Regarde, Next.js te donne tous les outils pour build des apps ultra-rapides. Mais comme pour le vibe coding, les outils ne remplacent pas la compréhension.

Les 7 pièges que je t'ai montrés sont responsables de 80% des problèmes de performance que je vois dans les apps Next.js en production. La bonne nouvelle ? Ils se corrigent tous en quelques heures de travail.

La performance n'est pas un luxe. C'est :

- Une meilleure expérience utilisateur (surtout sur mobile/réseau moyen)

- Un meilleur SEO (Google pénalise les sites lents)

- Des coûts d'infrastructure réduits (ton portefeuille te remerciera)

- Une crédibilité professionnelle renforcée

Ton action immédiate :

1. Lance ANALYZE=true npm run build

2. Identifie ton plus gros problème

3. Applique la solution de cet article

4. Mesure l'impact avec Lighthouse

Une app performante n'est pas construite par magie. Elle est construite avec intention, mesure et optimisation continue.

Et toi : quel est le score Lighthouse de ton app en ce moment ?

Partage-le en commentaire, et dis-moi quel piège tu vas corriger en premier ! 👇

Si l'article t'a aidé, n'hésite pas à le partager avec tes collègues devs. On se retrouve sur GitHub pour d'autres ressources et discussions tech.

Ressources pour aller plus loin

- Next.js Bundle Analyzer - Documentation officielle

- Web.dev Core Web Vitals - Guide complet de Google

- Bundlephobia - Vérifier le poids des packages

- Next.js Image Optimization - Guide officiel

Mots-clés : optimisation Next.js, Next.js performance, bundle size Next.js, Next.js lent, Core Web Vitals, React Server Components, dynamic import Next.js, next/image, Next.js 15, App Router performance, Web Vitals, Lighthouse score

Partager cet article

Les 7 pièges de performance qui plombent ton app Next.js (et comment les éviter) | Blog | Landry Bella - Insights & Writings