Running document processing inside a Vercel serverless function is a trap most teams fall into once. You bundle a PDF library, the function bloats, cold starts slow to a crawl, and then a large upload returns a 413 FUNCTION_PAYLOAD_TOO_LARGE. The cleaner path is to treat document processing as an external REST service and let your Next.js API routes act as thin, stateless orchestrators. That's exactly what this guide builds.
Prerequisites
You'll need Node.js 18 or higher (20 is recommended), npm, and a Vercel account. Node 18 ships native fetch, so you won't add any HTTP client dependencies. You'll also need a Foxit developer account to get your API credentials.
Scaffold the project and create the environment variable file:
npx create-next-app@latest foxit-vercel-demo --typescript --app --no-tailwind
cd foxit-vercel-demo
# .env.local
FOXIT_CLIENT_ID=your_client_id_here
FOXIT_CLIENT_SECRET=your_client_secret_here
FOXIT_BASE_URL=https://na1.fusion.foxit.com
Your editor of choice works fine here. VS Code with the TypeScript extension catches type errors early, and WebStorm and Sublime Text are solid alternatives. Install jq if you want to inspect API responses from the terminal without writing a parsing script.
Set FOXIT_CLIENT_ID and FOXIT_CLIENT_SECRET in your local .env.local file now. Before deploying, add both to your Vercel project's environment variables through the dashboard or via the Vercel CLI. Never put credentials directly in source files.
Why document processing breaks in serverless functions
Three hard constraints make in-function document processing painful, and Vercel documents all three.
Bundle size is the first. When you install a native PDF library, you're pulling in compiled binaries. Vercel's function bundle limit is 250 MB uncompressed. A library like pdf-lib stays small, but anything that links to a system binary (Ghostscript, poppler, native Tesseract) blows past that ceiling quickly and degrades cold-start times for every function in the deployment.
The second constraint is the 4.5 MB request and response body cap. If a user uploads a 6 MB invoice scan and your route tries to read the raw body, Vercel rejects the request with a 413 FUNCTION_PAYLOAD_TOO_LARGE before your handler even runs. Working around this inside a standard serverless function adds fragility.
Third, function duration limits. With Fluid Compute enabled (on by default), the default maximum duration is 300 seconds. Pro and Enterprise plans can extend to 800 seconds, with an 1800-second extended beta option. A synchronous OCR job on a dense scanned contract can burn that budget and return a 504 FUNCTION_INVOCATION_TIMEOUT.
The billing model actually works in your favor here. Vercel charges for active CPU time, not I/O wait. A function that fires an external call and then polls for a result accumulates almost no billed time during the wait.
All three constraints point to the same conclusion: document transformation is I/O-bound and asynchronous by nature, so your function should be a thin orchestrator that delegates work to an external service and handles the result. Foxit's PDF Services API is built around that model.
Architecture overview: Vercel as orchestrator, Foxit as processor
A browser posts a document to your Next.js API route, the route uploads the file to Foxit and triggers an extraction task, then returns a taskId to the client. The client polls a second route until the task reaches a terminal state. At that point, your route downloads the result and either returns it or writes it to a database.
Foxit holds the in-flight document state, which keeps your function stateless between calls and matches how serverless runtimes are supposed to work.
For files larger than a few megabytes, skip routing the upload through your function body entirely. Upload directly from the browser to Foxit (if your access model permits it), or use Vercel Blob as a staging area and pass the blob URL to Foxit on the server side. A 10 MB contract routed through a serverless function body will hit the 4.5 MB cap every time.
For jobs that might exceed even the extended duration limit, Vercel Workflows (released to general availability in April 2026) gives you a managed durable-execution layer. A Workflow step can pause and resume across function invocations, giving you unlimited execution time without managing a dedicated worker process.
Getting credentials and the auth model
Create a free developer account at account.foxit.com/site/sign-up. The free developer plan gives you 500 credits per year with an annual reset and no credit card required. Once you log in, open the APIs dashboard to find your Client ID and Client Secret.
Foxit's PDF Services API uses header-based authentication built for backend automation with no UI layer, just REST. Every request to both the PDF Services API and the Document Generation API sends lowercase client_id and client_secret as custom request headers, with no Bearer token, no OAuth2 flow, and no SDK to install. The base host for all calls is https://na1.fusion.foxit.com.
On Vercel, store both values as environment variables in your project settings. Read them in code via process.env.FOXIT_CLIENT_ID and process.env.FOXIT_CLIENT_SECRET. Treat these like database passwords. If you accidentally commit them in source, rotate them immediately from the developer portal.
You can explore the full endpoint reference at docs.developer-api.foxit.com, including an in-browser playground that lets you test extraction calls before writing a single line of code.
Implementing upload and extraction in a Next.js API route
The upload and extraction route makes two sequential calls to Foxit. First, it posts the document to get a documentId. Second, it submits that documentId to the structural extraction endpoint to get a taskId. The route returns the taskId immediately, and the actual extraction work happens asynchronously on Foxit's side.
You can use any invoice or contract PDF as your test file. A sample collection of template-compatible documents lives at github.com/lucienchemaly/foxit-demo-templates if you need one to start with. Supported upload formats include PDF, Microsoft Office files, images, and plain text.
// app/api/extract/route.ts
import { NextRequest, NextResponse } from "next/server";
const BASE = process.env.FOXIT_BASE_URL!;
const foxitHeaders: Record<string, string> = {
client_id: process.env.FOXIT_CLIENT_ID!,
client_secret: process.env.FOXIT_CLIENT_SECRET!,
};
export async function POST(req: NextRequest) {
// Step 1: Read the incoming multipart form data
const formData = await req.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
// Step 2: Upload the document to Foxit PDF Services
const uploadForm = new FormData();
uploadForm.append("file", file);
const uploadRes = await fetch(`${BASE}/pdf-services/api/documents/upload`, {
method: "POST",
headers: foxitHeaders,
body: uploadForm,
});
if (!uploadRes.ok) {
const err = await uploadRes.text();
return NextResponse.json(
{ error: "Upload failed", detail: err },
{ status: 502 },
);
}
const { documentId } = await uploadRes.json();
// Step 3: Trigger structural extraction (returns taskId, not the result)
const extractRes = await fetch(
`${BASE}/pdf-services/api/documents/pdf-structural-extract`,
{
method: "POST",
headers: { ...foxitHeaders, "Content-Type": "application/json" },
body: JSON.stringify({ documentId }),
},
);
if (!extractRes.ok) {
const err = await extractRes.text();
return NextResponse.json(
{ error: "Extraction trigger failed", detail: err },
{ status: 502 },
);
}
const { taskId } = await extractRes.json();
// Return the taskId. The client polls for completion separately.
return NextResponse.json({ taskId });
}
This route reads the uploaded file from the incoming FormData, rebuilds a new FormData to forward it to Foxit's upload endpoint, and extracts the documentId from the response. It then calls the pdf-structural-extract endpoint with that documentId and captures the returned taskId. The route returns immediately with the taskId without waiting for extraction to finish, so your function stays short-lived while Foxit handles the heavy lifting asynchronously.
foxitHeaders pulls credentials from environment variables, which is the only acceptable pattern when deploying to a shared runtime.
Handling the async polling pattern without blocking your function
Polling a task status endpoint from a serverless function is simpler than most teams expect. Treat any non-terminal status as "keep polling" rather than matching on a specific in-progress label. That way, your loop stays correct if Foxit ever adds a new intermediate status.
The four statuses you'll encounter are PENDING, PROCESSING, COMPLETED, and FAILED. The progress field on the response runs from 0 to 100. A COMPLETED task includes a resultDocumentId that you pass to the download endpoint.
// app/api/poll/[taskId]/route.ts
import { NextRequest, NextResponse } from "next/server";
const BASE = process.env.FOXIT_BASE_URL!;
const foxitHeaders: Record<string, string> = {
client_id: process.env.FOXIT_CLIENT_ID!,
client_secret: process.env.FOXIT_CLIENT_SECRET!,
};
const MAX_ATTEMPTS = 20;
const BASE_DELAY_MS = 2_000;
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ taskId: string }> },
) {
const { taskId } = await params;
let attempts = 0;
while (attempts < MAX_ATTEMPTS) {
const res = await fetch(`${BASE}/pdf-services/api/tasks/${taskId}`, {
headers: foxitHeaders,
});
const task = await res.json();
if (task.status === "FAILED") {
return NextResponse.json(
{ error: "Extraction failed", task },
{ status: 500 },
);
}
if (task.status === "COMPLETED") {
// Download the ZIP archive from Foxit
const dlRes = await fetch(
`${BASE}/pdf-services/api/documents/${task.resultDocumentId}/download`,
{ headers: foxitHeaders },
);
const zipBuffer = Buffer.from(await dlRes.arrayBuffer());
// Unzip to access StructureInfo.json and per-page PNGs
// (unzip step depends on your preferred library, e.g. 'fflate' or 'adm-zip')
return NextResponse.json({
status: "COMPLETED",
resultDocumentId: task.resultDocumentId,
progress: task.progress,
});
}
// Non-terminal status: exponential backoff before retrying
await new Promise((resolve) =>
setTimeout(resolve, BASE_DELAY_MS * Math.pow(1.5, attempts)),
);
attempts++;
}
return NextResponse.json(
{ error: "Max polling attempts reached" },
{ status: 408 },
);
}
This loops up to 20 times with exponential backoff starting at 2 seconds. Any status other than COMPLETED or FAILED falls through to the sleep and increments the attempt counter. On completion, the route downloads the result from the /documents/{resultDocumentId}/download endpoint, which returns a ZIP archive containing StructureInfo.json, the structured representation of the document. Image-based or scanned inputs can also include a rendered PNG for each page.
Here is an abbreviated view of the real StructureInfo.json from running this flow against a one-page invoice PDF:
{
"analyzeResult": {
"info": {
"basicInfo": {
"analyzedPageCount": 1,
"elementCounts": { "paragraph": 6, "title": 1 }
}
},
"version": { "schema": "1.0.7", "software": "FoxitPDFAnalyzer", "model": "idp-analysis" },
"pages": [{ "pageNumber": 1, "size": {}, "state": {} }],
"elements": [
{
"type": "title",
"content": { "text": "INVOICE", "style": { "fontName": "Arial-BoldMT", "fontSize": 24.0 } },
"region": { "page": 1, "boundingBox": [90, 71, 189, 71, 189, 99, 90, 99] },
"score": 0.8795,
"id": "title1"
},
{
"type": "paragraph",
"content": { "text": "Issued by Foxit DocGen API Demo", "style": { "fontName": "ArialMT", "fontSize": 11.0 } },
"region": { "page": 1, "boundingBox": [90, 113, 261, 113, 261, 127, 90, 127] },
"score": 0.8991,
"id": "paragraph1"
}
]
}
}
In this output, info.basicInfo reports the page count and a tally of detected element types, version.schema is the value your parser should pin to, and each entry in elements carries its type, extracted content.text, a region.boundingBox giving the eight-point polygon of the element on the page, and a confidence score.
For jobs that consistently finish within a minute or two, this inline polling approach works well. For documents that take longer, or if you're processing high volumes, move the polling into a Vercel Workflows step. A Workflow pauses and resumes across function boundaries, so a five-minute extraction job doesn't require you to hold a function open or set up a separate queue worker.
Routing structured output downstream and closing the loop with DocGen
Once you unzip the archive, StructureInfo.json gives you a machine-readable representation of the document's content. The top-level key is analyzeResult, which contains a pages array and an elements array. Each element includes type, content, region, and score. Table elements use regions and scores (plural) and nest a paragraph object per cell, each with rowIndex and columnIndex for precise grid positioning. Pin your parsers to analyzeResult.version.schema so a schema version bump doesn't break downstream logic.
The Structural Extraction endpoint returns this machine-readable StructureInfo.json with text and table structure intact, routing directly into downstream AI or database steps without a custom parsing layer. Three patterns cover most cases:
- Forward text and table content to a RAG pipeline or LLM for summarization, classification, or Q&A.
- Write specific extracted fields (vendor name, invoice total, contract dates) to a CRM or database record.
- Feed those extracted values directly into the Document Generation API to produce a filled-out output document.
That third option closes the full loop. The DocGen endpoint works differently from the PDF Services endpoints because it is synchronous. You post a request and get the rendered document back immediately, with no taskId and no polling.
Send your Word template as a base64-encoded string. A confirmed-working invoice template is available at github.com/lucienchemaly/foxit-demo-templates/raw/main/invoice_simple.docx.
// app/api/generate/route.ts
import { NextRequest, NextResponse } from "next/server";
const DOCGEN_URL =
"https://na1.fusion.foxit.com/document-generation/api/GenerateDocumentBase64";
const foxitHeaders: Record<string, string> = {
client_id: process.env.FOXIT_CLIENT_ID!,
client_secret: process.env.FOXIT_CLIENT_SECRET!,
"Content-Type": "application/json",
};
export async function POST(req: NextRequest) {
const { base64Template, documentValues } = await req.json();
// DocGen is synchronous. No taskId or polling required.
const res = await fetch(DOCGEN_URL, {
method: "POST",
headers: foxitHeaders,
body: JSON.stringify({
base64FileString: base64Template, // .docx template, base64-encoded
documentValues, // values extracted from StructureInfo.json
outputFormat: "pdf", // or 'docx'
}),
});
const { base64FileString, message } = await res.json();
return NextResponse.json({ pdf: base64FileString, message });
}
This route sends three fields in the request body: base64FileString (your Word template encoded as a base64 string), documentValues (a JSON object containing the template tokens and their values, drawn from your parsed StructureInfo.json), and outputFormat (either "pdf" or "docx"). The response comes back with a base64FileString containing the rendered output document, with no waiting or polling.
One constraint applies to DocGen uploads. The .docx template must stay under 4 MB after base64 encoding. If yours is larger, compress embedded images through Word's Picture Format menu, remove embedded fonts, and drop any OLE objects. Splitting an oversized template into smaller per-section templates is the most reliable fix when image compression isn't enough.
What to wire up today
The fastest path to a working serverless document processing proof of concept takes four steps. Clone or scaffold a fresh Next.js App Router project. Set FOXIT_CLIENT_ID and FOXIT_CLIENT_SECRET in .env.local using the credentials from the Foxit developer portal. Drop in the app/api/extract/route.ts from this guide and point it at an invoice or contract PDF. Run the server with npm run dev, post a file, and unzip the returned archive to inspect StructureInfo.json before you build any downstream logic.
Inspect that raw JSON before wiring up any database writes or AI calls. Extraction output differs between document types. Invoices tend to produce clean table elements with well-separated line items, while scanned contracts produce dense text elements with lower confidence scores. Knowing what the output looks like for your specific document type shapes every routing decision that follows.
The free developer plan covers 500 credits per year with no credit card, which is enough to run a full proof of concept without a procurement conversation. See the credits reference for per-operation credit costs.
If you want to test extraction before writing any code, the Foxit developer portal includes an in-browser playground where you can upload a file and inspect the StructureInfo.json response directly. Create your account at account.foxit.com/site/sign-up, then point your first upload route at the sample PDF when you're ready to move into code.
Invoices, contracts, and scanned forms each produce different extraction output, and the routing logic that works well for one often needs adjustment for another. Share your use case in the comments and I'll follow up with specifics.

Top comments (0)