Building PaperForge: 5,000+ Document Templates with AI-Generated Forms and Live PDF Preview

March 4, 2026 (4mo ago)

Legal documents shouldn't require a lawyer for simple use cases. A freelancer writing an NDA, a contractor drafting a service agreement, a small business creating an invoice - these are well-understood document types with established templates.

That's why I built paperforge.dev - a platform with 5,000+ professionally-designed document templates. Users pick a template, fill a dynamic form tailored to their role and jurisdiction, see a live A4 PDF preview as they type, and download instantly. Free with a watermark, or $9 for a clean version.

The Data Model: Role x Document x Modifier

The core insight is that legal documents are a niche matrix - a combination of three dimensions:

  1. Role: Freelancer, contractor, landlord, small business owner, consultant, etc.
  2. Document: NDA, service agreement, invoice, lease agreement, employment contract, etc.
  3. Modifier: State-specific (California vs. Texas have different legal requirements)
// Drizzle ORM schema
const roles = pgTable('roles', {
  id: uuid('id').primaryKey().defaultRandom(),
  title: text('title').notNull().unique(),
  industryRisks: text('industry_risks'),
  specificJargon: text('specific_jargon'),
  groundTruth: jsonb('ground_truth'), // Pre-researched regulations
});
 
const documents = pgTable('documents', {
  id: uuid('id').primaryKey().defaultRandom(),
  typeName: text('type_name').notNull().unique(),
  coreFields: jsonb('core_fields').default('[]').notNull(),
  requiredClauses: jsonb('required_clauses'), // Legal clause structure
});
 
const modifiers = pgTable('modifiers', {
  id: uuid('id').primaryKey().defaultRandom(),
  contextName: text('context_name').notNull().unique(),
  statutes: jsonb('statutes'), // State laws and compliance
});
 
const nicheMatrix = pgTable('niche_matrix', {
  id: uuid('id').primaryKey().defaultRandom(),
  roleId: uuid('role_id').references(() => roles.id),
  documentId: uuid('document_id').references(() => documents.id),
  modifierId: uuid('modifier_id').references(() => modifiers.id),
 
  slug: text('slug').notNull().unique(),
  keyword: text('keyword').notNull(),
 
  // AI-generated content
  h1Title: text('h1_title'),
  metaDescription: text('meta_description'),
  whyYouNeedThis: text('why_you_need_this'),
  faqs: jsonb('faqs').default('[]').notNull(),
  formSchema: jsonb('form_schema').default('[]').notNull(),
 
  published: boolean('published').default(false).notNull(),
}, (t) => [
  unique('niche_matrix_unq').on(t.roleId, t.documentId, t.modifierId),
]);

Each row in the niche matrix is a unique template: "NDA for Freelancer in California", "Service Agreement for Contractor in Texas", "Invoice for Consultant". The combinatorial explosion of roles x documents x modifiers is how we get to 5,000+ templates.

Static RAG: Grounding AI with Pre-Researched Data

The biggest risk with AI-generated legal content is hallucination. An LLM might invent a law that doesn't exist or reference the wrong statute. My solution is static RAG - pre-researched ground truth stored in the database and injected into every AI prompt.

The Research Pipeline

Before any AI generation happens, each role, document, and modifier gets researched and populated with verified data:

Roles get ground truth about regulations, liabilities, and industry requirements:

{
  "regulations": [
    { "name": "ADA", "description": "...", "enforcingBody": "DOJ" },
    { "name": "OSHA", "description": "...", "enforcingBody": "DOL" }
  ],
  "commonLiabilities": [
    { "liability": "wrongful termination", "mitigation": "clear documentation" }
  ],
  "contractualPainPoints": ["scope creep", "payment terms"]
}

Documents get required clause structures:

{
  "legalPurpose": "Protect confidential information shared between parties",
  "clauses": [
    { "name": "Definition of Confidential Information", "description": "..." },
    { "name": "Obligations of Receiving Party", "description": "..." },
    { "name": "Term and Termination", "description": "..." }
  ]
}

Modifiers get state-specific statutes:

{
  "contractLaw": [
    { "statute": "Cal. Civ. Code 1549-1701", "description": "California contract formation" },
    { "statute": "Cal. Bus. & Prof. Code 16600", "description": "Non-compete restrictions" }
  ]
}

