DeepCitation + Vercel AI SDK
If you’re already using useChat and streamText, adding DeepCitation requires three changes: an upload endpoint, citation-enhanced prompt wrapping in the chat handler, and a post-stream verification call on the client.
This guide assumes you have a working useChat / streamText setup. If you’re starting from scratch, see the Next.js guide for the full setup including routing and component structure.
Prerequisites
npm install deepcitation ai @ai-sdk/openai @ai-sdk/react
CSS setup required. Add @import "deepcitation/tailwind.css" to your globals.css (Tailwind v4), or import "deepcitation/styles.css" in your root layout (non-Tailwind). See Styling.
How DeepCitation Integrates with streamText
streamText streams tokens to the client. Citation verification must happen after streaming ends — the LLM appends its <<<CITATION_DATA>>> block at the very end of the response, so you need the complete output before calling getAllCitationsFromLlmOutput().
The integration model is:
useChat → POST /api/chat → streamText (tokens stream to client)
↓ (streaming ends)
client detects isLoading: true → false
↓
POST /api/verify (complete llmOutput)
↓
verifyAttachment() → citations + proofs
Step 1: Document Upload Endpoint
Create /api/upload/route.ts to handle file uploads. This runs prepareAttachments() and returns both the fileDataPart (for tracking which attachment to verify against) and deepTextPagesByAttachmentId (raw page text keyed by attachmentId).
// app/api/upload/route.ts
import { DeepCitation, validateUploadFile } from "deepcitation";
import { type NextRequest, NextResponse } from "next/server";
const dc = new DeepCitation({ apiKey: process.env.DEEPCITATION_API_KEY! });
export async function POST(req: NextRequest) {
const formData = await req.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Validate before uploading
const validationError = validateUploadFile(file.size, file.type, new Uint8Array(arrayBuffer));
if (validationError) {
return NextResponse.json({ error: validationError }, { status: 400 });
}
const { fileDataParts, deepTextPagesByAttachmentId } = await dc.prepareAttachments([
{ file: buffer, filename: file.name },
]);
return NextResponse.json({
fileDataPart: fileDataParts[0], // Contains attachmentId for verification
deepTextPagesByAttachmentId, // Raw page text for prompt rendering
});
}
Step 2: Enhanced Chat Handler
In your existing streamText handler, intercept the last user message and wrap it with citation instructions when documents are present. The client passes deepTextPagesByAttachmentId in the request body.
// app/api/chat/route.ts
import { openai } from "@ai-sdk/openai";
import { wrapCitationPrompt } from "deepcitation";
import { convertToModelMessages, streamText, type UIMessage } from "ai";
export const maxDuration = 60;
export async function POST(req: Request) {
const {
messages,
deepTextPagesByAttachmentId = {}, // Added: accumulated per-upload raw page map
} = await req.json();
const uiMessages = messages as UIMessage[];
const hasDocuments = Object.keys(deepTextPagesByAttachmentId).length > 0;
// Extract the latest user message text
const lastUserMessage = uiMessages.findLast(m => m.role === "user");
const lastUserContent =
lastUserMessage?.parts
?.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map(p => p.text)
.join("") ?? "";
// Wrap with citation instructions only when documents are uploaded
const { enhancedSystemPrompt, enhancedUserPrompt } = hasDocuments
? wrapCitationPrompt({
systemPrompt: "You are a helpful assistant that answers questions based on provided documents.",
userPrompt: lastUserContent,
deepTextPagesByAttachmentId,
})
: {
enhancedSystemPrompt: "You are a helpful assistant.",
enhancedUserPrompt: lastUserContent,
};
// Replace the last user message with the enhanced version
const enhancedMessages = uiMessages.map((m, i) =>
i === uiMessages.length - 1 && m.role === "user" && hasDocuments
? { ...m, parts: [{ type: "text" as const, text: enhancedUserPrompt }] }
: m,
);
const modelMessages = await convertToModelMessages(enhancedMessages);
const result = streamText({
model: openai("gpt-4o-mini"),
system: enhancedSystemPrompt,
messages: modelMessages,
maxRetries: 2, // Retry transient LLM failures (default: 2; set 0 to disable)
});
return result.toTextStreamResponse();
}
Step 3: Verification Endpoint
// app/api/verify/route.ts
import { DeepCitation, getAllCitationsFromLlmOutput } from "deepcitation";
import { type NextRequest, NextResponse } from "next/server";
const dc = new DeepCitation({ apiKey: process.env.DEEPCITATION_API_KEY! });
export async function POST(req: NextRequest) {
const { llmOutput, attachmentId } = await req.json();
const citations = getAllCitationsFromLlmOutput(llmOutput);
if (Object.keys(citations).length === 0) {
return NextResponse.json({ citations: {}, verifications: {} });
}
const { verifications } = await dc.verifyAttachment(attachmentId, citations, {
outputImageFormat: "avif",
});
return NextResponse.json({ citations, verifications });
}
Step 4: Client — useChat + Post-Stream Verification
Wire the client so that: (1) uploads accumulate deepTextPagesByAttachmentId which get sent with each chat request, and (2) verification fires when isLoading transitions from true to false.
"use client";
import { useChat } from "@ai-sdk/react";
import type { Citation, FileDataPart, Verification } from "deepcitation";
import { useEffect, useEffectEvent, useRef, useState } from "react";
export default function Chat() {
const [fileDataParts, setFileDataParts] = useState<FileDataPart[]>([]);
const [deepTextPagesByAttachmentId, setDeepTextPagesByAttachmentId] = useState<Record<string, string[]>>({});
const [verifications, setVerifications] = useState<
Record<string, { citations: Record<string, Citation>; verifications: Record<string, Verification> }>
>({});
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
streamProtocol: "text",
body: {
deepTextPagesByAttachmentId, // Sent with every chat request
},
});
// useEffectEvent: stable reference, always reads latest state.
// This avoids adding fileDataParts to the useEffect dependency array
// which would re-trigger verification when files are added.
const verifyMessage = useEffectEvent((messageId: string, content: string) => {
if (!content || fileDataParts.length === 0) return;
fetch("/api/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
llmOutput: content,
attachmentId: fileDataParts[0].attachmentId,
}),
})
.then(res => res.json())
.then(data => setVerifications(prev => ({ ...prev, [messageId]: data })));
});
// Detect streaming end: isLoading true → false
const prevLoadingRef = useRef(false);
useEffect(() => {
if (prevLoadingRef.current && !isLoading) {
const last = messages[messages.length - 1];
if (last?.role === "assistant" && !verifications[last.id]) {
const content =
last.content ||
last.parts
?.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map(p => p.text)
.join("") ||
"";
verifyMessage(last.id, content);
}
}
prevLoadingRef.current = isLoading;
}, [isLoading, messages, verifications]);
// Handle file upload
const handleFile = async (file: File) => {
const formData = new FormData();
formData.append("file", file);
const res = await fetch("/api/upload", { method: "POST", body: formData });
if (!res.ok) return;
const data = await res.json();
setFileDataParts(prev => [...prev, data.fileDataPart]);
setDeepTextPagesByAttachmentId(prev => ({
...prev,
[data.fileDataPart.attachmentId]: data.deepTextPages,
}));
};
return (
<div>
<input type="file" onChange={e => e.target.files?.[0] && handleFile(e.target.files[0])} />
{messages.map(msg => {
const v = verifications[msg.id];
return (
<div key={msg.id}>
<strong>{msg.role}:</strong>
{/* Pass citations and verifications to your message renderer */}
<MessageContent
content={msg.content}
citations={v?.citations}
verifications={v?.verifications}
/>
</div>
);
})}
<form onSubmit={handleSubmit}>
<input value={input} onChange={handleInputChange} />
<button type="submit" disabled={isLoading}>Send</button>
</form>
</div>
);
}
Rendering CitationComponent
Once you have citations and verifications for a message, replace [N] citation markers with CitationComponent. The deepcitation/react entry point now ships with a "use client" directive, so imports from it are automatically client components. Your own component file still needs "use client" if it uses hooks or browser APIs.
"use client";
import { parseCitationResponse, type Citation, type Verification } from "deepcitation";
import { CitationComponent } from "deepcitation/react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { CONTINUE, visit } from "unist-util-visit";
// Remark plugin — replaces [N] in text nodes with custom AST nodes,
// keeping markdown formatting (bold, lists, etc.) intact.
// See INTEGRATION.md Recipe 3 for details.
const MARKER_RE = /(\[\d+\])/g;
function remarkCitationMarkers() {
return (tree: any) => {
visit(tree, "text", (node: any, index: any, parent: any) => {
if (index == null || !parent || !node.value) return;
const parts = node.value.split(MARKER_RE);
if (parts.length <= 1) return;
const newNodes = parts.filter(Boolean).map((part: string) => {
const m = part.match(/^\[(\d+)\]$/);
if (m) return { type: "citation-marker", data: { hName: "citation-marker", hProperties: { n: m[1] } } };
return { type: "text", value: part };
});
parent.children.splice(index, 1, ...newNodes);
return [CONTINUE, index + newNodes.length];
});
};
}
function MessageContent({
content,
citations = {},
verifications = {},
}: {
content: string;
citations?: Record<string, Citation>;
verifications?: Record<string, Verification>;
}) {
const result = parseCitationResponse(content);
return (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkCitationMarkers]}
components={{
"citation-marker": ({ n }: { n: string }) => {
const key = result.markerMap[Number(n)];
const citation = key ? (citations[key] ?? result.citations[key]) : null;
if (!key || !citation) return <sup>[{n}]</sup>;
return <CitationComponent citation={citation} verification={verifications[key]} />;
},
}}
>
{result.visibleText}
</ReactMarkdown>
);
}
Using generateText (Non-Streaming)
If you use generateText instead of streamText, verification happens right after generation — no streaming detection needed:
// app/api/chat/route.ts (non-streaming variant)
import { openai } from "@ai-sdk/openai";
import { DeepCitation, wrapCitationPrompt, getAllCitationsFromLlmOutput } from "deepcitation";
import { generateText } from "ai";
import { NextResponse } from "next/server";
const dc = new DeepCitation({ apiKey: process.env.DEEPCITATION_API_KEY! });
export async function POST(req: Request) {
const { userPrompt, attachmentId, deepTextPages } = await req.json();
const { enhancedSystemPrompt, enhancedUserPrompt } = wrapCitationPrompt({
systemPrompt: "You are a helpful assistant that cites sources.",
userPrompt,
deepTextPages,
});
const { text } = await generateText({
model: openai("gpt-4o-mini"),
system: enhancedSystemPrompt,
prompt: enhancedUserPrompt,
});
// Verify in the same handler — no streaming detection needed
const citations = getAllCitationsFromLlmOutput(text);
const { verifications } =
Object.keys(citations).length > 0
? await dc.verifyAttachment(attachmentId, citations)
: { verifications: {} };
return NextResponse.json({ text, citations, verifications });
}
Multiple File Providers
wrapCitationPrompt accepts deepTextPagesByAttachmentId as a raw page map. Pass the accumulated map directly:
wrapCitationPrompt({
systemPrompt: "...",
userPrompt: question,
deepTextPagesByAttachmentId, // Record<attachmentId, string[]>
});
For verification across multiple attachments, use groupCitationsByAttachmentId:
import { groupCitationsByAttachmentId } from "deepcitation";
const citationsByAttachment = groupCitationsByAttachmentId(citations);
const results = await Promise.all(
Array.from(citationsByAttachment.entries()).map(([id, cits]) =>
dc.verifyAttachment(id, cits),
),
);
Audio & Video Citation Support
DeepCitation supports audio and video file uploads in addition to documents. The upload and verification flow is identical — prepareAttachments() accepts audio/video files and verifyAttachment() returns time-range evidence for media citations. No changes to your streamText or useChat wiring are needed; the citation type is determined by the uploaded file.
Error Handling & Retry Behavior
DeepCitation API errors now include a docUrl property pointing to the relevant documentation page. Use this for actionable error messages in your UI:
try {
const { verifications } = await dc.verifyAttachment(attachmentId, citations);
} catch (err: any) {
console.error(err.message);
if (err.docUrl) {
console.error(`See: ${err.docUrl}`);
}
}
For the streamText / generateText calls, configure maxRetries to handle transient LLM provider failures (the Vercel AI SDK retries automatically). For DeepCitation API calls (prepareAttachments, verifyAttachment), implement your own retry logic or use the SDK’s built-in retry where available.
Scaffold This Integration
npx degit DeepCitation/deepcitation/examples/nextjs-ai-sdk my-app
cd my-app
cp .env.example .env.local
# Set DEEPCITATION_API_KEY and OPENAI_API_KEY in .env.local
npm install && npm run dev
Next Steps
- Next.js App Router guide — “use client” boundary table, SSG pattern, DeepCitationTheme setup
- Components — CitationDrawer for grouped source browsing
- Styling — CSS customization and DeepCitationTheme provider
- Error Handling — retry logic, invalid key errors