Prerequisites
- Node.js 18+
- Next.js 14+ (App Router)
- A Doclo API key
Installation
Copy
pnpm add @doclo/client
Project Structure
Copy
app/
├── api/
│ ├── process-document/
│ │ └── route.ts # Sync processing endpoint
│ ├── process-async/
│ │ └── route.ts # Async processing endpoint
│ └── doclo-webhook/
│ └── route.ts # Webhook handler
├── components/
│ └── document-uploader.tsx
└── page.tsx
Environment Setup
Create a.env.local file:
Copy
DOCLO_API_KEY=dc_live_org123_...
DOCLO_WEBHOOK_SECRET=whsec_... # For webhook verification
Basic API Route: Synchronous Processing
For documents that process quickly (under 30 seconds):Copy
// app/api/process-document/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { DocloClient } from '@doclo/client';
const client = new DocloClient({
apiKey: process.env.DOCLO_API_KEY!
});
interface InvoiceOutput {
invoiceNumber: string;
total: number;
currency: string;
vendor: string;
}
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get('file') as File | null;
const flowId = formData.get('flowId') as string | null;
if (!file) {
return NextResponse.json(
{ error: 'No file provided' },
{ status: 400 }
);
}
if (!flowId) {
return NextResponse.json(
{ error: 'No flowId provided' },
{ status: 400 }
);
}
// Convert file to base64
const buffer = await file.arrayBuffer();
const base64 = Buffer.from(buffer).toString('base64');
// Process synchronously
const result = await client.flows.run<InvoiceOutput>(flowId, {
input: {
document: {
base64,
filename: file.name,
mimeType: file.type || 'application/pdf'
}
},
wait: true,
timeout: 30000 // 30 second timeout
});
return NextResponse.json({
success: true,
data: result.output,
metrics: {
duration: result.duration,
cost: result.metrics?.cost
}
});
} catch (error) {
console.error('Processing error:', error);
if (error instanceof Error) {
return NextResponse.json(
{ error: error.message },
{ status: 500 }
);
}
return NextResponse.json(
{ error: 'Processing failed' },
{ status: 500 }
);
}
}
Frontend: File Upload Component
Copy
// app/components/document-uploader.tsx
'use client';
import { useState } from 'react';
interface ExtractedData {
invoiceNumber: string;
total: number;
currency: string;
vendor: string;
}
interface ProcessingResult {
success: boolean;
data?: ExtractedData;
error?: string;
metrics?: {
duration: number;
cost: number;
};
}
export function DocumentUploader({ flowId }: { flowId: string }) {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<ProcessingResult | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!file) return;
setLoading(true);
setResult(null);
try {
const formData = new FormData();
formData.append('file', file);
formData.append('flowId', flowId);
const response = await fetch('/api/process-document', {
method: 'POST',
body: formData
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Processing failed');
}
setResult(data);
} catch (error) {
setResult({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
});
} finally {
setLoading(false);
}
};
return (
<div className="max-w-md mx-auto p-6">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">
Upload Document
</label>
<input
type="file"
accept=".pdf,image/*"
onChange={(e) => setFile(e.target.files?.[0] || null)}
className="w-full border rounded p-2"
/>
</div>
<button
type="submit"
disabled={!file || loading}
className="w-full bg-blue-600 text-white py-2 rounded disabled:opacity-50"
>
{loading ? 'Processing...' : 'Extract Data'}
</button>
</form>
{result && (
<div className="mt-6 p-4 rounded bg-gray-100">
{result.success ? (
<div>
<h3 className="font-semibold mb-2">Extracted Data</h3>
<pre className="text-sm overflow-auto">
{JSON.stringify(result.data, null, 2)}
</pre>
{result.metrics && (
<p className="text-sm text-gray-600 mt-2">
Processed in {result.metrics.duration}ms
</p>
)}
</div>
) : (
<p className="text-red-600">{result.error}</p>
)}
</div>
)}
</div>
);
}
Async Processing with Webhooks
For longer-running extractions, use async processing:Copy
// app/api/process-async/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { DocloClient } from '@doclo/client';
const client = new DocloClient({
apiKey: process.env.DOCLO_API_KEY!
});
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get('file') as File | null;
const flowId = formData.get('flowId') as string | null;
const callbackUrl = formData.get('callbackUrl') as string | null;
if (!file || !flowId) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
);
}
const buffer = await file.arrayBuffer();
const base64 = Buffer.from(buffer).toString('base64');
// Start async execution
const execution = await client.flows.run(flowId, {
input: {
document: {
base64,
filename: file.name,
mimeType: file.type || 'application/pdf'
}
},
// Webhook URL for completion notification
webhookUrl: callbackUrl || `${process.env.NEXT_PUBLIC_URL}/api/doclo-webhook`,
// Optional metadata to identify this request
metadata: {
source: 'nextjs-app',
uploadedAt: new Date().toISOString()
}
});
return NextResponse.json({
success: true,
executionId: execution.id,
status: execution.status,
message: 'Processing started. You will be notified when complete.'
});
} catch (error) {
console.error('Processing error:', error);
return NextResponse.json(
{ error: 'Failed to start processing' },
{ status: 500 }
);
}
}
Webhook Handler
Receive completion notifications:Copy
// app/api/doclo-webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyWebhookSignature, parseWebhookEvent } from '@doclo/client';
export async function POST(request: NextRequest) {
try {
// Get raw body for signature verification
const rawBody = await request.text();
const signature = request.headers.get('x-doclo-signature');
if (!signature) {
return NextResponse.json(
{ error: 'Missing signature' },
{ status: 401 }
);
}
// Verify webhook signature
const isValid = await verifyWebhookSignature(
rawBody,
signature,
process.env.DOCLO_WEBHOOK_SECRET!
);
if (!isValid) {
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 401 }
);
}
// Parse the event
const body = JSON.parse(rawBody);
const event = parseWebhookEvent(body);
// Handle different event types
if (event.event === 'execution.completed') {
console.log('Extraction completed:', event.data.id);
console.log('Output:', event.data.output);
// Process the result (e.g., save to database, notify user)
await handleCompletedExtraction(event.data);
} else if (event.event === 'execution.failed') {
console.error('Extraction failed:', event.data.id);
console.error('Error:', event.data.error);
// Handle the failure
await handleFailedExtraction(event.data);
}
// Return 200 quickly to acknowledge receipt
return NextResponse.json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 500 }
);
}
}
async function handleCompletedExtraction(execution: any) {
// Save to database
// await prisma.extraction.update({
// where: { executionId: execution.id },
// data: {
// status: 'completed',
// output: execution.output,
// completedAt: new Date()
// }
// });
// Notify user via websocket, email, etc.
console.log('Processed extraction:', execution.id);
}
async function handleFailedExtraction(execution: any) {
// Log failure, notify team, retry if appropriate
console.error('Failed extraction:', execution.id, execution.error);
}
Polling for Results
Alternative to webhooks - poll for completion:Copy
// app/api/check-status/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { DocloClient } from '@doclo/client';
const client = new DocloClient({
apiKey: process.env.DOCLO_API_KEY!
});
export async function GET(request: NextRequest) {
const executionId = request.nextUrl.searchParams.get('id');
if (!executionId) {
return NextResponse.json(
{ error: 'Missing execution ID' },
{ status: 400 }
);
}
try {
const execution = await client.runs.get(executionId);
return NextResponse.json({
id: execution.id,
status: execution.status,
output: execution.status === 'success' ? execution.output : null,
error: execution.status === 'failed' ? execution.error : null,
duration: execution.duration
});
} catch (error) {
return NextResponse.json(
{ error: 'Failed to get status' },
{ status: 500 }
);
}
}
Copy
// In your component
async function pollForResult(executionId: string): Promise<any> {
const maxAttempts = 60; // 5 minutes at 5-second intervals
const interval = 5000;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const response = await fetch(`/api/check-status?id=${executionId}`);
const data = await response.json();
if (data.status === 'success') {
return data.output;
}
if (data.status === 'failed') {
throw new Error(data.error?.message || 'Processing failed');
}
// Still processing, wait and try again
await new Promise(resolve => setTimeout(resolve, interval));
}
throw new Error('Processing timed out');
}
Error Handling
Handle different error types appropriately:Copy
// app/api/process-document/route.ts
import { NextRequest, NextResponse } from 'next/server';
import {
DocloClient,
AuthenticationError,
ValidationError,
RateLimitError,
TimeoutError,
NotFoundError
} from '@doclo/client';
const client = new DocloClient({
apiKey: process.env.DOCLO_API_KEY!
});
export async function POST(request: NextRequest) {
try {
// ... processing logic
} catch (error) {
console.error('Processing error:', error);
if (error instanceof AuthenticationError) {
return NextResponse.json(
{ error: 'Invalid API key' },
{ status: 401 }
);
}
if (error instanceof ValidationError) {
return NextResponse.json(
{ error: `Invalid input: ${error.message}` },
{ status: 400 }
);
}
if (error instanceof RateLimitError) {
const retryAfter = error.rateLimitInfo?.retryAfter || 60;
return NextResponse.json(
{ error: 'Rate limit exceeded', retryAfter },
{
status: 429,
headers: { 'Retry-After': String(retryAfter) }
}
);
}
if (error instanceof TimeoutError) {
return NextResponse.json(
{ error: 'Processing timed out. Try async processing.' },
{ status: 504 }
);
}
if (error instanceof NotFoundError) {
return NextResponse.json(
{ error: 'Flow not found' },
{ status: 404 }
);
}
return NextResponse.json(
{ error: 'Processing failed' },
{ status: 500 }
);
}
}
Type-Safe Flow Results
Use TypeScript generics for type-safe extraction:Copy
// types/extraction.ts
export interface InvoiceExtraction {
invoiceNumber: string;
invoiceDate: string;
vendor: {
name: string;
address: string;
};
lineItems: Array<{
description: string;
quantity: number;
amount: number;
}>;
total: number;
currency: string;
}
// In your API route
const result = await client.flows.run<InvoiceExtraction>(flowId, {
input: { document: { base64, filename, mimeType } },
wait: true
});
// result.output is typed as InvoiceExtraction
const { invoiceNumber, total, currency } = result.output!;
Server Actions (Next.js 14+)
Use Server Actions for a streamlined approach:Copy
// app/actions/process-document.ts
'use server';
import { DocloClient } from '@doclo/client';
const client = new DocloClient({
apiKey: process.env.DOCLO_API_KEY!
});
interface ProcessResult {
success: boolean;
data?: any;
error?: string;
}
export async function processDocument(formData: FormData): Promise<ProcessResult> {
const file = formData.get('file') as File | null;
const flowId = formData.get('flowId') as string;
if (!file) {
return { success: false, error: 'No file provided' };
}
try {
const buffer = await file.arrayBuffer();
const base64 = Buffer.from(buffer).toString('base64');
const result = await client.flows.run(flowId, {
input: {
document: {
base64,
filename: file.name,
mimeType: file.type
}
},
wait: true,
timeout: 30000
});
return {
success: true,
data: result.output
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Processing failed'
};
}
}
Copy
// app/components/server-action-uploader.tsx
'use client';
import { processDocument } from '@/app/actions/process-document';
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="bg-blue-600 text-white py-2 px-4 rounded"
>
{pending ? 'Processing...' : 'Extract'}
</button>
);
}
export function ServerActionUploader({ flowId }: { flowId: string }) {
async function handleAction(formData: FormData) {
formData.append('flowId', flowId);
const result = await processDocument(formData);
console.log('Result:', result);
}
return (
<form action={handleAction}>
<input type="file" name="file" accept=".pdf,image/*" />
<SubmitButton />
</form>
);
}
Production Considerations
Rate Limiting
Protect your endpoints from abuse:Copy
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '1 m'), // 10 requests per minute
});
export async function POST(request: NextRequest) {
const ip = request.ip ?? '127.0.0.1';
const { success, limit, remaining } = await ratelimit.limit(ip);
if (!success) {
return NextResponse.json(
{ error: 'Rate limit exceeded' },
{
status: 429,
headers: {
'X-RateLimit-Limit': String(limit),
'X-RateLimit-Remaining': String(remaining)
}
}
);
}
// Continue with processing...
}
File Size Limits
Configure Next.js body size limits:Copy
// next.config.js
module.exports = {
experimental: {
serverActions: {
bodySizeLimit: '10mb'
}
}
};
Caching
Cache flow information:Copy
import { unstable_cache } from 'next/cache';
const getFlowInfo = unstable_cache(
async (flowId: string) => {
return await client.flows.get(flowId);
},
['flow-info'],
{ revalidate: 3600 } // Cache for 1 hour
);