écrits/tutorial/2026/05
Tutorial29 mai 2026·28 min

Firecrawl + Next.js : Tutoriel d'extraction de données web par IA

Apprenez à construire des pipelines d'extraction de données web par IA avec Firecrawl et Next.js 15. Ce tutoriel couvre le scraping, l'extraction structurée avec Zod, le crawl de sites entiers et un tableau de bord prêt pour la production.

Le scraping web a fondamentalement changé à l'ère de l'IA. Les scrapers traditionnels se cassent constamment quand les sites mettent à jour leur structure HTML. Firecrawl résout ce problème en combinant le crawl web intelligent avec l'extraction propulsée par des LLM — vous définissez ce que vous voulez, pas le trouver dans le DOM.

Dans ce tutoriel, vous allez construire un tableau de bord de veille concurrentielle avec Firecrawl et Next.js 15 qui :

  • Scrappe n'importe quelle page web et retourne du Markdown propre
  • Extrait des données structurées (noms de produits, tarifs, fonctionnalités) grâce à l'IA et aux schémas Zod
  • Crawle des sites entiers ou des catalogues de produits
  • Affiche les résultats en temps réel dans un tableau de bord professionnel

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20 ou plus installé
  • Des connaissances de base en Next.js App Router et TypeScript
  • Un compte Firecrawl avec une clé API (plan gratuit : 500 crédits/mois)
  • Une familiarité avec Zod pour la validation de schémas

Ce que vous allez construire

Une application Next.js 15 avec :

  1. Des routes API qui communiquent avec le SDK Firecrawl
  2. Une extraction structurée validée par Zod pour les données concurrentes
  3. Des tâches de crawl asynchrones avec polling d'état pour les grands sites
  4. Une interface de tableau de bord affichant des cartes avec tarifs et fonctionnalités

Étape 1 : Configuration du projet

Créez un nouveau projet Next.js 15 avec TypeScript :

npx create-next-app@latest competitor-intel --typescript --tailwind --app
cd competitor-intel

Installez le SDK JavaScript Firecrawl et Zod :

npm install @mendable/firecrawl-js zod

Ajoutez votre clé API dans .env.local :

FIRECRAWL_API_KEY=fc-your-api-key-here

Étape 2 : Configurer le client Firecrawl

Créez un module client réutilisable dans lib/firecrawl.ts :

import FirecrawlApp from '@mendable/firecrawl-js';
 
if (!process.env.FIRECRAWL_API_KEY) {
  throw new Error('FIRECRAWL_API_KEY is not defined');
}
 
export const firecrawl = new FirecrawlApp({
  apiKey: process.env.FIRECRAWL_API_KEY,
});

Ce pattern singleton empêche la création de plusieurs instances pendant le rendu côté serveur.

Étape 3 : Scraper une page unique

Le endpoint de scraping de Firecrawl récupère une URL et retourne des données propres dans plusieurs formats. Créez app/api/scrape/route.ts :

import { NextRequest, NextResponse } from 'next/server';
import { firecrawl } from '@/lib/firecrawl';
 
export async function POST(request: NextRequest) {
  const { url } = await request.json();
 
  if (!url || typeof url !== 'string') {
    return NextResponse.json({ error: 'URL is required' }, { status: 400 });
  }
 
  try {
    const result = await firecrawl.scrapeUrl(url, {
      formats: ['markdown', 'html'],
    });
 
    if (!result.success) {
      return NextResponse.json({ error: 'Scrape failed' }, { status: 500 });
    }
 
    return NextResponse.json({
      markdown: result.markdown,
      title: result.metadata?.title,
      description: result.metadata?.description,
    });
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to scrape URL' },
      { status: 500 }
    );
  }
}

Le tableau formats contrôle ce que Firecrawl retourne. markdown donne un texte propre et lisible, idéal pour les LLM.

Étape 4 : Extraction structurée par LLM avec Zod

La vraie puissance de Firecrawl vient de l'extraction guidée par schéma — vous définissez un schéma Zod, et Firecrawl utilise un LLM pour extraire les champs correspondants de n'importe quelle page.

