DEV Community

Kaleb
Kaleb

Posted on

Building a privacy-first SEPA QR code generator: Zero backend, client-side PDF, 4 languages

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

Key constraints from the spec:

  • Recipient name: max 70 characters
  • Payment reference: max 140 characters
  • Amount format: EUR prefix + 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();
}
Enter fullscreen mode Exit fullscreen mode

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">
Enter fullscreen mode Exit fullscreen mode

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',
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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' },
    ],
  }];
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Start with i18n in mind — retrofitting 4 languages after the fact means touching every component
  2. pdf-lib font embedding — embedding custom fonts in PDFs is surprisingly complex
  3. 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)