Use Case
Generating audit-trail PDFs from a Next.js Server Action
When a contractor signs an NDA in your app, or a field tech submits an inspection report, your records team doesn't just want the filled document. They want the filled document plus a cover page showing who signed, when, from what IP, against which template version, and what the resulting checksum is. That cover page is the difference between a document and a defensible audit trail. The Next.js App Router has a clean primitive for this: a Server Action that fills the main template, fills a metadata cover page, and merges them — two PDFops endpoints in one round-trip, under 500ms end-to-end.
The setup
Every multi-product business I run eventually accumulates a stack of records-keeping obligations: contractor NDAs, vendor MSAs, customer DPAs, inspection reports for the regulated bits, maintenance logs for the insured bits. Most of these documents are AcroForm-shaped — the contractor types their name and date, the field tech fills in five readings and a sign-off — and most of them live three years on a shared drive before anyone looks at them again. When someone does look, it's usually because a lawyer, an auditor, or a customer wants to know who signed what, when, and against what template revision.
A bare filled PDF doesn't answer those questions. The contractor's name is in the form fields, but the timestamp is whenever the PDF reader displays the file. The template version is whatever shipped that quarter. The signing IP isn't anywhere. You can't tell from the file alone whether it was signed during business hours from an office or at 3 AM from a coffee-shop wi-fi. (Companion: contract PDFs from onboarding flows covers the broader template-fill pattern; this post is specifically about adding the metadata cover page.)
The shape of the fix is a cover page bound to the document at the moment of signature, with everything the records team would later want to know — request metadata, template version, document checksum — laid out in plain language. The cover page is the audit trail; the original document is the artifact it audits. A Next.js Server Action is a natural home for the stitching logic: it runs on the server, it has request metadata (headers, IP), it returns a serializable result the form can hand back to the user, and it gates cleanly behind your existing auth middleware.
The flow
- User submits the form: a React form posts to the Server Action via
<form action={generateAuditTrail}>; FormData arrives server-side - Server Action → auth + request metadata: read session, IP (
x-forwarded-for), user-agent, server timestamp (~1ms) - Server Action → Vercel Blob: fetch the main template + the cover-page template (cached at the edge after first fetch;
~20-40msfor both in parallel) - Server Action → PDFops (1st call):
POST /api/fill-formwith the main template + the user's answers (~100-200ms) - Server Action → PDFops (2nd call):
POST /api/fill-formwith the cover-page template + the audit metadata (~100-200ms; can be issued in parallel with the first if you don't need the main PDF's checksum on the cover page) - Server Action → PDFops (3rd call):
POST /api/mergewith [cover, main] → one combined PDF (~80-150ms) - Server Action → Vercel Blob: stash the merged PDF with a non-guessable key (
~30-50ms) - Server Action → caller: return the signed URL; React revalidates and renders a download link
End-to-end: ~350-500ms sequentially, or ~250-350ms if you parallelize the two fill-form calls. The Server Action runs once per submission; no background queue, no separate worker, no extra infrastructure.
The function
Here's the whole thing — ~75 lines of TypeScript, no helper abstractions. The audit metadata is read from request headers (Server Action sees them via next/headers) and a stable template-version constant. The main and cover fill-form calls run in parallel; the merge waits on both.
// app/actions/generate-audit-trail.ts
'use server';
import { put } from '@vercel/blob';
import { headers } from 'next/headers';
import { auth } from '@/lib/auth';
const TEMPLATE_VERSION = '2026.05.nda-v3';
const MAIN_TEMPLATE_URL = process.env.NDA_TEMPLATE_URL!;
const COVER_TEMPLATE_URL = process.env.AUDIT_COVER_TEMPLATE_URL!;
interface AuditTrailResult {
ok: true;
url: string; // signed download URL
checksum: string; // SHA-256 of the merged PDF
}
export async function generateAuditTrail(formData: FormData): Promise<AuditTrailResult> {
const session = await auth();
if (!session?.user) throw new Error('unauthenticated');
// 1. Request metadata — what the audit trail captures.
const h = await headers();
const audit = {
signed_by: session.user.email,
signed_at: new Date().toISOString(),
ip: h.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown',
user_agent: h.get('user-agent') ?? 'unknown',
template_ver: TEMPLATE_VERSION,
};
// 2. Fetch both templates in parallel (cached at the edge after first fetch).
const [mainTpl, coverTpl] = await Promise.all([
fetch(MAIN_TEMPLATE_URL).then(r => r.arrayBuffer()),
fetch(COVER_TEMPLATE_URL).then(r => r.arrayBuffer()),
]);
// 3. Two fill-form calls in parallel — main NDA + audit cover.
const callFillForm = async (pdf: ArrayBuffer, fields: Record<string, string>) => {
const fd = new FormData();
fd.append('pdf', new Blob([pdf], { type: 'application/pdf' }), 'tpl.pdf');
fd.append('fields', JSON.stringify(fields));
const r = await fetch('https://pdfops.dev/api/fill-form', { method: 'POST', body: fd });
if (!r.ok) throw new Error(`fill-form failed: ${await r.text()}`);
return r.arrayBuffer();
};
const [filledMain, filledCover] = await Promise.all([
callFillForm(mainTpl, {
signer_name: String(formData.get('signer_name') ?? ''),
signer_email: session.user.email,
effective_at: audit.signed_at.slice(0, 10),
}),
callFillForm(coverTpl, {
signed_by: audit.signed_by,
signed_at: audit.signed_at,
ip: audit.ip,
user_agent: audit.user_agent,
template_ver: audit.template_ver,
}),
]);
// 4. Merge cover + main into one PDF.
const mergeFd = new FormData();
mergeFd.append('pdf', new Blob([filledCover], { type: 'application/pdf' }), 'cover.pdf');
mergeFd.append('pdf', new Blob([filledMain], { type: 'application/pdf' }), 'main.pdf');
const mergeResp = await fetch('https://pdfops.dev/api/merge', { method: 'POST', body: mergeFd });
if (!mergeResp.ok) throw new Error(`merge failed: ${await mergeResp.text()}`);
const merged = await mergeResp.arrayBuffer();
// 5. Checksum + store. The checksum is part of the audit trail itself —
// a future verifier can recompute SHA-256 and confirm the bytes
// haven't been tampered with since the moment of signature.
const checksum = await sha256Hex(merged);
const key = `audit/${session.user.id}/${crypto.randomUUID()}.pdf`;
const { url } = await put(key, merged, {
access: 'public',
contentType: 'application/pdf',
addRandomSuffix: false,
});
return { ok: true, url, checksum };
}
async function sha256Hex(buf: ArrayBuffer): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', buf);
return Array.from(new Uint8Array(digest))
.map(b => b.toString(16).padStart(2, '0')).join('');
}
That's the production-shape. Drop it in app/actions/, point a form at it, and every submission produces a merged PDF — cover page first, then the signed document — stored in Vercel Blob, with the SHA-256 returned so you can write the audit row to your database alongside it.
One auth-boundary footgun worth flagging: the
example uses access: 'public' on the Blob put, with
a random UUID in the key path. That's fine for short-lived
download links the signer opens within minutes of signing —
but audit-trail PDFs are exactly the kind of artifact you'll
want to retrieve months or years later. For long-lived
storage, switch to access: 'private' and gate
downloads behind your own route handler that checks the
requesting user's right to view this particular document.
The cover page already records who signed; the access layer
enforces who can fetch.
The cover-page template
The cover page is a one-page PDF with AcroForm fields named the same as the keys in the audit object: signed_by, signed_at, ip, user_agent, template_ver. Build it once in Acrobat, Mac Preview, or LibreOffice Draw — a header reading Audit trail, a table with one row per metadata field, and a footer showing the brand and the main document's title.
Keep the cover template stable across template versions. The template_ver field tells the future auditor which version of the main document was signed; the cover template itself is a long-lived bound surface. If you change the cover layout, bump a separate COVER_TEMPLATE_VERSION constant and stamp it onto the cover too — that's how the next records query knows which schema the cover row follows.
The merge call places the cover at index 0, which means it becomes page 1 of the merged PDF. Anyone opening the final file sees the audit metadata before the document itself. That's deliberate: the cover is what a non-lawyer reviewer reads first, and "who signed what when" is the question that dominates audit reviews.
The cost math
Audit-trail PDFs scale with signature events, which scale with how many compliance-touched flows your business runs. At 20,000 signatures per year across a multi-business portfolio — NDAs, MSAs, DPAs, inspection sign-offs, onboarding affirmations — here's how the substrate matters:
- Manual: assemble in Word + scan back in: this is what most operations actually do today. Ops team prepares the document, the signer prints+scans+emails. Audit metadata lives in the email metadata (if the auditor is willing to dig) or doesn't exist at all. Cost: ~$50,000/yr in ops time + worse audit defensibility.
- DocRaptor / PDFShift / PDFMonkey: $0.05–$0.20 per document. At 20,000 docs and TWO PDF operations per signature (main fill + cover fill, then merge counted as a third for some pricing) the math gets unfriendly fast — $3,000–$12,000/yr. Workable, but priced from a substrate that wasn't designed for edge-deployed apps.
- DocuSign / Dropbox Sign: $25–$45/user/month, which includes an audit trail. The full e-sign workflow if your signers will use it. Excellent product, deeply opinionated, ~$5,000–$15,000/yr for a 10-seat ops team — and you're paying for far more than the cover-page-stamp use case if that's all you actually need.
- PDFops (post-beta): priced from the substrate's unit economics. Three PDFops calls per signature (or two if you parallelize) at per-document pricing that lands materially below incumbents when paid tiers open. Globally distributed by default. During beta: 100 requests per IP per month, free, no signup.
The point isn't that PDFops stamps cover pages faster. It's that the per-document price point of the substrate it runs on is low enough that adding a cover-page stamp to every signature stops being a "nice to have if budget allows" and becomes the default — and the audit team gets defensible records on artifacts they didn't previously have any audit metadata on.
When this pattern doesn't fit
- You need cryptographic non-repudiation, not just records-keeping. A SHA-256 checksum on a Blob-stored PDF is "tamper-evident if you trust your own database"; it's not "signer cannot deny signing." For non-repudiation you need a real e-sign workflow with the signer's certificate or a TSA timestamp — DocuSign / Dropbox Sign / Adobe Sign all do this natively. The cover-page-stamp pattern is upstream of that; many ops teams ship both (cover page for internal records, e-sign for legal binding).
- You need to render the cover page from data, not a template. If the cover varies wildly per document (variable-length log of events, a chart, a long signer-by-signer table) the AcroForm-fill approach gets fragile. Headless Chrome rendering HTML-to-PDF still has a place when the cover is essentially a small web page; just be aware of the cost gap. See the DocRaptor comparison for that side of the tradeoff.
- You're streaming the result back to the browser directly, not storing and emailing a link. Server Actions can return a URL but not a binary stream; for "user clicks submit, browser downloads the PDF" the natural shape is a Route Handler returning
application/pdf. Same three PDFops calls inside; different Next.js primitive on the outside.
Try it
The two endpoints behind this flow are live and work standalone. Pull any AcroForm PDF and call fill-form against it:
curl -X POST https://pdfops.dev/api/fill-form \
-F "pdf=@cover-template.pdf" \
-F 'fields={"signed_by":"alex@example.com","signed_at":"2026-05-20T14:32:11Z","ip":"203.0.113.42","user_agent":"Mozilla/5.0 ...","template_ver":"2026.05.nda-v3"}' \
-o filled-cover.pdf
Then merge the cover with the main document:
curl -X POST https://pdfops.dev/api/merge \
-F "pdf=@filled-cover.pdf" \
-F "pdf=@signed-nda.pdf" \
-o nda-with-audit-trail.pdf
The output is one PDF, cover first. From there it's just wrapping it in a Server Action shaped like the function above. The fill-form and merge reference pages cover the full request schemas and error codes.
If the cover-page-stamp pattern matters for compliance work you're already doing, or you need an endpoint that isn't on the surface yet, drop a note on the waitlist form — the message field is the fastest way to influence what ships next.