Définissez votre schéma produit dans lib/schemas.ts :

import { z } from 'zod';
 
export const ProductSchema = z.object({
  name: z.string().describe('Product or service name'),
  tagline: z.string().optional().describe('Main marketing tagline'),
  pricing: z
    .array(
      z.object({
        plan: z.string(),
        price: z.string(),
        features: z.array(z.string()),
      })
    )
    .optional()
    .describe('Pricing tiers with features'),
  mainFeatures: z.array(z.string()).describe('Top 5 key features'),
  targetAudience: z.string().optional().describe('Who the product is for'),
  techStack: z.array(z.string()).optional().describe('Technologies mentioned'),
});
 
export type Product = z.infer<typeof ProductSchema>;

Créez la route API pour l'extraction dans app/api/extract/route.ts :

import { NextRequest, NextResponse } from 'next/server';
import { firecrawl } from '@/lib/firecrawl';
import { ProductSchema } from '@/lib/schemas';
 
export async function POST(request: NextRequest) {
  const { url } = await request.json();
 
  if (!url || typeof url !== 'string') {
    return NextResponse.json({ error: 'URL is required' }, { status: 400 });
  }
 
  try {
    const result = await firecrawl.scrapeUrl(url, {
      formats: ['extract'],
      extract: {
        schema: ProductSchema,
        prompt:
          'Extract product information, pricing tiers, and key features from this page.',
      },
    });
 
    if (!result.success || !result.extract) {
      return NextResponse.json({ error: 'Extraction failed' }, { status: 500 });
    }
 
    return NextResponse.json({ data: result.extract });
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to extract data' },
      { status: 500 }
    );
  }
}

Le format extract envoie le contenu scrapé via un LLM et retourne des données correspondant à votre schéma Zod — même après une refonte complète du site.

Étape 5 : Crawler des sites entiers

Pour crawler plusieurs pages, utilisez asyncCrawlUrl. Créez app/api/crawl/route.ts :

import { NextRequest, NextResponse } from 'next/server';
import { firecrawl } from '@/lib/firecrawl';
 
export async function POST(request: NextRequest) {
  const { url, limit = 10 } = await request.json();
 
  if (!url || typeof url !== 'string') {
    return NextResponse.json({ error: 'URL is required' }, { status: 400 });
  }
 
  try {
    const crawlResponse = await firecrawl.asyncCrawlUrl(url, {
      limit,
      scrapeOptions: {
        formats: ['markdown'],
      },
      excludePaths: ['/blog/*', '/news/*'],
    });
 
    if (!crawlResponse.success) {
      return NextResponse.json(
        { error: 'Crawl failed to start' },
        { status: 500 }
      );
    }
 
    return NextResponse.json({ jobId: crawlResponse.id });
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to start crawl' },
      { status: 500 }
    );
  }
}

asyncCrawlUrl retourne un identifiant de tâche immédiatement. Interrogez le statut :

// app/api/crawl/status/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { firecrawl } from '@/lib/firecrawl';
 
export async function GET(request: NextRequest) {
  const jobId = request.nextUrl.searchParams.get('jobId');
 
  if (!jobId) {
    return NextResponse.json({ error: 'jobId is required' }, { status: 400 });
  }
 
  const status = await firecrawl.checkCrawlStatus(jobId);
 
  return NextResponse.json({
    status: status.status,
    completed: status.completed,
    total: status.total,
    pages: status.status === 'completed' ? status.data : [],
  });
}

Étape 6 : Découverte des URLs avec Map

Avant de consommer des crédits de crawl sur un site entier, utilisez mapUrl pour découvrir les pages disponibles :

const siteMap = await firecrawl.mapUrl('https://competitor.com', {
  search: 'pricing',
  limit: 50,
});
 
console.log(siteMap.links);
// ['https://competitor.com/pricing', 'https://competitor.com/pricing/enterprise', ...]

Idéal pour le crawl ciblé — découvrez les pages pertinentes en premier, puis crawlez uniquement celles-ci.