Injecting RAG Into AI Prompts

When generating form schemas and SEO content, all relevant ground truth is injected into the system prompt:

const ragContext = `
=== VERIFIED ROLE GROUND TRUTH (${roleName}) ===
Regulations: ${regs.map(r => `- ${r.name}: ${r.description}`).join('\n')}
Common Liabilities: ${liabilities.map(l => `- ${l.liability}${l.mitigation}`).join('\n')}
 
=== VERIFIED DOCUMENT STRUCTURE (${docName}) ===
Legal Purpose: ${clauses.legalPurpose}
Required Clauses: ${clauses.clauses.map(c => `- ${c.name}: ${c.description}`).join('\n')}
 
=== VERIFIED STATE LAW (${modifierName}) ===
Statutes: ${statutes.contractLaw.map(s => `- ${s.statute}: ${s.description}`).join('\n')}
`;

The system prompt explicitly instructs the AI: "You MUST reference the verified regulations, clauses, and statutes provided above. DO NOT invent any laws or regulations not in the ground truth."

This doesn't eliminate all hallucination risk, but it dramatically reduces it. The AI has specific statutes and clause structures to reference instead of generating them from training data.

AI-Generated Form Schemas

Each template needs a dynamic form tailored to the specific role/document/modifier combination. I use Vercel AI SDK's generateObject with Zod schemas for deterministic JSON output:

import { generateObject } from 'ai';
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { z } from 'zod';
 
const formFieldSchema = z.object({
  id: z.string(),
  type: z.enum(['text', 'email', 'number', 'date', 'textarea', 'checkbox', 'select', 'signature', 'image']),
  label: z.string(),
  required: z.boolean().optional(),
  placeholder: z.string().optional(),
  helpText: z.string().optional(),
  defaultValue: z.union([z.string(), z.boolean()]).optional(),
  options: z.array(z.object({ label: z.string(), value: z.string() })).optional(),
  group: z.string().optional(),
  prefix: z.string().optional(),
});
 
const { object } = await generateObject({
  model: openrouter('google/gemini-3-flash-preview'),
  schema: z.object({
    h1Title: z.string(),
    metaDescription: z.string(),
    whyYouNeedThis: z.string(),
    faqs: z.array(z.object({ question: z.string(), answer: z.string() })),
    formSchema: z.array(formFieldSchema).min(3),
  }),
  system: `${ragContext}\n\nCRITICAL RULES:\n- Use "select" for finite option sets\n- Use "number" with prefix "$" for monetary amounts\n- Use "signature" for signature fields\n- Group related fields`,
  prompt: `Generate content and form schema for a ${roleName} filling a ${docName}`,
});

Post-processing ensures consistency:

// Auto-detect and add $ prefix to money fields
const MONEY_PATTERN = /price|amount|cost|fee|salary|budget|payment/i;
const ensurePrefix = (fields: FormFieldSchema[]) =>
  fields.map(f =>
    f.type === 'number' && !f.prefix && MONEY_PATTERN.test(f.label)
      ? { ...f, prefix: '$' }
      : f
  );
 
// Combine base document fields + AI-generated fields
const finalSchema = [...ensurePrefix(coreFields), ...ensurePrefix(object.formSchema)];

The base coreFields come from the document type (common fields like "Full Name", "Date", "Address"), while the AI generates role-specific fields ("Hourly Rate" for freelancers, "Property Address" for landlords).

The Dynamic Form Engine

The form engine renders from the JSON schema at runtime:

interface FormFieldSchema {
  id: string;
  type: 'text' | 'email' | 'number' | 'date' | 'textarea'
       | 'checkbox' | 'select' | 'signature' | 'image';
  label: string;
  required?: boolean;
  placeholder?: string;
  group?: string;
  prefix?: string;
  options?: { label: string; value: string }[];
}
 
function FormEngine({ schema, formData, onChange }: FormEngineProps) {
  const groups = groupFields(schema);
 
  return (
    <div className="space-y-8">
      {groups.map((group) => (
        <fieldset key={group.groupName}>
          {group.groupName && (
            <legend className="text-sm font-bold uppercase tracking-widest">
              {group.groupName}
            </legend>
          )}
          {group.fields.map((field) => (
            <FormField key={field.id} field={field} value={formData[field.id]} onChange={onChange} />
          ))}
        </fieldset>
      ))}
    </div>
  );
}

