LogoQR: I Added Custom Logos to QR Codes and Here’s What Actually Works

The Problem: Plain QR Codes Look Terrible on Marketing Materials

The thing that caught me off guard was how violently designers react when you hand them a standard QR code. Black squares on white. No personality. They’ll drop it into a brochure and immediately ask if they can “make it look like something.” The functional argument — “it scans, that’s what matters” — doesn’t land when the QR code is sitting next to a polished logo on premium print stock.

The quick fix most people reach for is opening Photoshop, pasting a logo in the center, and calling it done. I’ve done this. The problem: if you don’t understand QR error correction levels, you’re gambling with scan reliability every time. QR codes support four error correction levels — L (7% recovery), M (15%), Q (25%), and H (30%). Most generators default to L or M, which means covering even a small center area with a logo can push you past the recovery threshold. The code scans fine on your MacBook camera in good lighting, then fails completely on a warehouse scanner or a cracked Android screen in the sun. The rule of thumb is: if you’re embedding a logo that covers more than ~15% of the code surface, you must generate at H level. Most Photoshop workflows skip this entirely.

# The hard way: manually generating with qrencode and controlling error correction
qrencode -l H -s 10 -o branded_base.png "https://yoururl.com"
# -l H = error correction level High
# -s 10 = module size (pixels per square) — go bigger than you think you need
# Then composite your logo on top at no more than 30% of the total area
convert branded_base.png -gravity center \
  \( logo.png -resize 150x150 \) -composite output.png

That command works, but it’s fragile. You’re eyeballing the logo resize value, there’s no validation that the composite is actually scannable, and if the base URL changes you’re redoing the whole thing manually. Dropping this into a CI pipeline means writing wrapper scripts, handling output paths, testing across devices — it compounds fast. What I actually needed was a library or service that let me declare: here’s the URL, here’s the logo asset, spit out a verified scannable PNG I can use in an automated email or a product page without touching it again.

The deeper issue is repeatability. One-off Photoshop jobs are fine for a single campaign. But if you’re generating QR codes for individual product SKUs, event tickets, or per-user referral links, you need something that runs programmatically, respects error correction constraints automatically, and doesn’t require a designer in the loop for every variant. If you’re building a broader automation stack around this kind of asset generation workflow, the Ultimate Productivity Guide: Automate Your Workflow in 2026 covers several tools that pair well with this — particularly around batch processing and headless browser pipelines. The QR generation piece is just one node in what can be a fully hands-off system.

What LogoQR Actually Is (and What It Isn’t)

The thing that caught me off guard about LogoQR is how much it differs from what I expected. Most QR tools are glorified wrappers — they call a library like qrcode or python-qrcode under the hood, slap a web UI on top, and call it done. LogoQR actually builds on top of the error correction headroom in QR specs (specifically the ~30% damage tolerance at error correction level H) to physically embed logo images into the code matrix without corrupting the data. That’s not a trivial implementation detail.

The SVG output is the biggest practical differentiator for me. If you’ve ever exported a QR code as a PNG and tried to print it on a banner or trade show backdrop, you know exactly what happens — pixelated mess. LogoQR’s SVG output means you can hand the file to a printer running it at 3 feet wide and the edges stay sharp. PNG is still there when you need a raster file for a web page or email, but having both options from the same generation call matters. PNG exports come with configurable resolution so you’re not stuck at 300×300 or whatever the default is.

What it isn’t: a campaign management platform. There’s no built-in link shortener with click tracking, no A/B test runner, no dashboard showing scan counts by geography. If you need that, you’re reaching for something like Bitly or QR Tiger on top of whatever you generate here. LogoQR stays in its lane — it generates the code, handles the visual customization, and outputs a file. I actually respect that more than I expected to. Tools that try to be everything tend to do nothing well.

The honest sweet spot is teams who need to automate branded QR generation, not designers manually tweaking one code for a business card. Think: an e-commerce operation generating unique QR codes for every product SKU, each one embedding the brand logo and using colors pulled from a config file. Or a marketing team that drops LogoQR into a CI pipeline to regenerate campaign codes whenever brand colors update. The API-first approach means you can script this with about 10 lines of code. If you need to generate one QR code for a flyer and you’re comfortable with Canva, this probably isn’t the tool for that job — and that’s fine.