Étape 7 : Construction de l'interface du tableau de bord

Créez la page principale dans app/page.tsx :

'use client';
 
import { useState } from 'react';
import type { Product } from '@/lib/schemas';
 
export default function IntelligenceDashboard() {
  const [url, setUrl] = useState('');
  const [loading, setLoading] = useState(false);
  const [product, setProduct] = useState<Product | null>(null);
  const [error, setError] = useState<string | null>(null);
 
  async function handleExtract() {
    if (!url) return;
    setLoading(true);
    setError(null);
 
    try {
      const res = await fetch('/api/extract', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ url }),
      });
      const json = await res.json();
 
      if (!res.ok) {
        setError(json.error ?? 'Extraction failed');
        return;
      }
      setProduct(json.data);
    } catch {
      setError("Erreur réseau — veuillez réessayer");
    } finally {
      setLoading(false);
    }
  }
 
  return (
    <div className="max-w-4xl mx-auto p-8">
      <h1 className="text-3xl font-bold mb-2">Veille Concurrentielle</h1>
      <p className="text-gray-600 mb-8">
        Entrez une URL concurrente pour extraire des données produit structurées par IA.
      </p>
 
      <div className="flex gap-2 mb-8">
        <input
          type="url"
          value={url}
          onChange={(e) => setUrl(e.target.value)}
          placeholder="https://concurrent.com/tarifs"
          className="flex-1 border rounded-lg px-4 py-2 text-sm"
        />
        <button
          onClick={handleExtract}
          disabled={loading || !url}
          className="bg-orange-500 text-white px-6 py-2 rounded-lg disabled:opacity-50"
        >
          {loading ? "Extraction..." : "Extraire"}
        </button>
      </div>
 
      {error && (
        <div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6 text-red-700">
          {error}
        </div>
      )}
 
      {product && <ProductCard product={product} />}
    </div>
  );
}
 
