This is the build companion to The Web Scraper Didn't Die — It Got a Protocol: the actual route handler that turns a URL into a typed database row, on the stack that's genuinely stable in mid-2026, not the one an LLM's training data remembers.
The stack, pinned to what's actually stable
Four packages, four confirmed-stable baselines: the Vercel AI SDK reached its version 5 general availability on July 31, 2025; Zod 4 went stable on May 19, 2025, shipped deliberately under the zod@3.25 subpath so the AI SDK ecosystem wouldn't break on the upgrade; Prisma 7.0.0 shipped November 19, 2025, making the Rust-free TypeScript client the default; Next.js 16 is current, with Turbopack on by default and middleware.ts renamed to proxy.ts. Write these as "5+", "4+", "7+", and "16+" in your own notes — newer minors and majors will exist by the time you read this, and the right move is checking the docs, not trusting a remembered number.
The pipeline itself is four steps: Firecrawl scrapes a URL to markdown, generateObject from the AI SDK converts a Zod schema to JSON Schema and calls the model's structured-output mode against that markdown, the SDK validates and auto-retries on malformed output, and Prisma writes the validated object to Postgres.
Wiring Firecrawl to a Zod schema with generateObject
Start with the schema, not the scraper — the shape you want out determines everything upstream. .describe() on each field is not decorative; it's the single highest-leverage lever for extraction accuracy, confirmed directly in the AI SDK's own GitHub discussion on this pattern (#1947) and in Vercel's own guidance. Mark genuinely optional fields .nullable() rather than letting the model guess:
// lib/firecrawl.ts
import Firecrawl from '@mendable/firecrawl-js'
export const firecrawl = new Firecrawl({ apiKey: process.env.FIRECRAWL_API_KEY! })
// app/api/extract/route.ts
import { NextResponse } from 'next/server'
import { generateObject } from 'ai'
import { openai } from '@ai-sdk/openai'
import { z } from 'zod'
import { firecrawl } from '@/lib/firecrawl'
import { prisma } from '@/lib/prisma'
export const maxDuration = 60
const ExtractionSchema = z.object({
title: z.string().describe('The main title of the page'),
summary: z.string().describe('A 2-3 sentence summary'),
topics: z.array(z.string()).describe('Key topics covered'),
})
export async function POST(request: Request) {
const { url } = await request.json()
try {
const scraped = await firecrawl.scrape(url, { formats: ['markdown'] })
const { object } = await generateObject({
model: openai('gpt-4o'),
schema: ExtractionSchema,
prompt: `Extract structured data from this page:\n\n${scraped.markdown}`,
})
const saved = await prisma.extraction.create({
data: { url, title: object.title, summary: object.summary, data: object },
})
return NextResponse.json(saved)
} catch (err) {
return NextResponse.json({ error: (err as Error).message }, { status: 500 })
}
}
Two details worth calling out because they're easy to get subtly wrong. First, Firecrawl's own docs show both import Firecrawl from '@mendable/firecrawl-js' and import { Firecrawl } from 'firecrawl' as valid — pick one and be consistent across a codebase rather than mixing them. Second, the AI SDK supports both a provider-specific call like openai('gpt-4o') and a unified model-string form like 'openai/gpt-5.2' depending on how you've configured providers — again, pick one convention and hold it. generateObject handles the retry logic on malformed structured output itself, per the AI SDK docs, so you don't need to hand-roll a retry loop around it.
The Route Handler, Visualized
Request → Firecrawl → generateObject → Prisma → Response
Persisting extractions with Prisma 7
Prisma 7 changes two things that matter for this exact pipeline: the generator block now points at prisma-client (not the old prisma-client-js) with an explicit output path, and the recommended pattern pairs it with a driver adapter rather than Prisma's older built-in query engine binary:
// prisma/schema.prisma
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Extraction {
id String @id @default(cuid())
url String
title String
summary String
data Json
createdAt DateTime @default(now())
}
The client itself needs a singleton pattern in any serverless deployment — Next.js on Vercel included — because each hot function invocation that constructs a new PrismaClient without reuse will eventually exhaust your Postgres connection pool under concurrent load, a documented and common failure mode:
// lib/prisma.ts
import { PrismaClient } from '../src/generated/prisma'
import { PrismaPg } from '@prisma/adapter-pg'
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL })
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }
export const prisma =
globalForPrisma.prisma ?? new PrismaClient({ adapter })
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
If you're on Prisma Postgres specifically (Prisma's own serverless database, production-ready since its 6.4.0 release in February 2025), connection pooling is available via a ?pool=true flag or through Prisma Accelerate rather than requiring the adapter pattern above — worth checking which path applies to your database provider before copying this verbatim.
Mapping this to scout, reader, synthesizer
The companion piece to this one describes a three-layer pattern — a scout that discovers URLs, a reader that extracts them, a synthesizer that merges results — implemented in frameworks like CrewAI as three role-based agents. In raw AI SDK code, that same pattern collapses into three chained function calls inside one route handler: Scout is a Firecrawl /map call or a Brave Search API query that returns a list of candidate URLs; Reader is the firecrawl.scrape() call shown above, run once per URL; Synthesizer is a second generateObject call that takes the array of per-page objects and merges them against a rollup schema. Nothing here requires a separate orchestration framework unless you want one — for a handful of pages, three sequential awaits in one handler is the whole system.
Weekly npm Downloads
Snyk / npmtrends / Socket — live figures, re-check before citing
Live figures move weekly — re-check on npmjs.com/npmtrends before citing
Cost and timeout math before you ship
Three numbers decide whether this pipeline is cheap or expensive at scale, and all three are worth checking against their live pricing pages before you commit to a budget, not just trusting the figures below. Firecrawl's JSON extraction mode costs roughly 5 credits per page versus 1 credit for a plain scrape — at Standard-tier pricing (~$83 for 100,000 credits), that's the difference between roughly $0.83 and $4.15 per 1,000 pages before any LLM token cost is added on top. Claude Sonnet 4.5 runs $3 per million input tokens and $15 per million output tokens at platform.claude.com's published pricing; Sonnet 5, which launched June 30, 2026, carries an introductory $2/$10 rate through September 1, 2026, after which it moves to the same $3/$15 tier. And Vercel's function timeout is plan-gated: Hobby defaults to 10 seconds (configurable up to 60 via maxDuration), Pro defaults higher and can be configured up to 300 seconds — which matters directly for this pipeline, since a slow-rendering page plus a structured-output call can genuinely take longer than a Hobby-tier default allows.
None of these figures are exotic to find — they're one visit to firecrawl.dev/pricing, platform.claude.com/pricing, or vercel.com/docs away — but they're exactly the kind of number that goes stale silently in a tutorial and quietly blows a budget six months later. Check them before you deploy, not after the first invoice.
Sources
- Vercel, "AI SDK — Generating Structured Data," ai-sdk.dev/docs
- Vercel, AI SDK 5 general availability notes, July 31, 2025
- Zod, "Introducing Zod 4," zod.dev/v4, stable May 19, 2025
- Prisma, "Prisma 7.0.0" release notes, prisma.io/changelog, November 19, 2025
- Prisma, "Announcing Prisma 6.19.0" (connection pooling), prisma.io/blog
- Next.js, "Route Handlers" and "Upgrading: Version 16," nextjs.org/docs
- Firecrawl, "Node SDK," docs.firecrawl.dev/sdks/node
- Vercel, "Configuring Maximum Duration for Functions," vercel.com/docs
- Anthropic, pricing documentation, platform.claude.com/docs/pricing
Written by Abhishek Kushwaha, founder and writer at Global Tech Search, based in Kathmandu, Nepal.
Claude API Pricing per 1M Tokens
platform.claude.com/pricing
Per platform.claude.com/pricing — Sonnet 5's intro rate steps up to $3/$15 on Sept 1, 2026
Firecrawl Credit Cost by Operation
firecrawl.dev/pricing
firecrawl.dev/pricing — JSON extraction costs 5x a plain scrape before any LLM token cost
Vercel Function Timeout by Plan
vercel.com/docs — configurable via maxDuration
vercel.com/docs — configurable via `export const maxDuration` in the route handler