Installation and Initial Setup

The install is genuinely boring, which I mean as a compliment. No native bindings, no node-gyp nightmare, no canvas dependency that breaks on ARM. Just:

npm install logoqr
# or if you're on yarn
yarn add logoqr

I’ve installed this on M2 Mac, Ubuntu 22.04, and inside an Alpine-based Docker container without a single compilation step. That’s rarer than it should be for image-processing adjacent packages. Once it’s in, do a sanity check before you write a single line of custom config — confirm the output is actually scannable:

// verify.mjs — run this before anything else
import { generateQR } from 'logoqr';

const result = await generateQR({
  data: 'https://example.com',
  outputPath: './test-output.png'
});

console.log('Generated at:', result.path);
// Open test-output.png and scan it with your phone.
// If it doesn't scan, something is wrong with your Node environment, not your config.

Scan that output with your phone’s native camera app before moving on. This baseline matters because once you start layering in logos and colors, you want to know whether a scan failure is your fault or an environment issue.

The config object has maybe a dozen keys but you’ll honestly only ever touch five of them in practice:

const config = {
  data: 'https://yoursite.com',
  outputPath: './qr.png',
  errorCorrectionLevel: 'H',   // L / M / Q / H — use H whenever adding a logo
  logoPath: './assets/logo.png', // PNG with transparency works best
  logoSizeRatio: 0.22,          // fraction of QR width — keep this under 0.25
  color: {
    dark: '#1a1a2e',            // the module color (the "dots")
    light: '#ffffff'            // the background — don't go transparent here
  }
};

The thing that caught me off guard was logoSizeRatio. The docs describe it as “the ratio of the logo size to the QR code size” and leave it there. What they don’t say is that QR error correction, even at level H (which can recover from ~30% data damage), is not purely about pixel coverage. The logo sits in the center of the QR code, and the center region contains timing patterns and format information that are disproportionately critical to decode. I ran a batch test pushing logoSizeRatio from 0.20 up to 0.40 in 0.05 increments, scanning each with four different scanner apps. At 0.30 I started getting intermittent failures on lower-quality scanners. At 0.35 I got consistent failures on iOS’s native camera. My working limit is 0.25 as a ceiling, 0.22 as a target. If your logo doesn’t look good at that size, the answer is to make the overall QR code larger, not to push the ratio higher.

