Firecrawl and Supabase use their Next.js version, which is a Pro feature (free accounts can use it during their free week).
Your keys
0 of 4 ready
Create each key on its own page, paste it below, then copy the finished lines into your project's .env file. What you paste stays in this tab. It is never sent to Wirestack or saved.
A Next.js App Router app that answers questions about your own website. A one-time ingest route crawls the site with Firecrawl, splits each page into chunks, embeds them with the open gte-small model and stores them in Supabase pgvector. The chat route then looks up the closest chunks for each question and gives them to the model before it answers. The chat page is the AI SDK useChat page, unchanged.
Before you start
Set up each piece first, in this order. Each one works on its own before you connect them.
Firecrawl (reads your website). Install firecrawl and get an API key from https://www.firecrawl.dev. Env var: FIRECRAWL_API_KEY.
Supabase pgvector search (finds by meaning). Run its three SQL snippets in the Supabase SQL Editor (enable vector, create the documents table with vector(384), create match_documents). Install @supabase/supabase-js and @huggingface/transformers. Env vars: SUPABASE_URL, SUPABASE_SECRET_KEY.
AI SDK chat box. Create the app with create-next-app (App Router), install ai, @ai-sdk/react and zod, and add app/api/chat/route.ts and app/page.tsx from that piece. Env var: AI_GATEWAY_API_KEY.
One install line for all three:
Shell
npm install firecrawl @supabase/supabase-js @huggingface/transformers ai @ai-sdk/react zod
Your .env.local holds all four keys. Never prefix them with NEXT_PUBLIC_.
You add two files and change one. app/page.tsx stays exactly as the AI SDK piece wrote it.
lib/docs.ts (new). The Supabase client and the embedding model from the Supabase piece, shared by both routes so the model loads once.
TypeScript
import { createClient } from '@supabase/supabase-js'import { pipeline } from '@huggingface/transformers'export const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SECRET_KEY!)// Load the model once and reuse it across requests.const embeddingPipeline = pipeline('feature-extraction', 'Supabase/gte-small')export async function embed(text: string) { const generateEmbedding = await embeddingPipeline const output = await generateEmbedding(text, { pooling: 'mean', normalize: true, }) return Array.from(output.data)}// Split markdown on blank lines, then pack paragraphs into chunks of about maxChars.export function chunkMarkdown(markdown: string, maxChars = 1000) { const chunks: string[] = [] let current = '' for (const para of markdown.split(/\n\s*\n/)) { if (current && current.length + para.length > maxChars) { chunks.push(current.trim()) current = '' } current += para + '\n\n' } if (current.trim()) chunks.push(current.trim()) return chunks}
app/api/ingest/route.ts (new). The Firecrawl crawl from the Firecrawl piece, but instead of writing files it chunks each page and inserts the chunks the way the Supabase piece's embed route does. Change https://example.com to your site.
TypeScript
import { Firecrawl } from 'firecrawl'import { supabase, embed, chunkMarkdown } from '@/lib/docs'const firecrawl = new Firecrawl({ apiKey: process.env.FIRECRAWL_API_KEY })export async function POST() { const response = await firecrawl.crawl('https://example.com', { limit: 100, scrapeOptions: { formats: ['markdown'], }, }) let stored = 0 for (const [i, page] of response.data.entries()) { const title = page.metadata?.title ?? `page-${i}` for (const body of chunkMarkdown(page.markdown ?? '')) { const embedding = await embed(body) const { error } = await supabase.from('documents').insert({ title, body, embedding }) if (error) { return Response.json({ error: error.message }, { status: 500 }) } stored++ } } return Response.json({ pages: response.data.length, chunks: stored })}
app/api/chat/route.ts (changed). The AI SDK piece's route, with a lookup added before streamText.
TypeScript
import { streamText, UIMessage, convertToModelMessages, createUIMessageStreamResponse, toUIMessageStream,} from 'ai'import { supabase, embed } from '@/lib/docs'export async function POST(req: Request) { const { messages }: { messages: UIMessage[] } = await req.json() // 1. Take the text of the latest user message. const lastUser = [...messages].reverse().find(m => m.role === 'user') const question = (lastUser?.parts ?? []) .map(part => (part.type === 'text' ? part.text : '')) .join(' ') // 2. Embed it and find the closest chunks. const { data: documents } = await supabase.rpc('match_documents', { query_embedding: await embed(question), match_threshold: 0.78, match_count: 10, }) const context = (documents ?? []) .map((d: { title: string; body: string }) => `## ${d.title}\n${d.body}`) .join('\n\n') // 3. Give the chunks to the model as instructions. const result = streamText({ model: 'anthropic/claude-sonnet-5.5', instructions: 'Answer using only the context below from our website. ' + "If the context does not cover the question, say you don't know.\n\n" + context, messages: await convertToModelMessages(messages), }) return createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }), })}
app/page.tsx: no change. Use the AI SDK piece's file as is.
What each part does:
lib/docs.ts holds the Supabase client, the gte-small pipeline and the pooling: 'mean', normalize: true options exactly as the Supabase piece uses them. Ingest and search must use the same model, or the vectors will not match.
The ingest route runs firecrawl.crawl with the Firecrawl piece's options. Each page's markdown is split into chunks of about 1,000 characters, and each chunk becomes one row in documents (title, body, embedding).
The chat route reads the text parts of the latest user message, embeds them, and calls match_documents with the Supabase piece's threshold and count.
The matched body values go into instructions, the streamText setting the AI SDK reference lists for the model's behavior. The rest of the route is unchanged, so useChat on the page still gets the same stream.
If you already ran the Supabase piece's sample embed route, delete those three sample rows in the Supabase table editor so they do not show up as context.
Test it
Start the app with npm run dev.
Run the ingest once. The request waits until the crawl and all inserts are done:
Shell
curl -X POST http://localhost:3000/api/ingest
You should see {"pages":N,"chunks":M} and new rows in the documents table. Each crawled page costs 1 Firecrawl credit.
Open http://localhost:3000 and ask something your site answers, worded differently from the page. The reply should use your site's content.
Ask something your site does not cover. The model should say it does not know.
If answers ignore your content, lower match_threshold (for example to 0.5), as the Supabase piece suggests.
Not confirmed
This combined code has not been run. Each piece was checked on its own official pages; the wiring between them was not.
The chunk size of about 1,000 characters and the split on blank lines are our own choice, not from any official page. Long single paragraphs stay as one chunk.
page.metadata?.title is shown in Firecrawl's Python and raw API examples; the JS example hides the metadata keys, so the TypeScript field name was not confirmed. The page-${i} fallback covers a missing title.
Reading the latest message by filtering parts for type === 'text' follows the UIMessage type on the AI SDK reference, but no official page shows this exact code.
The wording of the instructions prompt is ours.
The TypeScript types of pipeline(...) and output.data were not confirmed (see the Supabase piece). If the compiler complains, loosen the types in lib/docs.ts.
The ingest route crawls and embeds in one request. On a host with a short request time limit it may time out; it is meant to run on your own machine. Running the embedding model on a serverless host was not confirmed.
Running ingest twice inserts duplicate rows. Clearing the table first is up to you.