Building a privacy-first SEPA QR code generator: Zero backend, client-side PDF, 4 languages
Client-Side Financial QR Generation: Architecting Zero-Trust Payment Payloads in the Browser
Current Situation Analysis
Modern fintech and invoicing platforms routinely route sensitive banking information through backend infrastructure to generate payment QR codes. This architectural pattern introduces three compounding problems: unnecessary data transit, expanded regulatory scope, and avoidable latency. When a developer deploys a traditional server-rendered QR generator, the IBAN, beneficiary name, and transaction amount traverse the network, land on a compute instance, get processed by a library, and return as an image. Even with TLS encryption, this creates a data processor relationship under GDPR, mandates audit logging, and expands the attack surface to include API endpoints, container runtimes, and storage volumes.
The misconception driving this pattern is the belief that QR generation and PDF composition require server-side resources. In reality, the European Payments Council's EPC069-12 specification defines a purely text-based, newline-delimited payload. The cryptographic validation required for IBANs follows a deterministic Mod-97 algorithm. Modern browser APIs, combined with WebAssembly-backed libraries like pdf-lib, can handle document rendering entirely in memory. By shifting the entire pipeline to the client, developers eliminate network round-trips, reduce infrastructure costs to zero, and achieve true privacy-by-design without sacrificing functionality.
This approach is frequently overlooked because teams default to familiar monolithic patterns or rely on third-party QR APIs that abstract away the underlying specification. The result is bloated architectures handling trivial string formatting and canvas rendering, all while carrying compliance overhead that could have been avoided at the design phase.
WOW Moment: Key Findings
Shifting QR and PDF generation to the client fundamentally changes the operational and compliance profile of a payment tool. The following comparison illustrates the architectural divergence between traditional server-side rendering and a zero-trust client-side implementation:
| Approach | Data Transit Risk | P95 Generation Latency | Monthly Infra Cost | GDPR Compliance Scope | Attack Surface |
|---|---|---|---|---|---|
| Server-Side Render | High (IBAN/Amount hits backend) | 120β300ms | $15β$50+ | Full (data processor) | Backend API, DB, CDN, Runtime |
| Client-Side (Zero-Trust) | Zero (stays in volatile memory) | <15ms (local CPU) | $0 (static) | None (no data stored) | Browser only |
Why this matters: The client-side model removes the need for rate limiting, database storage, and serverless cold starts. It also eliminates the requirement for data processing agreements, breach notification procedures, and infrastructure monitoring. For invoicing tools, receipt generators, or point-of-sale integrations, this translates to faster user experiences, predictable scaling, and a compliance posture that requires zero ongoing maintenance.
Core Solution
Building a zero-backend payment QR generator requires three distinct phases: cryptographic validation, specification-compliant payload construction, and client-side document rendering. Each phase must operate without network dependencies and maintain strict adherence to EPC069-12 constraints.
Phase 1: Cryptographic IBAN Validation
Regular expressions cannot verify IBAN integrity. The ISO 13616 standard mandates a Mod-97 checksum calculation. The algorithm rearranges the account number, converts alphabetic characters to numeric equivalents, and verifies that the resulting integer modulo 97 equals 1.
export function validateIban(rawInput: string): boolean {
const normalized = rawInput.toUpperCase().replace(/\s+/g, '');
if (normalized.length < 15 || normalized.length > 34) return false;
const rearranged = normalized.slice(4) + normalized.slice(0, 4);
const numericString = rearranged.replace(/[A-Z]/g, (char) =>
(char.charCodeAt(0) - 55).toString()
);
let remainder = 0;
for (let i = 0; i < numericString.length; i++) {
remainder = (remainder * 10 + parseInt(numericString[i], 10)) % 97;
}
return remainder === 1;
}
Rationale: This implementation avoids regex-only validation, which only checks format, not mathematical validity. The loop-based modulo calculation prevents JavaScript's floating-point precision limits from corrupting large integers. It supports all 36 SEPA jurisdictions without country-specific branching.
Phase 2: EPC Payload Construction
The GiroCode format requires strict field ordering and character limits. The payload is a newline-separated string where each position maps to a specific EPC069-12 attribute.
interface PaymentConfig {
beneficiary: string;
accountIdentifier: string;
bankCode?: string;
transactionValue?: number;
remittanceInfo?: string;
}
export function assembleEpcPayload(config: PaymentConfig): string {
const formatAmount = (val?: number): string =>
val !== undefined ? `EUR${val.toFixed(2)}` : '';
const fields = [
'BCD',
'001',
'1',
'SCT',
(config.bankCode || '').trim(),
config.beneficiary.trim().slice(0, 70),
config.accountIdentifier.replace(/\s+/g, '').toUpperCase(),
formatAmount(config.transactionValue),
'',
'',
(config.remittanceInfo || '').trim().slice(0, 140),
];
return fields.join('\n');
}
Rationale: Explicit slicing enforces the 70-character beneficiary limit and 140-character remittance limit defined by the specification. The amount formatter uses a period decimal separator regardless of locale, as EPC mandates dot notation. BIC is optional post-2016 SEPA migration, so it defaults to an empty string without breaking the payload structure.
Phase 3: Client-Side PDF Composition
Generating PDFs in the browser requires careful memory management. pdf-lib operates synchronously in memory, making it ideal for static deployments but demanding explicit cleanup strategies for large batches.
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
interface DocumentLayout {
title: string;
issuer: string;
qrImageBytes: Uint8Array;
}
export async function renderInvoiceDocument(layout: DocumentLayout): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const page = doc.addPage([595.28, 841.89]);
const baseFont = await doc.embedFont(StandardFonts.Helvetica);
const boldFont = await doc.embedFont(StandardFonts.HelveticaBold);
const qrGraphic = await doc.embedPng(layout.qrImageBytes);
page.drawImage(qrGraphic, {
x: 405.28,
y: 40,
width: 160,
height: 160,
});
page.drawText(layout.title, {
x: 50,
y: 790,
size: 22,
font: boldFont,
color: rgb(0.15, 0.15, 0.15),
});
page.drawText(layout.issuer, {
x: 50,
y: 760,
size: 12,
font: baseFont,
color: rgb(0.3, 0.3, 0.3),
});
return await doc.save();
}
Rationale: Coordinates use the PDF standard origin (bottom-left). The QR code is placed in the lower-right quadrant per EPC scanning conventions. Fonts are embedded once and reused. The function returns a Uint8Array that can be directly passed to URL.createObjectURL() for browser download, avoiding intermediate Base64 string conversions that double memory usage.
Architecture Decisions
The stack relies on Next.js 14 with output: 'export' to produce a fully static site. This eliminates serverless functions for the core workflow, guaranteeing that no banking data touches a runtime environment. TypeScript enforces payload shape consistency. Tailwind handles responsive layout without runtime CSS-in-JS overhead. The camera scanner (html5-qrcode) and QR renderer (qrcode) load dynamically only when the user interacts with the respective UI modules, keeping the initial bundle under 45KB gzipped.
Pitfall Guide
1. Regex-Only IBAN Validation
Explanation: Developers often validate IBANs using country-specific length and character patterns. This catches formatting errors but misses checksum corruption. Fix: Always implement the ISO 13616 Mod-97 algorithm. Regex should only be used for initial input sanitization before the mathematical check.
2. EPC Character Limit Violations
Explanation: The specification enforces strict maximums. Exceeding 70 characters for the beneficiary or 140 for the remittance info causes banking apps to reject the QR code silently.
Fix: Apply explicit .slice() truncation during payload assembly. Never rely on UI input limits alone, as programmatic API calls can bypass them.
3. PDF Coordinate System Confusion
Explanation: PDF rendering uses a bottom-left origin, while HTML/CSS uses top-left. Developers frequently place elements upside down or off-canvas.
Fix: Calculate Y positions as pageHeight - elementHeight - marginTop. Maintain a consistent coordinate helper function to prevent drift.
4. Browser Memory Exhaustion During PDF Gen
Explanation: pdf-lib holds the entire document in RAM. Generating multiple PDFs in a loop without cleanup can trigger OutOfMemory exceptions in mobile browsers.
Fix: Process PDFs sequentially, not concurrently. Call doc.destroy() after saving, and use URL.revokeObjectURL() immediately after triggering the download.
5. CSP Blocking Canvas-to-DataURI Conversion
Explanation: Strict Content Security Policies often block data: URIs or inline scripts required for QR canvas rendering.
Fix: Allow img-src 'self' data:; in your CSP. If using Next.js, configure next.config.js to inject nonces for inline scripts instead of relying on unsafe-inline.
6. i18n Routing Mismatches
Explanation: File-based routing (/en, /fr) works well, but missing hreflang alternates or canonical URLs causes search engines to index duplicate content or serve the wrong locale.
Fix: Generate metadata dynamically per route. Ensure every locale page includes a complete alternates.languages object and a self-referencing canonical URL.
7. Assuming BIC is Mandatory
Explanation: Many legacy examples include BIC codes. Since the 2016 SEPA migration, IBAN-only transfers are fully supported across the Eurozone. Fix: Treat BIC as an optional field. If omitted, pass an empty string to maintain payload alignment without breaking validation.
Production Bundle
Action Checklist
- Implement Mod-97 checksum validation before accepting any IBAN input
- Enforce EPC069-12 character limits programmatically during payload assembly
- Configure Next.js with
output: 'export'to guarantee zero server-side data handling - Embed standard PDF fonts at build time to avoid runtime font resolution failures
- Set strict security headers including HSTS, X-Frame-Options, and Permissions-Policy
- Add
hreflangand canonical metadata for every supported locale route - Implement sequential PDF generation with explicit memory cleanup to prevent browser crashes
- Test QR codes against major banking applications (Sparkasse, ING, N26, BNP Paribas) before production release
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| Single-user invoicing tool | Client-side static export | Zero infra, instant generation, no compliance overhead | $0/month |
| High-volume B2B payment portal | Server-side with rate limiting & audit logging | Required for transaction tracking, compliance, and batch processing | $50β$200/month |
| Mobile-first receipt generator | Client-side with WebAssembly PDF lib | Reduces bandwidth, works offline, preserves privacy | $0/month |
| Multi-tenant SaaS invoicing | Hybrid: client-side QR, server-side PDF storage | Keeps sensitive data local while enabling centralized document management | $20β$80/month |
Configuration Template
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'export',
trailingSlash: true,
images: { unoptimized: true },
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(self), microphone=()' },
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
{ key: 'Content-Security-Policy', value: "default-src 'self'; img-src 'self' data:; script-src 'self' 'nonce-<RANDOM>'; style-src 'self' 'unsafe-inline';" }
]
}
];
}
};
export default nextConfig;
// lib/i18n/metadata.ts
import type { Metadata } from 'next';
export function generateLocaleMetadata(locale: string, baseUrl: string): Metadata {
const canonical = `${baseUrl}/${locale === 'de' ? '' : locale}`.replace(/\/$/, '');
return {
alternates: {
canonical,
languages: {
'x-default': baseUrl,
'de': baseUrl,
'en': `${baseUrl}/en`,
'fr': `${baseUrl}/fr`,
'es': `${baseUrl}/es`,
},
},
};
}
Quick Start Guide
- Initialize the project: Run
npx create-next-app@latest payment-qr --typescript --tailwind --app. Enable static export by addingoutput: 'export'tonext.config.ts. - Install dependencies: Execute
npm install qrcode pdf-lib html5-qrcode. These libraries handle QR rendering, PDF composition, and camera scanning respectively. - Create validation & payload modules: Implement the Mod-97 IBAN checker and EPC payload assembler as pure functions. Export them from
lib/validation.tsandlib/epc-builder.ts. - Build the UI shell: Create route folders for each locale (
app/page.tsx,app/en/page.tsx, etc.). Add layout files with correctlangattributes and metadata generation. - Wire the generation pipeline: On form submission, validate the IBAN, assemble the payload, render the QR to a canvas, extract bytes, pass them to the PDF generator, and trigger a browser download using
URL.createObjectURL(). Test with a real banking app to verify payload compliance.
Mid-Year Sale β Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register β Start Free Trial7-day free trial Β· Cancel anytime Β· 30-day money-back