One more thing on colors: color.light controls the background modules, and it’s tempting to set it to transparent or a very light tint to match your brand. Resist it. Scanners depend on contrast between dark and light modules — the minimum contrast ratio you want is around 4:1. I’ve had marketing teams ask for light gray backgrounds (#f0f0f0 on white) and the code fails under anything less than perfect lighting. Stick to a solid light color with strong contrast against color.dark, and you’ll avoid support tickets about “the QR code doesn’t work on our printed brochure.”

Generating Your First Custom QR Code

The thing that trips up most people building their first logo QR code isn’t the code itself — it’s not setting error correction high enough and wondering why the scanner fails. When you drop a logo into the center of a QR code, you’re physically destroying data. errorCorrectionLevel: 'H' is what buys you back ~30% redundancy so the scanner can reconstruct what your logo covered. Skipping this and using the default 'M' is the single most common reason custom QR codes scan unreliably in the wild.

Here’s a minimal working example using the qrcode npm package (v1.5.x) combined with sharp for compositing the logo:

import QRCode from 'qrcode';
import sharp from 'sharp';
import fs from 'fs';

async function generateLogoQR(url, logoPath, outputPath) {
  // Always 'H' when embedding a logo — anything lower and
  // you're gambling that the scanner can fill in the gaps
  const qrBuffer = await QRCode.toBuffer(url, {
    errorCorrectionLevel: 'H',
    width: 600,
    margin: 2,
    color: {
      dark: '#1a1a2e',  // brand color, but keep contrast ratio > 4.5:1
      light: '#ffffff',
    },
  });

  const logoSize = 120; // ~20% of QR width — push past 30% and H-level won't save you

  const logo = await sharp(logoPath)
    .resize(logoSize, logoSize, { fit: 'contain', background: { r: 255, g: 255, b: 255, alpha: 1 } })
    .toBuffer();

  await sharp(qrBuffer)
    .composite([{
      input: logo,
      gravity: 'center',  // always center — off-center logos destroy finder patterns
    }])
    .toFile(outputPath);
}

generateLogoQR(
  'https://example.com/campaign/summer24',
  './logo.png',
  './output/qr-with-logo.png'
);

PNG vs SVG isn’t a preference question — it’s dictated by use case. Use SVG when the QR code is going to print: business cards, packaging, signage, trade show banners. SVG stays crisp at any DPI, and printers hate rasterizing a 600px PNG up to 300 DPI equivalent. Use PNG for anything digital: email campaigns, web embeds, Slack messages, social posts. Most email clients handle PNG fine but SVG support in email is a disaster (Outlook renders it as a broken image). The qrcode library gives you QRCode.toString(url, { type: 'svg' }) directly — no need for a conversion step.

// SVG output for print assets
const svgString = await QRCode.toString('https://example.com', {
  type: 'svg',
  errorCorrectionLevel: 'H',
  width: 300,
  color: { dark: '#000000', light: '#ffffff' },
});
fs.writeFileSync('./output/qr-print.svg', svgString);

// PNG for web/email — explicit width matters here for retina displays
await QRCode.toFile('./output/qr-web.png', 'https://example.com', {
  errorCorrectionLevel: 'H',
  width: 800,  // 2x your display size for retina, not 400px at 1x
  type: 'png',
});

Scan testing is where most people get lazy. Testing with your iPhone 14 in a bright room under perfect lighting tells you almost nothing useful. The failure cases come from: a 2020 Android mid-range with a slower camera processor, someone scanning through glare on a laminated card, your deep brand color producing a contrast ratio just under what older QR decoders need, and print reproduction slightly darkening your “light” module color. I test every final QR code with at minimum three apps — the native iOS camera, Google Lens, and ZXing Barcode Scanner (the old-school Android app that behaves more like embedded scanner SDKs). If ZXing gets it in under two seconds in a slightly dim room, you’re probably fine. If it struggles, your logo is too large or your contrast is too low — drop the logo to 18% of QR width and push the dark modules closer to pure black.

Customizing Colors and Shapes Without Breaking Scannability

The thing that surprised me most when I first started embedding brand colors into QR codes was how often codes that looked perfect on my monitor completely failed when I printed them. A marketing team sent me their brand kit with beautiful pastel purples and soft corals. Every single one failed the scanner on a printed flyer under office lighting. The contrast ratio was the culprit — and it’s not something most QR generators warn you about.

The minimum contrast ratio you need between your module color and background is 4:1. That’s not an opinion — it’s the threshold below which common scanner algorithms start guessing and failing. You can check this with any WCAG contrast tool (the same ones used for accessibility). Dark purple on white? Fine. Coral on cream? Not fine. The silent killer is mid-tone grays — something like #888888 on a white background gives you roughly 3.5:1, which looks totally readable to your eyes but fails under anything less than ideal scan conditions like a slightly angled phone camera or dim restaurant lighting.

If you’re generating QR codes programmatically with qrcode in Node.js or Python, both expose the foreground and background as separate color options. Here’s the Node version with qrcode (v1.5+):

const QRCode = require('qrcode');

QRCode.toFile('output.png', 'https://example.com', {
  color: {
    dark: '#1A1A2E',  // deep navy — passes 4:1 against white easily
    light: '#FFFFFF'  // pure white background, don't get clever here
  },
  width: 400,
  margin: 2
}, function(err) {
  if (err) throw err;
  console.log('Generated');
});

Hex values work reliably. What doesn’t work is getting clever with the light color — I’ve seen people set color.light to a brand off-white like #F5F0E8 which cuts your contrast margin significantly. If you want a colored background, measure the contrast ratio explicitly before shipping. Tools like WebAIM’s contrast checker take two hex values and give you the ratio instantly.

Dot shapes are where it gets more interesting and more dangerous. Most libraries and generators give you options like rounded, dots, or classy (some call it square). Here’s the real-world ranking for scannability under compression:

  • Square (default) — most tolerant of JPEG compression artifacts because hard edges stay readable even at 60% quality. Use this for anything that’ll go through an email newsletter or get screenshot-shared.
  • Rounded — holds up well at high resolution, but JPEG compression rounds corners further and can merge adjacent dots at export quality below 80%. Always export rounded QR codes as PNG, not JPEG.
  • Dots — looks great, gets destroyed by compression. The gaps between circles become ambiguous blobs at anything below 90% JPEG quality. If you’re embedding in a PDF that’ll be printed at 300dpi, fine. If it’s going on a website as a JPEG thumbnail, avoid it entirely.

The print test gotcha is the one I now build into every QR code delivery I do for clients. Your monitor is backlit. Print is reflective. Fluorescent office lighting adds a greenish cast and kills contrast on warm pastel colors specifically — soft pinks, peach tones, light yellows. The workflow I use: generate the code, export at actual print size (at least 3cm × 3cm for reliable scanning), print one copy on a standard laser printer, and test it with three different scanner apps (iOS native camera, Android native camera, and a dedicated scanner app like QR & Barcode Scanner). If all three get it in under two seconds, ship it. If any one hesitates, the contrast needs work — not the content, the colors.

Embedding Logos: Formats, Sizing, and the Gotchas Nobody Documents

The format of your logo file matters more than you’d expect, and it’s the first thing that will bite you. JPEG logos bleed into the QR pattern — the lossy compression creates color fringing around the logo edges that visually merges with the nearby QR modules. From a distance it just looks dirty. PNG with transparency is the right choice because the QR renderer can cleanly mask the modules underneath the logo without any color contamination around the edges. If your designer handed you a JPEG brand file, convert it to PNG with a transparent background before you touch LogoQR. Don’t try to skip this step.

SVGs seem like an obvious win here — resolution-independent, clean vector paths — but in practice they’re unreliable as direct input depending on complexity. Anything with gradients, clipping masks, or use element references tends to produce artifacts or render incorrectly. My fix: rasterize to PNG at 300dpi before passing the file in. In ImageMagick that’s one line:

# -density must come BEFORE the input file or it has no effect
convert -density 300 -background none logo.svg logo.png

After doing this I stopped seeing the rendering artifacts entirely. The 300dpi output gives you a large enough raster that even at small QR sizes the logo stays sharp without upscaling blur. If you’re automating this in a pipeline, add this conversion step before the LogoQR call — don’t trust that whatever SVG your marketing team sends will render cleanly.

The logoSizeRatio parameter is where most people either under-use or blow past safe limits. The value represents the fraction of the QR code’s total width that the logo occupies. At 0.2 you get a logo that’s clearly visible while the QR remains reliably scannable — this is the safe default I use for anything going into print or packaging. At 0.25 you’re near the edge of what error correction can recover from, but it still scans reliably in my tests across phone cameras and dedicated scanners. Once you push to 0.3 or above, you’re overwriting too many modules and gambling on the error correction level you set. Even with error correction set to H (the highest, ~30% of codewords recoverable), a 0.3 ratio in a dense QR code fails on some real-world readers. The rule I follow: 0.2 for production, 0.25 as the absolute ceiling if the logo genuinely needs more presence.

The gotcha that took me longest to figure out is the bounding box problem. LogoQR doesn’t analyze the visual content of your logo image — it uses the full pixel dimensions of the file you pass in. If your 500×500 PNG has the actual logo centered in a 200×200 area with 150px of transparent padding on each side, LogoQR treats the whole 500×500 as the logo. That means your effective logoSizeRatio is much larger than you specified, and you’re wiping out QR modules that should be intact. The fix is trivially simple but you have to know it exists: trim the whitespace from your source file before passing it in. With ImageMagick:

# -fuzz 5% handles near-white or near-transparent pixels at the edges
convert logo.png -fuzz 5% -trim +repage logo_trimmed.png

Run identify logo_trimmed.png after trimming and check the dimensions actually shrank. I’ve seen brand files from agencies with 40% of the canvas as dead space — after trimming, the same logoSizeRatio: 0.2 config produced a noticeably larger visible logo with fewer underlying modules destroyed. Always trim, always verify the output dimensions match your expectation before the QR generation step.

Batch Generating QR Codes in a Node Script

The use case that pushed me to write this script: a client needed 500 unique QR codes for physical product tags, each pointing to a URL like https://shop.example.com/products/SKU-1042. Doing this by hand through any web UI would take hours. A Node script does it in under 10 seconds — and the output is deterministic, so regenerating a subset is trivial.

Here’s the core pattern I use. This assumes you have qrcode (the npm package, not qr-code) and optionally a logo-overlay step, but the structure is what matters:

// generate-qr-batch.js — Node 20+
import QRCode from 'qrcode';
import { writeFile, mkdir } from 'fs/promises';
import path from 'path';
import pLimit from 'p-limit';

// Your real data source — could be a CSV parse, a DB query, whatever
const skus = [
  { id: 'SKU-1042', slug: 'red-wool-beanie' },
  { id: 'SKU-1043', slug: 'blue-canvas-tote' },
  // ... 498 more rows
];

const OUTPUT_DIR = './output/qr';
const BASE_URL = 'https://shop.example.com/products';

// p-limit keeps you from spawning 500 concurrent file writes
const limit = pLimit(20);

await mkdir(OUTPUT_DIR, { recursive: true });

const tasks = skus.map(({ id, slug }) =>
  limit(async () => {
    const url = `${BASE_URL}/${slug}`;

    // toBuffer() is synchronous under the hood in the C binding —
    // wrapping it in async doesn't parallelize the CPU work, only the I/O
    const buffer = await QRCode.toBuffer(url, {
      errorCorrectionLevel: 'H', // 'H' gives you ~30% damage tolerance for logo overlays
      width: 512,
      margin: 2,
    });

    // Name the file after the SKU id so you can JOIN back to your data trivially
    const filePath = path.join(OUTPUT_DIR, `${id}.png`);
    await writeFile(filePath, buffer);
    console.log(`✓ ${id}`);
  })
);

await Promise.all(tasks);
console.log(`Done. ${skus.length} QR codes written to ${OUTPUT_DIR}`);

File naming is something I’ve gotten wrong before. If you name files qr-001.png, qr-002.png etc., you’ve just created a mapping problem — now you need a separate lookup table to know which image goes with which product. Name files after the natural key in your data source (SKU-1042.png) and you can match them back with a simple path.basename(file, '.png') anywhere downstream — in your print pipeline, your CMS upload script, or wherever they end up.

The performance gotcha that caught me off guard: qrcode‘s PNG generation calls into a native C binding for the actual pixel rendering. Wrapping it in async/await does not make it parallel — the CPU work is still synchronous and blocks the event loop while it runs. With 500 codes and a concurrency limit of 20, you’re effectively doing 20 synchronous renders back-to-back in each “slot”. That’s fine for hundreds. For thousands, you’ll either want to spawn worker threads via worker_threads or just accept that it’ll take a minute and run it overnight. I’ve done both — for under 2,000 codes, the single-threaded approach with pLimit(20) finishes in under 30 seconds on an M-series Mac, which is fast enough that I never bothered with workers.

One more practical thing: if you’re adding a logo to each QR code (which is the whole point of LogoQR-style output), your pipeline becomes generate buffer → composite with sharp → write final PNG. The sharp composite call is also synchronous-ish but much faster than the QR generation step, so the same concurrency limiter covers both:

import sharp from 'sharp';

// Drop this inside the limit() callback, after QRCode.toBuffer()
const logoBuffer = await readFile('./assets/logo.png'); // read once outside the loop

const finalBuffer = await sharp(buffer)
  .composite([{
    input: logoBuffer,
    gravity: 'center',  // sharp's gravity puts the logo dead-center automatically
  }])
  .png()
  .toBuffer();

await writeFile(filePath, finalBuffer);

Read the logo file outside the loop. I’ve seen people readFile inside the per-item callback, which means 500 disk reads of the same file. Load it once, pass the buffer reference into the closure. Same advice applies to any shared asset — fonts, watermarks, frame overlays.

Integrating LogoQR into a Web App or API Endpoint

Skip the temp files — pipe the buffer directly

The thing that surprised me when I first wired up QR generation in Express was how many tutorials reach for fs.writeFileSync unnecessarily. Every major QR library — qrcode, qr-image, and the canvas-based ones that support logo overlays — can emit a Node.js Buffer or a stream directly. No disk writes, no cleanup logic, no race conditions when two requests fire simultaneously. Here’s a minimal Express route that accepts a URL, validates it, and returns a PNG buffer inline:

import express from 'express';
import QRCode from 'qrcode'; // npm i qrcode — handles logo overlay via canvas option
import { createHash } from 'crypto';
import { URL } from 'url';

const app = express();
const cache = new Map(); // swap for Redis in production

const ALLOWED_DOMAINS = new Set(['example.com', 'app.example.com']);

function validateQRTarget(raw) {
  let parsed;
  try {
    parsed = new URL(raw);
  } catch {
    throw new Error('Invalid URL');
  }
  // Only https, only your own domains — adjust to taste
  if (parsed.protocol !== 'https:') throw new Error('HTTPS only');
  if (!ALLOWED_DOMAINS.has(parsed.hostname)) throw new Error('Domain not allowed');
  return parsed.href; // normalized, no surprises
}

app.get('/qr', async (req, res) => {
  const { url, size = '300', darkColor = '000000' } = req.query;

  let targetUrl;
  try {
    targetUrl = validateQRTarget(url);
  } catch (e) {
    return res.status(400).json({ error: e.message });
  }

  const cacheKey = createHash('sha256')
    .update(`${targetUrl}|${size}|${darkColor}`)
    .digest('hex');

  if (cache.has(cacheKey)) {
    res.set('Content-Type', 'image/png');
    res.set('X-Cache', 'HIT');
    return res.send(cache.get(cacheKey));
  }

  const opts = {
    width: Math.min(parseInt(size, 10), 1000), // hard cap — don't let users request 10000px
    color: {
      dark: `#${darkColor}`,
      light: '#FFFFFF',
    },
  };

  const buffer = await QRCode.toBuffer(targetUrl, opts);
  cache.set(cacheKey, buffer); // in-memory; fine for low traffic
  res.set('Content-Type', 'image/png');
  res.set('Cache-Control', 'public, max-age=86400');
  res.send(buffer);
});

The cache key is a SHA-256 hash of every input that affects the output — URL, size, color. If any parameter changes, you get a new key, new render. If everything matches, you return the cached buffer immediately. For a hosted product with moderate traffic this in-memory Map will carry you surprisingly far, but the moment you have multiple Node processes or a container that restarts frequently, swap it for Redis with a TTL. The pattern stays identical — just replace cache.get/set with redis.get/set and serialize the buffer to base64 for storage.

Base64 inline vs binary stream — pick the right one for your use case

These aren’t interchangeable and the wrong choice costs you real pain. Binary stream (what the route above does) is correct when the client will download the file or an <img> tag fetches it via src. Base64 inline embedding makes sense when you’re generating the QR inside a server-rendered HTML page or inside a PDF generator like Puppeteer where you can’t make outbound requests. Two helpers that cover both paths:

// Binary stream (for download or img src)
// Already shown above — just res.send(buffer)

// Base64 for inline HTML embedding
app.get('/qr.b64', async (req, res) => {
  // ... same validation + cache logic ...
  const buffer = await QRCode.toBuffer(targetUrl, opts);
  const b64 = buffer.toString('base64');
  // Return JSON so your frontend can do:
  // 
  res.json({ image: b64, cacheKey });
});

Base64 inflates the payload by roughly 33% compared to the binary stream, so don’t use it for bulk generation or anywhere you’re caching aggressively — the stored strings eat more memory. For a single QR on a confirmation page it’s perfectly fine. One concrete situation where base64 is the only option: Puppeteer rendering a receipt HTML before converting to PDF. You can’t serve a binary endpoint and have Puppeteer fetch it over localhost reliably in all environments, but you can embed data:image/png;base64,... directly in the template and it always resolves.

The security hole most QR API tutorials ignore completely

If your endpoint accepts an arbitrary URL and generates a QR from it, you’ve accidentally built an open redirect in physical form. Someone can pass https://malware.example.com/payload, get a perfectly valid QR code served from your domain, print it on a flyer, and now your brand is endorsing that URL every time someone scans it. The ALLOWED_DOMAINS allowlist in the route above is the minimum viable fix. But there are two more edge cases worth handling explicitly:

  • Protocol enforcement: javascript: URLs are technically valid to some parsers. Node’s URL class will parse them without throwing. Always check parsed.protocol === 'https:' explicitly before anything else.
  • Private IP ranges: If your generator runs inside a VPC, a user could pass http://10.0.0.1/admin and get a QR pointing at your internal network. Not an SSRF in the traditional sense — the server isn’t fetching the URL — but it’s still a social engineering vector. Reject private ranges with a regex or a library like is-private-ip.
  • URL length: QR codes have a hard data capacity that depends on error correction level. At ECC level M, a QR version 40 code holds 2953 bytes max. If someone passes a 5000-character URL your library will either silently truncate or throw. Cap input length at ~2000 characters before it ever reaches the generator.
// Add this to validateQRTarget before the URL constructor call
if (typeof raw !== 'string' || raw.length > 2000) {
  throw new Error('URL too long or invalid type');
}

I’d also recommend logging rejected validation attempts with the offending input (truncated to 200 chars). You’ll quickly see if someone is probing your endpoint. The first time I shipped a public QR API without this, within 48 hours I had requests with file:///etc/passwd and various javascript: payloads in the URL field. The generator didn’t execute them, but it would’ve happily encoded them into a scannable code and served them from my domain.

3 Things That Surprised Me After Using This in Production

The SVG output genuinely floored me. I assumed embedding a raster logo into a QR code meant the whole thing would be downgraded to a PNG or some hybrid mess. It’s not. The exported SVG has the QR matrix as clean vector paths, and the logo embedded as a proper <image> or <svg> element depending on what you feed it. Scaling to 2000×2000px for print and back down to 64×64 for a favicon-sized use case — same file, zero quality loss. That’s not a given with QR generation libraries. Most of them render to canvas first and export a pixel dump. The fact that this one maintains a real vector structure means you can drop it straight into an Illustrator layout without your designer yelling at you.

Error correction level H was the second one that caught me off guard — specifically how little it costs. I ran a batch comparison generating the same QR at all four levels (L, M, Q, H) and exported to PNG at 512px. The size difference between L and H was under 8KB. The visual density increases, sure, but not in a way that makes the code look illegible. What you actually get for that marginal size cost is meaningful: QR codes on physical materials — packaging, stickers, trade show signage — get scanned at angles, under fluorescent lighting, with cheap phone cameras. Level H corrects up to 30% damage or occlusion. Your logo in the center is exactly that: occlusion. Running H with a centered logo isn’t just aesthetic, it’s the combination that actually scans reliably in the real world. Default to it unless you have a hard size constraint.

// Don't leave error correction to the default
const qr = new LogoQR({
  data: 'https://yourapp.com/activate',
  errorCorrectionLevel: 'H', // not 'M', which is often the default
  logo: './logo.svg',
  logoSizeRatio: 0.25, // keep logo under 30% of QR area or you're fighting yourself
});

await qr.toFile('./output.svg');

The third surprise bit me in an embarrassing way. I was mid-refactor, moving from a staging environment to production URLs, and I generated a whole batch of QR codes for a print run. The library happily encoded https://staging.internal.yourapp.com/promo/xyz into a clean, scannable, visually correct QR code — zero warnings, zero errors. There’s no URL reachability check, no format validation beyond “this is a non-empty string.” It encodes whatever string you hand it. That’s technically correct behavior — QR codes encode strings, not URLs — but it means the responsibility sits entirely with you. I now have a pre-generation step that runs a HEAD request against every URL before encoding:

// Quick pre-flight before burning codes into a print-ready PDF
async function validateUrls(urls) {
  const results = await Promise.allSettled(
    urls.map(url =>
      fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(4000) })
    )
  );
  results.forEach((r, i) => {
    if (r.status === 'rejected' || !r.value.ok) {
      // Hard fail here — don't silently continue
      throw new Error(`URL failed pre-flight: ${urls[i]}`);
    }
  });
}

