Most fintech tools handle your banking data on their servers. I wanted to build something different: a SEPA QR code generator where your IBAN never leaves your device.
Here's what I built, the technical decisions I made, and what I learned.
What is a GiroCode?
A GiroCode (also called SEPA-QR or EPC-QR) is a standardized QR code following the EPC069-12 specification by the European Payments Council. It encodes all data needed for a SEPA bank transfer:
BCD ← Service Tag (always "BCD")
001 ← Version
1 ← UTF-8 encoding
SCT ← SEPA Credit Transfer
COBADEFFXXX ← BIC (optional since 2016)
Max Mustermann GmbH
DE89370400440532013000
EUR49.90 ← Amount (optional)
← Purpose type (empty)
← Creditor reference (empty)
Rechnung 2026-001 ← Payment reference (max 140 chars)
Scanning this with any German banking app (Sparkasse, ING, DKB, N26...) auto-fills the transfer form. No typos, no delays.
Architecture: Why No Backend?
The core design decision: everything runs client-side.
Reasons:
- Financial data (IBAN, amounts) should never transit third-party servers
- Static hosting on Vercel = near-zero operational cost
- No GDPR headaches around data storage
- Faster: no round-trip to a server for QR generation
The stack:
Next.js 14 (App Router, static export)
TypeScript
Tailwind CSS
qrcode → client-side QR generation
pdf-lib → client-side PDF generation
html5-qrcode → camera-based QR scanner
Resend → contact form emails (only server touch)
IBAN Validation: Mod-97 Checksum
Instead of a basic regex, I implemented the actual ISO 13616 Mod-97 algorithm:
export function ibanIsValid(iban: string): boolean {
iban = iban.toUpperCase().replace(/\s+/g, '');
if (iban.length < 15 || iban.length > 34) return false;
// Rearrange: move first 4 chars to end
const rearranged = iban.slice(4) + iban.slice(0, 4);
// Convert letters to numbers: A=10, B=11, ...
const numeric = rearranged.replace(
/[A-Z]/g,
ch => (ch.charCodeAt(0) - 55).toString()
);
// Mod-97 check
let remainder = 0;
for (const digit of numeric) {
remainder = (remainder * 10 + parseInt(digit)) % 97;
}
return remainder === 1;
}
This correctly validates IBANs from all 36 SEPA countries, not just Germany.
EPC Payload Generation
export function buildEPC({
name,
iban,
bic = '',
amount = '',
purpose = '',
}: EPCParams): string {
const amountStr = amount
? 'EUR' + parseFloat(amount).toFixed(2)
: '';
return [
'BCD',
'001',
'1',
'SCT',
bic.trim(),
name.trim().slice(0, 70),
iban.replace(/\s+/g, '').toUpperCase(),
amountStr,
'',
'',
purpose.trim().slice(0, 140),
].join('\n');
}
Key constraints from the spec:
- Recipient name: max 70 characters
- Payment reference: max 140 characters
- Amount format:
EURprefix + decimal with dot (not comma) - BIC: optional since SEPA migration completed in 2016
Client-Side PDF Generation with pdf-lib
This was the most interesting technical challenge. pdf-lib runs entirely in the browser:
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
export async function makePDF(
invoice: InvoiceData,
qrDataUrl: string
): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const page = doc.addPage([595.28, 841.89]); // DIN A4
const font = await doc.embedFont(StandardFonts.Helvetica);
const fontBold = await doc.embedFont(StandardFonts.HelveticaBold);
// Embed QR code (PNG from canvas.toDataURL)
const qrBytes = Uint8Array.from(
atob(qrDataUrl.split(',')[1]),
c => c.charCodeAt(0)
);
const qrImage = await doc.embedPng(qrBytes);
// Draw QR code bottom-right (EPC standard recommends this position)
page.drawImage(qrImage, {
x: 595.28 - 190,
y: 40,
width: 160,
height: 160,
});
// Invoice header
page.drawText('Rechnung', {
x: 50,
y: 800,
size: 24,
font: fontBold,
color: rgb(0.1, 0.1, 0.1),
});
return await doc.save();
}
The QR code gets passed as a PNG data URL from the canvas element, converted to bytes, and embedded directly into the PDF.
Internationalization with Next.js App Router
Supporting DE, EN, FR, ES without any i18n library:
app/
page.tsx → German (default)
en/
page.tsx → English
layout.tsx → <html lang="en">
fr/
page.tsx → French
layout.tsx → <html lang="fr">
es/
page.tsx → Spanish
layout.tsx → <html lang="es">
Each locale gets its own layout with correct lang attribute and hreflang alternates:
export const metadata: Metadata = {
alternates: {
canonical: 'https://www.girocodegenerator.com/en',
languages: {
'x-default': 'https://www.girocodegenerator.com',
'de': 'https://www.girocodegenerator.com',
'en': 'https://www.girocodegenerator.com/en',
'fr': 'https://www.girocodegenerator.com/fr',
'es': 'https://www.girocodegenerator.com/es',
},
},
};
Translations live in lib/translations/en.ts, fr.ts, es.ts — simple typed objects, no runtime overhead.
REST API Endpoint
For developers who want programmatic access:
GET /api/generate?name=Max+Mustermann&iban=DE89370400440532013000&betrag=49.90
Returns:
{
"success": true,
"qr_base64": "data:image/png;base64,...",
"epc_payload": "BCD\n001\n1\nSCT\n...",
"data": {
"name": "Max Mustermann",
"iban": "DE89370400440532013000",
"betrag": "49.90"
}
}
With rate limiting (30 req/min per IP) and input sanitization.
Security Headers
Since this handles financial data perception-wise, I added proper security headers in next.config.ts:
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' },
],
}];
}
Result: Grade A on securityheaders.com.
Performance
Being fully static has benefits:
- Vercel CDN edge delivery
- No server-side rendering latency
- pdf-lib and qrcode only load when needed
- All 122 pages pre-rendered at build time
What I'd Do Differently
- Start with i18n in mind — retrofitting 4 languages after the fact means touching every component
- pdf-lib font embedding — embedding custom fonts in PDFs is surprisingly complex
- CSP without unsafe-inline — Next.js makes strict CSP hard without nonce implementation
Try It
👉 girocodegenerator.com
📦 GitHub: github.com/KalipoETH/girocode-generator
Happy to discuss technical decisions or answer questions in the comments!
Top comments (0)