Doc API
Back to blog

Generate Pay Stubs and Payroll Documents with HTML

·7 min read

Pay stubs have strict requirements: they must be accurate, legible, and consistent. Employees need them for loan applications, rental agreements, and tax records. Generating them programmatically from payroll data is straightforward with HTML-to-PDF rendering.

This guide covers building a pay stub template, handling deductions and YTD totals, and generating PDFs at scale.

Pay stub structure

A standard pay stub includes:

  • Employee and employer information
  • Pay period and payment date
  • Earnings (regular, overtime, bonuses)
  • Deductions (tax, insurance, retirement)
  • Net pay
  • Year-to-date totals

All of this maps cleanly to an HTML table layout.

TypeScript template

interface PayStub {
  // Employer
  companyName: string
  companyAddress: string
  ein: string  // Employer Identification Number
 
  // Employee
  employeeName: string
  employeeId: string
  employeeAddress: string
  ssn: string  // Last 4 digits only
 
  // Pay period
  payPeriodStart: string
  payPeriodEnd: string
  payDate: string
  payFrequency: string  // e.g. "Bi-Weekly"
 
  // Earnings
  earnings: {
    description: string
    hours?: number
    rate?: number
    amount: number
    ytd: number
  }[]
 
  // Deductions
  deductions: {
    description: string
    amount: number
    ytd: number
  }[]
 
  // Totals
  grossPay: number
  grossPayYtd: number
  totalDeductions: number
  totalDeductionsYtd: number
  netPay: number
  netPayYtd: number
}
 