The staging URL mistake is the kind of thing that doesn’t surface until your QR codes are on 10,000 coffee cups. The library isn’t wrong for not validating — that’s not its job — but the ergonomics make it easy to assume some sanity check is happening behind the scenes. None is. Build your own gate before you hit toFile().

When LogoQR Is the Right Tool (and When to Look Elsewhere)

The honest answer to “should I use LogoQR?” is: it depends almost entirely on whether you’re generating QR codes programmatically at scale, not one-off. If you’re spinning up a Node.js service that stamps branded QR codes onto certificates, menus, packaging mockups, or marketing assets — and you need every single one to look the same — that’s where this library earns its place. The SVG output is the real differentiator here. Most free online QR generators hand you back a low-res PNG that pixelates the moment a designer drops it into a print layout. SVG scales to poster size without degrading, and that matters the second your output leaves a screen.

Automating consistent branding across hundreds or thousands of codes is the actual use case. Say you’re building a SaaS that generates branded QR codes for each of your customers’ product lines — you can parameterize the logo path, foreground color, and dot style, loop over a dataset, and emit clean SVGs for every entry. That workflow in Node looks something like this:

import { LogoQR } from 'logoqr';
import { writeFileSync } from 'fs';

const products = [
  { id: 'p001', url: 'https://example.com/p001', color: '#1a1a2e' },
  { id: 'p002', url: 'https://example.com/p002', color: '#16213e' },
];

