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 où 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 :
- Des routes API qui communiquent avec le SDK Firecrawl
- Une extraction structurée validée par Zod pour les données concurrentes
- Des tâches de crawl asynchrones avec polling d'état pour les grands sites
- 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-intelInstallez le SDK JavaScript Firecrawl et Zod :
npm install @mendable/firecrawl-js zodAjoutez 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
- Démarrez le serveur de développement :
npm run dev - Naviguez vers
http://localhost:3000 - Entrez une URL de page de tarifs concurrente
- Cliquez sur Extraire et attendez (généralement 3-8 secondes)
- 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
- Ajoutez
FIRECRAWL_API_KEYdans les variables d'environnement de votre projet Vercel - Augmentez
maxDurationà 60 secondes pour les routes de crawl - Utilisez toujours
asyncCrawlUrl(pascrawlUrl) 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.