The signature field is the most interesting - it supports both typed mode (rendered in a handwriting font) and drawn mode (HTML Canvas):

In typed mode, the user types their name and it renders in the Caveat handwriting font. In drawn mode, they draw on a canvas and the signature is captured as a base64 data URL. Both modes produce a data URL that gets embedded directly in the PDF.

Live A4 Preview

The preview shows a pixel-accurate A4 page that updates as the user types. The hard part is page breaks - I need to know where content overflows before rendering.

Dual-Layer Architecture

The solution is a hidden measurement layer and a visible render layer:

function PreviewMockup({ schema, formData, clauses }: PreviewProps) {
  const wrapperRef = useRef<HTMLDivElement>(null);
  const measureRef = useRef<HTMLDivElement>(null);
  const [pages, setPages] = useState<number[][]>([]);
 
  useLayoutEffect(() => {
    const pageWidth = wrapperRef.current!.offsetWidth;
    const pageHeight = pageWidth * (297 / 210); // A4 aspect ratio
 
    // Measure each content group in the hidden layer
    const heights = itemRefs.current.map(el => el.offsetHeight);
 
    // Compute page breaks
    let available = pageHeight - headerHeight - footerHeight - padding;
    let currentPage: number[] = [];
    const newPages: number[][] = [];
 
    for (let i = 0; i < heights.length; i++) {
      const needed = heights[i] + (currentPage.length > 0 ? SPACING : 0);
 
      if (currentPage.length > 0 && needed > available) {
        newPages.push(currentPage);
        currentPage = [i];
        available = pageHeight - footerHeight - padding - heights[i];
      } else {
        currentPage.push(i);
        available -= needed;
      }
    }
    if (currentPage.length > 0) newPages.push(currentPage);
    setPages(newPages);
  }, [schema, formData, clauses]);
 
  return (
    <div ref={wrapperRef}>
      {/* Hidden measurement layer */}
      <div ref={measureRef} className="absolute opacity-0 pointer-events-none">
        {renderAllContent(schema, formData)}
      </div>
 
      {/* Visible paginated pages */}
      {pages.map((groupIndices, pageIdx) => (
        <div key={pageIdx} style={{ aspectRatio: '210 / 297' }} className="bg-white border">
          {pageIdx === 0 && <Header documentType={documentType} />}
          {groupIndices.map(i => renderGroup(groups[i], formData))}
          <Footer pageNumber={pageIdx + 1} totalPages={pages.length} />
        </div>
      ))}
    </div>
  );
}

Why useLayoutEffect instead of useEffect? It runs synchronously before the browser paints. This prevents a flash of single-page content before page breaks are calculated. The user never sees unpaginated content.

Why a hidden measurement layer? You can't measure content heights without rendering it. The hidden layer renders all content at full width, measures each group's actual height, then the pagination algorithm distributes groups across pages without orphaning headers.

PDF Generation with React PDF

The actual PDF is generated server-side with @react-pdf/renderer:

import { Document, Page, Text, View, Font, Image } from '@react-pdf/renderer';
 
Font.register({
  family: 'Lora',
  fonts: [
    { src: 'https://cdn.jsdelivr.net/fontsource/fonts/lora@latest/latin-400-normal.ttf' },
    { src: 'https://cdn.jsdelivr.net/fontsource/fonts/lora@latest/latin-700-normal.ttf', fontWeight: 700 },
  ],
});
 
Font.register({
  family: 'Caveat', // Handwriting font for signatures
  fonts: [
    { src: 'https://cdn.jsdelivr.net/fontsource/fonts/caveat@latest/latin-400-normal.ttf' },
  ],
});
 
function PdfDocument({ documentType, formData, schema, watermarked, clauses }) {
  return (
    <Document>
      <Page size="A4" style={styles.page}>
        {watermarked && (
          <Text style={styles.watermark} fixed>paperforge.dev</Text>
        )}
        <View style={styles.header}>
          <Text style={styles.title}>{documentType}</Text>
        </View>
        {groups.map(group => renderGroup(group, formData))}
        {clauses?.map(clause => renderClause(clause))}
        <View style={styles.footer} fixed>
          <Text render={({ pageNumber, totalPages }) =>
            `Page ${pageNumber} of ${totalPages}`
          } />
        </View>
      </Page>
    </Document>
  );
}