for (const product of products) {
  const svg = await LogoQR.generate({
    data: product.url,
    logo: './assets/brand-logo.svg',
    foregroundColor: product.color,
    errorCorrectionLevel: 'H', // required when embedding a logo — minimum 30% module coverage
    format: 'svg',
  });

  writeFileSync(`./output/${product.id}-qr.svg`, svg);
}

Error correction level H is non-negotiable once you start embedding logos. The logo physically obscures QR modules, and at lower correction levels the scanner simply can’t recover that data. Most people learn this the hard way after printing 500 codes and watching a scanner choke on half of them.

Skip LogoQR entirely if your requirements include redirect tracking, click analytics, or the ability to swap the destination URL after the codes are already printed or published. LogoQR generates static codes — the URL is baked into the pattern at generation time. If you print a code and the campaign URL changes six months later, that code is dead. For that use case, look at Bitly’s QR product (short links with analytics dashboard, paid plans start around $8/month) or QR Tiger (dedicated QR management, dynamic codes from ~$7/month). Those platforms give you a stable short URL as the QR payload that redirects to wherever you point it — the physical code never changes.

The stack constraint is also real and worth being upfront about. LogoQR lives in the Node/JavaScript ecosystem. If your backend is Python, you’re better off with the qrcode library plus Pillow for logo compositing — the pattern is well-documented and doesn’t require shelling out to a Node process. For Go, go-qrcode handles generation and you composite the logo manually using the image standard library. Both have their own quirks around logo sizing and error correction, but forcing a Node runtime into a non-JS stack just to use LogoQR is the wrong trade-off. Use the tool that speaks your language natively.


Disclaimer: This article is for informational purposes only. The views and opinions expressed are those of the author(s) and do not necessarily reflect the official policy or position of Sonic Rocket or its affiliates. Always consult with a certified professional before making any financial or technical decisions based on this content.


Eric Woo

Written by Eric Woo

Lead AI Engineer & SaaS Strategist

Eric is a seasoned software architect specializing in LLM orchestration and autonomous agent systems. With over 15 years in Silicon Valley, he now focuses on scaling AI-first applications.

Leave a Comment