The Problem
Liam leads marketing at a 20-person consumer brand. They print QR codes on product packaging, event materials, and ads. Each QR code is generated manually in a free online tool, downloaded, and sent to the printer. When a campaign URL changes, they can't update printed QR codes — they've wasted $15K on packaging with dead links. They have no idea which QR codes get scanned, from where, or on what devices. They need dynamic QR codes (URL can change after printing), branded with their logo, and tracked with scan analytics.
Step 1: Build the QR Code Engine
typescript
// src/qr/generator.ts — QR code generation with dynamic links and scan tracking
import QRCode from "qrcode";
import sharp from "sharp";
import { pool } from "../db";
import { Redis } from "ioredis";
import { createHash } from "node:crypto";
const redis = new Redis(process.env.REDIS_URL!);
interface QRCodeConfig {
id: string;
name: string;
type: "static" | "dynamic";
destinationUrl: string;
shortCode: string; // for dynamic QR: resolves to destination
style: {
foreground: string; // hex color
background: string;
cornerRadius: number; // 0-50
logoUrl: string | null;
logoSize: number; // percentage of QR size (10-30)
errorCorrection: "L" | "M" | "Q" | "H";
size: number; // pixels
margin: number;
};
scanCount: number;
createdBy: string;
createdAt: string;
}
// Create a QR code
export async function createQRCode(
name: string,
destinationUrl: string,
options?: {
type?: "static" | "dynamic";
foreground?: string;
background?: string;
logoUrl?: string;
logoSize?: number;
size?: number;
userId?: string;
}
): Promise<{ qrCode: QRCodeConfig; imageBuffer: Buffer; imageUrl: string }> {
const id = `qr-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const type = options?.type || "dynamic";
const shortCode = generateShortCode();
// For dynamic QR, the encoded URL is our redirect endpoint
const encodedUrl = type === "dynamic"
? `${process.env.APP_URL}/q/${shortCode}`
: destinationUrl;
const style: QRCodeConfig["style"] = {
foreground: options?.foreground || "#000000",
background: options?.background || "#FFFFFF",
cornerRadius: 0,
logoUrl: options?.logoUrl || null,
logoSize: options?.logoSize || 20,
errorCorrection: options?.logoUrl ? "H" : "M", // H for logo overlay
size: options?.size || 1024,
margin: 2,
};
// Generate QR code
const imageBuffer = await generateQRImage(encodedUrl, style);
// Store config
const qrCode: QRCodeConfig = {
id, name, type, destinationUrl, shortCode,
style, scanCount: 0,
createdBy: options?.userId || "",
createdAt: new Date().toISOString(),
};
await pool.query(
`INSERT INTO qr_codes (id, name, type, destination_url, short_code, style, created_by, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`,
[id, name, type, destinationUrl, shortCode, JSON.stringify(style), options?.userId]
);
// Cache redirect for dynamic QR
if (type === "dynamic") {
await redis.set(`qr:redirect:${shortCode}`, JSON.stringify({ id, url: destinationUrl }));
}
// Cache generated image
const imageKey = `qr:image:${id}`;
await redis.setex(imageKey, 86400 * 30, imageBuffer);
return {
qrCode,
imageBuffer,
imageUrl: `${process.env.APP_URL}/api/qr/${id}/image`,
};
}
// Generate QR image with custom styling
async function generateQRImage(url: string, style: QRCodeConfig["style"]): Promise<Buffer> {
// Generate base QR code
const qrBuffer = await QRCode.toBuffer(url, {
errorCorrectionLevel: style.errorCorrection,
width: style.size,
margin: style.margin,
color: {
dark: style.foreground,
light: style.background,
},
});
let image = sharp(qrBuffer);
// Overlay logo in center
if (style.logoUrl) {
const logoResponse = await fetch(style.logoUrl);
const logoBuffer = Buffer.from(await logoResponse.arrayBuffer());
const logoPixels = Math.round(style.size * (style.logoSize / 100));
const resizedLogo = await sharp(logoBuffer)
.resize(logoPixels, logoPixels, { fit: "contain", background: { r: 255, g: 255, b: 255, alpha: 1 } })
.toBuffer();
// Add white padding around logo
const padding = 8;
const paddedLogo = await sharp({
create: {
width: logoPixels + padding * 2,
height: logoPixels + padding * 2,
channels: 4,
background: style.background,
},
}).composite([{
input: resizedLogo,
left: padding,
top: padding,
}]).png().toBuffer();
const offset = Math.round((style.size - logoPixels - padding * 2) / 2);
image = image.composite([{
input: paddedLogo,
left: offset,
top: offset,
}]);
}
return image.png().toBuffer();
}
// Resolve dynamic QR code (redirect endpoint)
export async function resolveQR(shortCode: string, scanData: {
ip: string; userAgent: string; referrer: string;
}): Promise<string | null> {
const cached = await redis.get(`qr:redirect:${shortCode}`);
if (!cached) return null;
const { id, url } = JSON.parse(cached);
// Track scan (non-blocking)
trackScan(id, shortCode, scanData).catch(() => {});
return url;
}
// Update destination URL (dynamic QR only)
export async function updateDestination(qrId: string, newUrl: string): Promise<void> {
const { rows: [qr] } = await pool.query(
"SELECT short_code, type FROM qr_codes WHERE id = $1", [qrId]
);
if (!qr || qr.type !== "dynamic") throw new Error("Can only update dynamic QR codes");
await pool.query("UPDATE qr_codes SET destination_url = $2 WHERE id = $1", [qrId, newUrl]);
await redis.set(`qr:redirect:${qr.short_code}`, JSON.stringify({ id: qrId, url: newUrl }));
}
// Track scan analytics
async function trackScan(qrId: string, shortCode: string, data: {
ip: string; userAgent: string; referrer: string;
}): Promise<void> {
const day = new Date().toISOString().slice(0, 10);
await redis.hincrby(`qr:scans:${qrId}:daily`, day, 1);
await redis.incr(`qr:scans:total:${qrId}`);
// Parse device
const device = data.userAgent.includes("Mobile") ? "mobile" : "desktop";
await redis.hincrby(`qr:scans:${qrId}:device`, device, 1);
await pool.query(
`INSERT INTO qr_scans (qr_id, ip_hash, device, user_agent, referrer, scanned_at)
VALUES ($1, $2, $3, $4, $5, NOW())`,
[qrId, createHash("md5").update(data.ip).digest("hex").slice(0, 12),
device, data.userAgent.slice(0, 200), data.referrer]
);
}
// Get scan analytics
export async function getScanAnalytics(qrId: string): Promise<{
totalScans: number;
scansByDay: Record<string, number>;
scansByDevice: Record<string, number>;
uniqueScans: number;
}> {
const [total, daily, devices, unique] = await Promise.all([
redis.get(`qr:scans:total:${qrId}`),
redis.hgetall(`qr:scans:${qrId}:daily`),
redis.hgetall(`qr:scans:${qrId}:device`),
pool.query("SELECT COUNT(DISTINCT ip_hash) as unique_count FROM qr_scans WHERE qr_id = $1", [qrId]),
]);
return {
totalScans: parseInt(total || "0"),
scansByDay: Object.fromEntries(Object.entries(daily).map(([k, v]) => [k, parseInt(v)])),
scansByDevice: Object.fromEntries(Object.entries(devices).map(([k, v]) => [k, parseInt(v)])),
uniqueScans: parseInt(unique.rows[0].unique_count),
};
}
// Batch generate QR codes (for product packaging)
export async function batchGenerate(items: Array<{
name: string; url: string; sku?: string;
}>, style?: Partial<QRCodeConfig["style"]>): Promise<Array<{ name: string; imageBuffer: Buffer }>> {
const results = [];
for (const item of items) {
const { imageBuffer } = await createQRCode(item.name, item.url, {
type: "dynamic",
...style,
});
results.push({ name: item.name, imageBuffer });
}
return results;
}
function generateShortCode(): string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
return Array.from({ length: 8 }, () => chars[Math.floor(Math.random() * chars.length)]).join("");
}
Results
- $15K packaging waste eliminated — dynamic QR codes let them change the destination URL after printing; expired campaign → updated to new landing page in 5 seconds
- Scan analytics reveal campaign performance — "The subway poster got 3,200 scans (82% mobile), the flyer got 45" — budget reallocated to high-performing channels
- Branded QR codes with logo — company logo embedded in center; QR codes look professional on packaging instead of generic black-and-white squares
- Batch generation for product lines — 500 SKU-specific QR codes generated in 2 minutes; each links to the product page and tracks scans independently
- Unique vs total scans — IP hashing distinguishes "100 people scanned" from "1 person scanned 100 times"; accurate reach measurement