function fmt(n: number): string {
  return n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
 
export function renderPayStub(stub: PayStub): string {
  const earningsRows = stub.earnings.map(e => `
    <tr>
      <td class="desc">${e.description}</td>
      <td class="num">${e.hours != null ? e.hours.toFixed(2) : ''}</td>
      <td class="num">${e.rate != null ? '$' + fmt(e.rate) : ''}</td>
      <td class="num">$${fmt(e.amount)}</td>
      <td class="num ytd">$${fmt(e.ytd)}</td>
    </tr>
  `).join('')
 
  const deductionRows = stub.deductions.map(d => `
    <tr>
      <td class="desc">${d.description}</td>
      <td class="num"></td>
      <td class="num"></td>
      <td class="num">$${fmt(d.amount)}</td>
      <td class="num ytd">$${fmt(d.ytd)}</td>
    </tr>
  `).join('')
 
  return `<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: Arial, Helvetica, sans-serif;
      font-size: 10px;
      color: #222;
      padding: 20px;
    }
 
    /* Header */
    .header {
      display: flex;
      justify-content: space-between;
      align-items: flex-start;
      border-bottom: 2px solid #1a56db;
      padding-bottom: 12px;
      margin-bottom: 12px;
    }
    .company-name { font-size: 16px; font-weight: 700; color: #1a56db; }
    .company-info { font-size: 9px; color: #555; margin-top: 2px; }
    .paystub-label {
      font-size: 18px;
      font-weight: 700;
      color: #1a56db;
      text-transform: uppercase;
      letter-spacing: 2px;
    }
 
    /* Info grid */
    .info-grid {
      display: grid;
      grid-template-columns: 1fr 1fr 1fr;
      gap: 0;
      margin-bottom: 16px;
    }
    .info-box {
      border: 1px solid #ddd;
      padding: 8px 10px;
    }
    .info-box:not(:first-child) { border-left: none; }
    .info-label {
      font-size: 8px;
      text-transform: uppercase;
      letter-spacing: 0.5px;
      color: #888;
      margin-bottom: 2px;
    }
    .info-value { font-size: 10px; font-weight: 600; }
    .info-value.large { font-size: 13px; }
 
    /* Tables */
    table {
      width: 100%;
      border-collapse: collapse;
      margin-bottom: 12px;
    }
    thead tr { background: #1a56db; color: white; }
    thead th {
      padding: 5px 8px;
      text-align: left;
      font-size: 8px;
      text-transform: uppercase;
      letter-spacing: 0.5px;
    }
    thead th.num { text-align: right; }
    tbody tr:nth-child(even) { background: #f8f9fa; }
    tbody td { padding: 5px 8px; }
    td.num { text-align: right; }
    td.ytd { color: #555; }
    tfoot tr { background: #eef2ff; font-weight: 700; }
    tfoot td { padding: 6px 8px; border-top: 1px solid #1a56db; }
 
    /* Section label */
    .section-label {
      font-size: 9px;
      font-weight: 700;
      text-transform: uppercase;
      letter-spacing: 1px;
      color: #1a56db;
      margin-bottom: 4px;
    }
 
    /* Net pay box */
    .net-pay-section {
      display: flex;
      justify-content: flex-end;
      margin-top: 8px;
    }
    .net-pay-box {
      border: 2px solid #1a56db;
      padding: 12px 20px;
      text-align: right;
      min-width: 180px;
    }
    .net-pay-label { font-size: 9px; text-transform: uppercase; letter-spacing: 1px; color: #555; }
    .net-pay-amount { font-size: 22px; font-weight: 700; color: #1a56db; }
    .net-pay-ytd { font-size: 9px; color: #888; margin-top: 2px; }
 
    /* Footer */
    .footer {
      margin-top: 16px;
      padding-top: 8px;
      border-top: 1px solid #ddd;
      font-size: 8px;
      color: #aaa;
      text-align: center;
    }
  </style>
</head>
<body>
 
  <div class="header">
    <div>
      <div class="company-name">${stub.companyName}</div>
      <div class="company-info">${stub.companyAddress}</div>
      <div class="company-info">EIN: ${stub.ein}</div>
    </div>
    <div class="paystub-label">Pay Stub</div>
  </div>
 
  <div class="info-grid">
    <div class="info-box">
      <div class="info-label">Employee</div>
      <div class="info-value">${stub.employeeName}</div>
      <div class="info-value" style="font-size:9px;font-weight:400;color:#555;">ID: ${stub.employeeId} &nbsp;•&nbsp; SSN: ***-**-${stub.ssn}</div>
      <div class="info-value" style="font-size:9px;font-weight:400;color:#555;">${stub.employeeAddress}</div>
    </div>
    <div class="info-box">
      <div class="info-label">Pay Period</div>
      <div class="info-value">${stub.payPeriodStart} – ${stub.payPeriodEnd}</div>
      <div class="info-value" style="font-size:9px;font-weight:400;color:#555;">${stub.payFrequency}</div>
      <div class="info-label" style="margin-top:6px;">Pay Date</div>
      <div class="info-value">${stub.payDate}</div>
    </div>
    <div class="info-box">
      <div class="info-label">Net Pay</div>
      <div class="info-value large" style="color:#1a56db;">$${fmt(stub.netPay)}</div>
      <div class="info-value" style="font-size:9px;font-weight:400;color:#888;">YTD: $${fmt(stub.netPayYtd)}</div>
    </div>
  </div>
 
  <div class="section-label">Earnings</div>
  <table>
    <thead>
      <tr>
        <th>Description</th>
        <th class="num">Hours</th>
        <th class="num">Rate</th>
        <th class="num">Current</th>
        <th class="num">YTD</th>
      </tr>
    </thead>
    <tbody>${earningsRows}</tbody>
    <tfoot>
      <tr>
        <td colspan="3">Gross Pay</td>
        <td class="num">$${fmt(stub.grossPay)}</td>
        <td class="num ytd">$${fmt(stub.grossPayYtd)}</td>
      </tr>
    </tfoot>
  </table>
 
  <div class="section-label">Deductions</div>
  <table>
    <thead>
      <tr>
        <th>Description</th>
        <th class="num">Hours</th>
        <th class="num">Rate</th>
        <th class="num">Current</th>
        <th class="num">YTD</th>
      </tr>
    </thead>
    <tbody>${deductionRows}</tbody>
    <tfoot>
      <tr>
        <td colspan="3">Total Deductions</td>
        <td class="num">$${fmt(stub.totalDeductions)}</td>
        <td class="num ytd">$${fmt(stub.totalDeductionsYtd)}</td>
      </tr>
    </tfoot>
  </table>
 
  <div class="net-pay-section">
    <div class="net-pay-box">
      <div class="net-pay-label">Net Pay</div>
      <div class="net-pay-amount">$${fmt(stub.netPay)}</div>
      <div class="net-pay-ytd">YTD: $${fmt(stub.netPayYtd)}</div>
    </div>
  </div>
 
  <div class="footer">
    This pay stub is generated for informational purposes. Keep this document for your records.
  </div>
 
</body>
</html>`
}

Generating the PDF

const stub: PayStub = {
  companyName: 'Acme Corp',
  companyAddress: '123 Main St, San Francisco, CA 94105',
  ein: '12-3456789',
  employeeName: 'Jane Smith',
  employeeId: 'EMP-1042',
  employeeAddress: '456 Oak Ave, Oakland, CA 94612',
  ssn: '7890',
  payPeriodStart: 'Feb 17, 2026',
  payPeriodEnd: 'Mar 1, 2026',
  payDate: 'Mar 5, 2026',
  payFrequency: 'Bi-Weekly',
  earnings: [
    { description: 'Regular', hours: 80, rate: 45, amount: 3600, ytd: 14400 },
    { description: 'Overtime', hours: 4, rate: 67.5, amount: 270, ytd: 540 },
  ],
  deductions: [
    { description: 'Federal Income Tax', amount: 612, ytd: 2448 },
    { description: 'State Income Tax (CA)', amount: 183.6, ytd: 734.4 },
    { description: 'Social Security (6.2%)', amount: 238.46, ytd: 953.84 },
    { description: 'Medicare (1.45%)', amount: 55.74, ytd: 222.96 },
    { description: 'Medical Insurance', amount: 180, ytd: 720 },
    { description: '401(k) (5%)', amount: 193.5, ytd: 774 },
  ],
  grossPay: 3870,
  grossPayYtd: 15480,
  totalDeductions: 1463.3,
  totalDeductionsYtd: 5853.2,
  netPay: 2406.7,
  netPayYtd: 9626.8,
}
 
const response = await fetch('https://api.docapi.co/v1/pdf', {
  method: 'POST',
  headers: {
    'x-api-key': process.env.DOCAPI_KEY!,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    html: renderPayStub(stub),
    options: {
      format: 'Letter',  // Pay stubs are commonly Letter size in the US
      printBackground: true,
      margin: { top: '10mm', right: '10mm', bottom: '10mm', left: '10mm' },
    },
  }),
})
 
