If you've ever tried to render an EAN-13 barcode with an add-on code in the browser, you know it's more complicated than it looks. This is the story of how I solved it for ToolZip.
The Problem
EAN-13 is the standard barcode on most retail products worldwide. It's 13 digits, with distinctive "guard bars" — the longer bars at the left edge, center, and right edge that scanners use to orient themselves.
Many ISBN books also use an EAN-5 add-on: a separate 5-digit barcode attached to the right side of the main barcode, typically encoding the price.
I was using bwip-js, the most complete barcode library available for JavaScript. It works great — until you need both EAN-13 and EAN-5 together with text labels.
The bwip-js includetext Bug
bwip-js has an option called includetext that renders the human-readable digits below the bars. This is what you see on every product barcode — the numbers printed under the bars.
Here's the problem: when includetext: true is set with an EAN-5 add-on, the digit glyphs overlap with the bars.
My first instinct: set includetext: false and add the text manually.
// ❌ This breaks the guard bars
bwipjs.toSVG({
bcid: 'ean13',
text: '9791190333146 07810',
includetext: false, // Guard bars disappear!
});
This removed the guard bars entirely. The guard bars are part of the includetext rendering path in bwip-js — they don't render without it.
Why Guard Bars Matter
Guard bars aren't decorative. They're functionally required:
Left guard: Tells the scanner where the barcode starts
Center guard: Divides the left and right digit groups
Right guard: Tells the scanner where it ends
A barcode without guard bars won't scan. Setting includetext: false was not an option.
The Solution: Keep includetext, Remove the Glyphs
The key insight: includetext: true renders both the guard bars and the digit glyphs as SVG path elements. I can keep the guard bars by keeping includetext: true, then surgically remove just the digit glyph paths and replace them with <text> SVG elements positioned correctly.
Step 1: Generate with includetext: true
// Server-side (Next.js API route)
const rawSvg = bwipjs.toSVG({
bcid: 'ean13',
text: '9791190333146 07810',
scale: 2,
height: 10,
includetext: true, // ← Must stay true
textxalign: 'center',
guardwhitespace: true,
});
Step 2: Identify the SVG structure
The generated SVG contains:
-
<path stroke=...>elements — the bars (keep these) -
<path fill="#000000">elements — the digit glyphs (remove these) - The LMI
>symbol — a small chevron bwip-js adds after EAN-5 (remove this)
const fills = [...rawSvg.matchAll(
/<path d="([^"]+)" fill="#000000" \/>/g
)];
// fills[0] = all digit glyphs (EAN-13 + EAN-5)
// fills[1] = LMI chevron symbol
Step 3: Calculate text positions from bar positions
The bars themselves tell us where to position the text. I parse the stroke path M/L coordinates to find:
const segs = [...rawSvg.matchAll(/M(\d+) (\d+)L(\d+) (\d+)/g)];
const allX = [...new Set(segs.map(m => +m[1]))].sort((a,b) => a-b);
// Find the gap between EAN-13 bars and EAN-5 bars
let splitIdx = 0;
for (let i = 1; i < allX.length; i++) {
if (allX[i] - allX[i-1] > 20) { splitIdx = i; break; }
}
const ean13Xs = allX.slice(0, splitIdx);
const ean5Xs = allX.slice(splitIdx);
Then find the center guard bar position:
let midGapIdx = 0, maxGap = 0;
for (let i = 1; i < ean13Xs.length; i++) {
const gap = ean13Xs[i] - ean13Xs[i-1];
if (gap > maxGap) { maxGap = gap; midGapIdx = i; }
}
const leftBarEndX = ean13Xs[midGapIdx - 1];
const rightBarStartX = ean13Xs[midGapIdx];
const leftGuardX = ean13Xs[0];
const rightGuardX = ean13Xs[ean13Xs.length - 1];
Step 4: Place text elements
const LEFT_PAD = 26;
const texts = [
// "9" — left of first guard bar
`<text
x="${LEFT_PAD + leftGuardX - 4}"
y="${ean13TextY}"
font-family="monospace"
font-size="18"
text-anchor="end">
${value[0]}
</text>`,
// "791190" — centered between left guard and center guard
`<text
x="${LEFT_PAD + (leftGuardX + leftBarEndX) / 2}"
y="${ean13TextY}"
font-family="monospace"
font-size="18"
text-anchor="middle">
${value.slice(1, 7)}
</text>`,
// "333146" — centered between center guard and right guard
`<text
x="${LEFT_PAD + (rightBarStartX + rightGuardX) / 2}"
y="${ean13TextY}"
font-family="monospace"
font-size="18"
text-anchor="middle">
${value.slice(7, 13)}
</text>`,
// EAN-5 — centered over EAN-5 bars, ABOVE the barcode
`<text
x="${LEFT_PAD + (ean5Xs[0] + ean5Xs[ean5Xs.length-1]) / 2}"
y="${ean5TextY}"
font-family="monospace"
font-size="16"
text-anchor="middle">
${addon}
</text>`,
].join('\n');
The Leading Zero Problem
One more gotcha: EAN-5 add-on codes are always 5 digits. A price of 7,810 Korean Won encodes as 07810.
If you render this as a JavaScript number, 07810 becomes 7810. The leading zero disappears.
Always pass the add-on as a string:
// ❌ Wrong
const addon = 07810; // → 7810
// ✅ Correct
const addon = "07810"; // → "07810"
// In SVG text element:
`${addon}` // renders as "07810" correctly
Why Server-Side?
I generate barcodes in a Next.js API route, not in the browser.
The reason: bwip-js renders differently in Node.js vs browser environments. The browser version uses a canvas-based renderer that produces slightly different SVG output, making the glyph extraction logic unreliable.
Running it server-side gives consistent, predictable output every time:
// app/api/barcode/route.ts
export async function GET(req: NextRequest) {
const bwipjs = (await import("bwip-js")).default;
const rawSvg = bwipjs.toSVG({ ... });
// post-process SVG
return new NextResponse(processedSvg, {
headers: { "Content-Type": "image/svg+xml" },
});
}
The Result
The final barcode renders correctly:
07810 ← EAN-5 above, centered
▌▌▌▌▌▌▌▌▌▌▌▌▌▌ ← EAN-13 bars with guard bars
9 791190 333146 ← 1 + 6 + 6 digit layout
Scannable, standard-compliant, and rendered entirely as SVG — no raster images, infinitely scalable for print.
Try It
The barcode generator is live at toolzip.app/tools/qr-barcode. It supports ISBN, EAN-13, EAN-8, UPC-A, Code 128, Code 39, and ISSN.
If you've run into similar bwip-js quirks or have questions about the SVG post-processing approach, drop a comment — happy to discuss.
Top comments (0)