Skip to main content
This guide shows how to integrate Doclo into a Next.js application, covering file uploads, API routes, and different execution patterns for production deployments.

Prerequisites

  • Node.js 18+
  • Next.js 14+ (App Router)
  • A Doclo API key

Installation

pnpm add @doclo/client

Project Structure

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:
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):
// 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

// 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:
// 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:
// 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:
// 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 }
    );
  }
}
Frontend polling implementation:
// 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:
// 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:
// 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:
// 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'
    };
  }
}
Use in a component:
// 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:
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:
// next.config.js
module.exports = {
  experimental: {
    serverActions: {
      bodySizeLimit: '10mb'
    }
  }
};

Caching

Cache flow information:
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
);

Next Steps