Signatures are embedded as <Image> elements from base64 data URLs. The fixed prop on the watermark and footer makes them repeat on every page.

Free vs. Paid: PDF Encryption

Free PDFs are watermarked and locked. Paid PDFs are clean and fully editable. The locking uses PDF encryption with RC4:

import { PDFDocument } from 'pdf-lib';
 
async function lockPdf(pdfBytes: Buffer): Promise<Buffer> {
  const pdf = await PDFDocument.load(pdfBytes);
 
  // Permission bits: allow print + accessibility, block edit + copy
  const RESTRICTED_PERMISSIONS =
    0xfffff000 |  // Reserved bits
    (1 << 2) |    // Print
    (1 << 9) |    // Accessibility extract
    (1 << 11);    // Print high quality
 
  const ownerPassword = randomBytes(32).toString('hex');
  const userPassword = ''; // No password to open
 
  // Encrypt every indirect object with RC4-128
  const encKey = computeEncryptionKey(userPassword, ownerKey, RESTRICTED_PERMISSIONS, fileId);
 
  for (const [ref, obj] of pdf.context.enumerateIndirectObjects()) {
    encryptObject(obj, ref.objectNumber, ref.generationNumber, encKey);
  }
 
  return Buffer.from(await pdf.save());
}

The result: free users can open and print the PDF, but cannot edit or copy text. The watermark is semi-transparent diagonal text across each page. Paying $9 removes both the watermark and the encryption.

Payment Flow: Polar.sh + Redis

The payment flow uses Redis as a session bridge between the form and the webhook:

1. User fills form → clicks "Get Clean PDF"
2. Form data cached in Redis with sessionId (24h TTL)
3. Redirect to Polar.sh checkout
4. After payment → Polar sends webhook with sessionId
5. Webhook retrieves form data from Redis
6. Generate clean PDF → upload to R2
7. Cache download URL in Redis
8. Success page polls /api/download-status → returns URL
// Cache form data before redirect
async function createCheckout(slug: string, email: string, formData: Record<string, unknown>) {
  const sessionId = crypto.randomUUID();
  await redis.set(`session:${sessionId}`, JSON.stringify({ slug, formData, email }), { ex: 86400 });
 
  const session = await polar.checkouts.create({
    products: [PRODUCT_ID],
    metadata: { slug, session_id: sessionId },
    successUrl: `${APP_URL}/templates/${slug}?success=true&session_id=${sessionId}`,
  });
 
  return session.url;
}

The webhook includes idempotency protection - if Polar delivers the event twice, the second invocation finds an existing result and returns early:

const existing = await redis.get(`result:${sessionId}`);
if (existing) return; // Already fulfilled

Rate Limiting

Free PDF generation is rate-limited to 5 requests per minute per IP using Upstash:

const ratelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(5, '1 m'),
  analytics: true,
});
 
const { success } = await ratelimit.limit(ip);
if (!success) {
  return NextResponse.json({ error: 'Too Many Requests' }, { status: 429 });
}

What I Learned

1. Static RAG beats dynamic RAG for legal content. Pre-researching regulations and statutes and storing them in the database is more reliable than letting the AI look things up on the fly. The ground truth is verified once and reused thousands of times.

2. Structured AI output is essential at scale. Using Zod schemas with generateObject ensures every AI-generated form schema is valid JSON with the correct field types. Without this, batch generation of 5,000+ templates would produce too many failures.

3. The hidden measurement layer is a hack that works. Measuring DOM heights before rendering is not elegant, but it produces pixel-accurate page breaks that match the actual PDF output. React PDF doesn't expose layout information, so client-side measurement is the pragmatic solution.

4. Redis as a session bridge simplifies webhook flows. Caching form data in Redis before redirecting to payment, then retrieving it in the webhook, eliminates the need for database-backed checkout sessions. The 24-hour TTL handles cleanup automatically.

Try It

Browse templates at paperforge.dev. Download a free watermarked PDF to test, or pay $9 for a clean version. No account required, ready in under 60 seconds.


Building a document generation system or have questions about the architecture? Find me on X or email [email protected].