function ProductCard({ product }: { product: Product }) {
  return (
    <div className="border rounded-xl p-6 space-y-4">
      <div>
        <h2 className="text-2xl font-bold">{product.name}</h2>
        {product.tagline && (
          <p className="text-gray-600 mt-1">{product.tagline}</p>
        )}
        {product.targetAudience && (
          <p className="text-sm text-orange-600 mt-1">
            Cible : {product.targetAudience}
          </p>
        )}
      </div>
 
      <div>
        <h3 className="font-semibold mb-2">Fonctionnalités clés</h3>
        <ul className="list-disc list-inside space-y-1 text-sm text-gray-700">
          {product.mainFeatures.map((f, i) => (
            <li key={i}>{f}</li>
          ))}
        </ul>
      </div>
 
      {product.pricing && product.pricing.length > 0 && (
        <div>
          <h3 className="font-semibold mb-2">Plans tarifaires</h3>
          <div className="grid grid-cols-1 md:grid-cols-3 gap-3">
            {product.pricing.map((plan, i) => (
              <div key={i} className="border rounded-lg p-3 text-sm">
                <div className="font-medium">{plan.plan}</div>
                <div className="text-orange-600 font-bold">{plan.price}</div>
                <ul className="mt-2 space-y-1 text-gray-600">
                  {plan.features.slice(0, 3).map((f, j) => (
                    <li key={j} className="truncate">
                      • {f}
                    </li>
                  ))}
                </ul>
              </div>
            ))}
          </div>
        </div>
      )}
 
      {product.techStack && product.techStack.length > 0 && (
        <div>
          <h3 className="font-semibold mb-2">Stack technique mentionnée</h3>
          <div className="flex flex-wrap gap-2">
            {product.techStack.map((tech, i) => (
              <span key={i} className="bg-gray-100 px-2 py-1 rounded text-xs">
                {tech}
              </span>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

Étape 8 : Limitation de débit et logique de retry

Firecrawl applique des limites de débit selon votre plan. Ajoutez un backoff exponentiel pour la résilience :

// lib/firecrawl-retry.ts
import { firecrawl } from './firecrawl';
 
export async function scrapeWithRetry(
  url: string,
  options = {},
  maxRetries = 3
) {
  let lastError: Error | null = null;
 
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await firecrawl.scrapeUrl(url, options);
    } catch (error) {
      lastError = error as Error;
 
      if (attempt < maxRetries) {
        // Backoff exponentiel : 1s, 2s, 4s
        await new Promise((resolve) =>
          setTimeout(resolve, Math.pow(2, attempt - 1) * 1000)
        );
      }
    }
  }
 
  throw lastError;
}

Pour traiter plusieurs URLs en batch, ajoutez un délai entre les requêtes :

async function batchScrape(urls: string[]) {
  const results = [];
 
  for (const url of urls) {
    const result = await scrapeWithRetry(url);
    results.push(result);
    // 500ms entre les requêtes pour rester dans les limites de débit
    await new Promise((resolve) => setTimeout(resolve, 500));
  }
 
  return results;
}

Étape 9 : Mise en cache avec Next.js

Les crédits Firecrawl sont limités, donc mettez en cache les résultats pour éviter les appels API redondants :

import { unstable_cache } from 'next/cache';
import { firecrawl } from '@/lib/firecrawl';
import { ProductSchema } from '@/lib/schemas';
 
export const getCachedProductData = unstable_cache(
  async (url: string) => {
    const result = await firecrawl.scrapeUrl(url, {
      formats: ['extract'],
      extract: { schema: ProductSchema },
    });
    return result.extract;
  },
  ['product-data'],
  { revalidate: 3600 } // Cache pendant 1 heure
);

Tester votre implémentation

  1. Démarrez le serveur de développement : npm run dev
  2. Naviguez vers http://localhost:3000
  3. Entrez une URL de page de tarifs concurrente
  4. Cliquez sur Extraire et attendez (généralement 3-8 secondes)
  5. Vérifiez que les données structurées correspondent au contenu de la page

Résolution des problèmes

Erreur "API key not found" : Assurez-vous que FIRECRAWL_API_KEY est dans .env.local et redémarrez le serveur.

"Scrape failed" sur certains sites : Certains sites bloquent agressivement les scrapers. Pour les SPA lourdes en JavaScript, ajoutez l'option waitFor :

const result = await firecrawl.scrapeUrl(url, {
  formats: ['extract'],
  waitFor: 2000,
  extract: { schema: ProductSchema },
});

Résultats d'extraction vides : L'extraction LLM fonctionne mieux avec des pages riches en contenu. Assurez-vous que la page cible contient suffisamment de texte visible.

Timeout de fonction en production : Pour les grands jobs de crawl, augmentez la durée maximale de la fonction :

export const maxDuration = 60;

Déploiement sur Vercel

  1. Ajoutez FIRECRAWL_API_KEY dans les variables d'environnement de votre projet Vercel
  2. Augmentez maxDuration à 60 secondes pour les routes de crawl
  3. Utilisez toujours asyncCrawlUrl (pas crawlUrl) en production pour éviter les timeouts synchrones

Prochaines étapes

  • Ajoutez une base de données (Neon + Drizzle) pour persister les données concurrentes extraites dans le temps
  • Planifiez des rescrapings hebdomadaires avec Trigger.dev pour la surveillance des changements
  • Combinez avec le SDK Vercel AI pour générer automatiquement des rapports d'analyse concurrentielle
  • Explorez l'API Agent de Firecrawl pour la collecte de données entièrement autonome

Conclusion

Firecrawl transforme le scraping web d'un exercice fragile de parsing HTML en un pipeline IA résilient. La combinaison de scrapeUrl pour les pages uniques, asyncCrawlUrl pour les sites entiers, et l'extraction basée sur schéma pour les données structurées couvre la grande majorité des besoins d'extraction de données web dans les applications IA modernes. Avec la validation de schéma Zod et la mise en cache Next.js, vous obtenez un pipeline prêt pour la production qui livre une veille concurrentielle structurée sans maintenir de sélecteurs CSS fragiles.