const pdfBuffer = Buffer.from(await response.arrayBuffer())

Batch payroll run

For multi-employee payroll, generate all pay stubs in parallel using Promise.all with a concurrency limit:

import PQueue from 'p-queue'
 
async function runPayroll(employees: Employee[]) {
  const queue = new PQueue({ concurrency: 5 })
  const results: { employeeId: string; buffer: Buffer }[] = []
 
  await queue.addAll(
    employees.map(employee => async () => {
      const stub = buildPayStub(employee)
      const html = renderPayStub(stub)
 
      const response = await fetch('https://api.docapi.co/v1/pdf', {
        method: 'POST',
        headers: {
          'x-api-key': process.env.DOCAPI_KEY!,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          html,
          options: { format: 'Letter', printBackground: true },
        }),
      })
 
      const buffer = Buffer.from(await response.arrayBuffer())
      results.push({ employeeId: employee.id, buffer })
    })
  )
 
  return results
}

p-queue lets you control how many concurrent PDF requests you make. Five concurrent requests is a safe default for most plans.

Next.js Route Handler

// app/api/payroll/[payrollId]/employees/[employeeId]/pdf/route.ts
import { NextRequest } from 'next/server'
import { getPayStubData } from '@/lib/payroll'
import { renderPayStub } from '@/lib/templates/pay-stub'
 
export const dynamic = 'force-dynamic'
 
export async function GET(
  req: NextRequest,
  { params }: { params: { payrollId: string; employeeId: string } }
) {
  const data = await getPayStubData(params.payrollId, params.employeeId)
  if (!data) return new Response('Not found', { status: 404 })
 
  const response = await fetch('https://api.docapi.co/v1/pdf', {
    method: 'POST',
    headers: {
      'x-api-key': process.env.DOCAPI_KEY!,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      html: renderPayStub(data),
      options: { format: 'Letter', printBackground: true },
    }),
  })
 
  const pdf = await response.arrayBuffer()
 
  return new Response(pdf, {
    headers: {
      'Content-Type': 'application/pdf',
      'Content-Disposition': `attachment; filename="pay-stub-${params.employeeId}.pdf"`,
      'Cache-Control': 'no-store',  // Never cache pay stubs
    },
  })
}

Use attachment instead of inline for pay stubs — employees should download them, not just view them in the browser.

Compliance notes

  • Show last 4 SSN only. Never print a full SSN on a pay stub. The template above uses ***-**-${stub.ssn} where ssn is already the last 4 digits.
  • Use no-store cache headers. Pay stubs contain sensitive personal data. Don't let them be cached by proxies or CDNs.
  • Retain generated PDFs. Store copies in your system (S3, Supabase storage) for employee record requests and audits.

Get started

DOCAPI_KEY=pk_your_key_here

Get a key at docapi.co/signup — 10 free PDFs, no credit card required.

Generate Pay Stubs and Payroll Documents with HTML | Doc API Blog | Doc API