Generate Pay Stubs and Payroll Documents with HTML
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} • 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}wheressnis already the last 4 digits. - Use
no-storecache 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_hereGet a key at docapi.co/signup — 10 free PDFs, no credit card required.