<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Zerrin Arslan</title>
    <description>The latest articles on DEV Community by Zerrin Arslan (@fontbox).</description>
    <link>https://dev.to/fontbox</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3985925%2F9ae2704d-e311-4789-831a-b5c5a349a3ac.png</url>
      <title>DEV Community: Zerrin Arslan</title>
      <link>https://dev.to/fontbox</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fontbox"/>
    <language>en</language>
    <item>
      <title>I built a browser extension that identifies fonts inside images. Here's what the recognizer actually does</title>
      <dc:creator>Zerrin Arslan</dc:creator>
      <pubDate>Sun, 11 Oct 2026 14:59:40 +0000</pubDate>
      <link>https://dev.to/fontbox/i-built-a-browser-extension-that-identifies-fonts-inside-images-heres-what-the-recognizer-43gm</link>
      <guid>https://dev.to/fontbox/i-built-a-browser-extension-that-identifies-fonts-inside-images-heres-what-the-recognizer-43gm</guid>
      <description>&lt;p&gt;Every "what font is this?" thread I have ever read ends the same way: someone posts a screenshot, three people guess, nobody is sure. The browser tools that exist (WhatFont and friends) read the CSS of a page, which is great until the lettering is a JPEG, a logo, a poster photo or a screenshot. Then they have nothing to read.&lt;/p&gt;

&lt;p&gt;I run a font site, I had a catalog of a few hundred thousand fonts sitting in a database, and I had a 16 GB consumer GPU at home. I figured a font identifier that looks at pixels would be a weekend. It was a summer. This is what actually worked, what embarrassed me, and the numbers at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version 1: pretrained features and a nearest-neighbour index
&lt;/h2&gt;

&lt;p&gt;The first version was the obvious thing. Render every font in the catalog into a few sample images, push them through an ImageNet EfficientNet-B2, store the embeddings, and at query time embed the user's image and return the nearest neighbours.&lt;/p&gt;

&lt;p&gt;My self-test said 93% top-1. I was thrilled for about a week.&lt;/p&gt;

&lt;p&gt;Then I built a benchmark from 137 real photos with known fonts (signage, book covers, packaging) and ran it. Top-5 accuracy: &lt;strong&gt;0 out of 137&lt;/strong&gt;. Not low. Zero. A hand-cropped, binarised, perfectly clean Montserrat photo returned garbage.&lt;/p&gt;

&lt;p&gt;The self-test was lying because it used the gallery's own renders as queries. Same words, same rendering style. That measures near-duplicate retrieval, not font identity. An ImageNet backbone embeds "what word is this" and "what texture is this" far more strongly than "what are the letterforms". If you test an identifier, test it with words it has never seen and with photos you did not render yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version 2: teach the embedder what a font is
&lt;/h2&gt;

&lt;p&gt;The fix was to fine-tune the backbone for font identity with an ArcFace head, on synthetic data I could generate at any scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;around 35,000 font families as classes, with &lt;strong&gt;family-level labels&lt;/strong&gt; (all weights of a family map to one class), because a photo of Montserrat Bold must not be a different class from Montserrat Regular&lt;/li&gt;
&lt;li&gt;random real words from a 6k-word list, so the model cannot memorise the sample text&lt;/li&gt;
&lt;li&gt;weight sampling along the variable-font axis, plus artificial thickening and thinning&lt;/li&gt;
&lt;li&gt;augmentation that looks like the queries people actually send: perspective, uneven lighting, low resolution, JPEG artefacts, textured backgrounds&lt;/li&gt;
&lt;li&gt;a later round added 12 "effect" renders per family: outline, 3D extrusion, glow, texture fill, emboss, pixelation, occlusion, wide tracking, multi-line layouts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each round warm-started from the previous checkpoint. A pilot on 3,900 fonts took 23 minutes and moved unseen-word top-1 from 1% to 61%. The full run, about 5 hours on the home GPU, landed at roughly &lt;strong&gt;74% top-1 and 86% top-5 on unseen words&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The number I care about most is generalisation to fonts the model never saw during training: about 59% top-1, against 65% for fonts it did see. Six points. That means adding a font to the index is "render 12 images, embed, append", no retraining. The index today holds 262,000 fonts and about 2.5 million render embeddings, and it is a plain tensor on CPU, not a vector database. Scoring is max(family prototype, best individual render). Median query latency is around half a second on 8 CPU threads; the GPU is shared with other jobs, so inference lives on CPU.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that broke in embarrassing ways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Tofu.&lt;/strong&gt; Some fonts in a 170k gallery have no Latin glyphs at all (Khmer, Tamil, Arabic-only families). Rendered with Latin sample words, they produce rows of identical .notdef boxes. Those boxes became magnets: anything blocky matched them at 99%. I ended up scanning every gallery entry for renders made of near-identical connected components and blocking 453 of them. The lesson underneath: look at your gallery images, not just your metrics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Weight blindness.&lt;/strong&gt; Before family-level labels, a bold photo of a font with a regular-only class simply did not match. Obvious in hindsight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The smart crop that was not.&lt;/strong&gt; I wrote an automatic text-region cropper. On a ground-truth set it improved exactly 0 of 18 queries, but it pushed scores into the 95-98 range and doubled latency. Tight crops make everything look confident. It shipped turned off, and "score went up" is no longer accepted as evidence for anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Calibrating on 12 samples.&lt;/strong&gt; I set a quality gate using a dozen hand-picked images. On 1,152 real queries it rejected 27% of legitimate uploads. Thresholds get calibrated on the full log now, nothing else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dark backgrounds.&lt;/strong&gt; White text on a dark poster embeds differently from black on white. Inverting the image when its mean brightness is below 110 was a ten-line change and gave +2 correct on the 137-image benchmark. Cheapest win in the whole project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Training on marketplace mockups.&lt;/strong&gt; I tried adding designer preview images from a font marketplace as training data. The model learned the layout template of each designer instead of the typeface, and matched mockups to mockups. Preview images are search data, not training data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest confidence
&lt;/h2&gt;

&lt;p&gt;Cosine similarity is not confidence. A clipart image with no text at all can score 96%. So the ranking you see comes from vote aggregation across image variants (full image plus OCR line crops), not raw score, and the UI shows a "not sure, closest look-alikes" state when the top candidates cluster together instead of printing a fake 99%.&lt;/p&gt;

&lt;p&gt;It is currently too cautious. In the comparison below it put the correct font at #1 on 9 of 12 images and still said "not sure" on 7 of them. That calibration is the next job, and it will be done on the real query log, see above.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 12-image test
&lt;/h2&gt;

&lt;p&gt;I rendered 12 known Google Fonts the way they show up in the wild: clean, textured, rotated, perspective-warped, blurred, low contrast, small JPEG, dark background. Then I uploaded the same 12 files to every free identifier I could reach through a browser. Score is the rank of the true family in the top 5.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Identifier&lt;/th&gt;
&lt;th&gt;Top-1&lt;/th&gt;
&lt;th&gt;Top-5&lt;/th&gt;
&lt;th&gt;Note&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FontBoxDL (mine)&lt;/td&gt;
&lt;td&gt;9 / 12&lt;/td&gt;
&lt;td&gt;12 / 12&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open-source identifier model (ResNet18, ~1k families)&lt;/td&gt;
&lt;td&gt;7 / 12&lt;/td&gt;
&lt;td&gt;9 / 12&lt;/td&gt;
&lt;td&gt;strong on photos, lost the clean serif and marker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WhatFontIs&lt;/td&gt;
&lt;td&gt;6 / 12&lt;/td&gt;
&lt;td&gt;10 / 12&lt;/td&gt;
&lt;td&gt;OCR letter step; collapses on small dark JPEGs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Font Squirrel Matcherator&lt;/td&gt;
&lt;td&gt;5 / 6&lt;/td&gt;
&lt;td&gt;5 / 6&lt;/td&gt;
&lt;td&gt;blocked after ~15 uploads, only 6 tested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WhatTheFont&lt;/td&gt;
&lt;td&gt;0 / 12&lt;/td&gt;
&lt;td&gt;0 / 12&lt;/td&gt;
&lt;td&gt;commercial-only catalog, so it structurally cannot name a free font; its paid look-alikes were reasonable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It is my test, on my images, so read it as "the approach works", not as a league table. The honest weak spot: script fonts. Lobster on a textured, rotated background came back at #4 behind three look-alikes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The extension
&lt;/h2&gt;

&lt;p&gt;The browser extension is the thin part. Manifest V3, no host permissions. Right-click an image, pick "Identify font in this image", and the extension sends only the image URL to my server, which fetches the image itself (with the usual SSRF guards) and returns the matches. Nothing is read from a page until you click. Because people kept asking, it also grew a hover inspector that reports the font that actually rendered an element (from the browser's font-face set, not the first name in the CSS stack) and a "fonts on this page" list.&lt;/p&gt;

&lt;p&gt;Porting it to Firefox was mostly manifest work: &lt;code&gt;background.scripts&lt;/code&gt; instead of a service worker, a &lt;code&gt;browser_specific_settings.gecko&lt;/code&gt; block, and the data-collection declaration AMO now requires.&lt;/p&gt;

&lt;p&gt;Links, if you want to poke at it: the &lt;a href="https://fontboxdl.com/tools/font-finder" rel="noopener noreferrer"&gt;web tool with cropping&lt;/a&gt;, the &lt;a href="https://fontboxdl.com/go/ext?src=devto" rel="noopener noreferrer"&gt;Chrome extension&lt;/a&gt;, the &lt;a href="https://fontboxdl.com/firefox-extension" rel="noopener noreferrer"&gt;Firefox add-on page&lt;/a&gt;, and the longer, less technical write-up with five worked examples: &lt;a href="https://fontboxdl.com/blog/how-to-identify-font-from-image" rel="noopener noreferrer"&gt;How to identify a font from an image&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What kinds of images stump the identifiers you have tried? Hand-lettering, neon, chrome effects, tiny screenshots? Misses are the only training data that matters at this point, so I would genuinely like to see them.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>machinelearning</category>
      <category>python</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I generated a pixel font in pure Python with fonttools (no font editor)</title>
      <dc:creator>Zerrin Arslan</dc:creator>
      <pubDate>Thu, 25 Jun 2026 19:32:28 +0000</pubDate>
      <link>https://dev.to/fontbox/i-generated-a-pixel-font-in-pure-python-with-fonttools-no-font-editor-17hp</link>
      <guid>https://dev.to/fontbox/i-generated-a-pixel-font-in-pure-python-with-fonttools-no-font-editor-17hp</guid>
      <description>&lt;p&gt;I needed a font last week — not to &lt;em&gt;use&lt;/em&gt; one, to &lt;em&gt;ship&lt;/em&gt; one. Long story short, the cleanest way to get listed on the big font directories is to publish your own typeface, and I'm not a type designer. No FontForge, no Glyphs, no years of Bézier practice. So I did the programmer thing: I generated one in pure Python with &lt;a href="https://github.com/fonttools/fonttools" rel="noopener noreferrer"&gt;fonttools&lt;/a&gt;. No GUI, no GPU, a few seconds of CPU. Here's how.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick: a pixel font is just rectangles
&lt;/h2&gt;

&lt;p&gt;A pixel/bitmap typeface is the easiest kind to generate by code, because every glyph is a grid of filled cells. Define each letter as a 5×7 bitmap, draw a square for every "on" cell, and you have an outline. No curves to hand-tune.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;G&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;01110&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;11111&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;B&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;11110&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;11110&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;11110&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="c1"&gt;# ... A–Z, 0–9, punctuation
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Drawing a glyph with a pen
&lt;/h2&gt;

&lt;p&gt;fonttools gives you a &lt;code&gt;TTGlyphPen&lt;/code&gt;. For each filled cell, draw a square contour:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fontTools.pens.ttGlyphPen&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TTGlyphPen&lt;/span&gt;

&lt;span class="n"&gt;PX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;  &lt;span class="c1"&gt;# cell size in font units
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;make_glyph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;pen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TTGlyphPen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;PX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;ry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;PX&lt;/span&gt;  &lt;span class="c1"&gt;# flip: row 0 is top
&lt;/span&gt;                &lt;span class="n"&gt;pen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;moveTo&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="n"&gt;pen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lineTo&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;PX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                &lt;span class="n"&gt;pen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lineTo&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;PX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;PX&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="n"&gt;pen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lineTo&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;PX&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                &lt;span class="n"&gt;pen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;closePath&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;glyph&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Assembling the font with FontBuilder
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;FontBuilder&lt;/code&gt; wires the glyphs, character map, metrics and metadata into a real &lt;code&gt;.ttf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fontTools.fontBuilder&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FontBuilder&lt;/span&gt;

&lt;span class="n"&gt;fb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FontBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isTTF&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# 1000 units per em
&lt;/span&gt;&lt;span class="n"&gt;fb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setupGlyphOrder&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.notdef&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;space&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;fb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setupCharacterMap&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="nf"&gt;ord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;space&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;fb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setupGlyf&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;space&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;TTGlyphPen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;glyph&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
              &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;make_glyph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;()}})&lt;/span&gt;
&lt;span class="n"&gt;fb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setupHorizontalMetrics&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;820&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;font&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getGlyphOrder&lt;/span&gt;&lt;span class="p"&gt;()})&lt;/span&gt;
&lt;span class="n"&gt;fb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setupHorizontalHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ascent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;descent&lt;/span&gt;&lt;span class="o"&gt;=-&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;fb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setupNameTable&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;familyName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Boxel&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;styleName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Regular&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...})&lt;/span&gt;
&lt;span class="n"&gt;fb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setupOS2&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;fb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setupPost&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;fb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Boxel-Regular.ttf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a working, installable font.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things that bit me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lowercase.&lt;/strong&gt; I only defined A–Z, so typing "hello" rendered as tofu (□□□□□). Fix for a caps-only design: map &lt;code&gt;a-z&lt;/code&gt; to the &lt;strong&gt;same glyph&lt;/strong&gt; as &lt;code&gt;A-Z&lt;/code&gt; in the cmap, so lowercase shows the uppercase shape instead of blanks:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="n"&gt;cmap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;ord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Z&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
          &lt;span class="n"&gt;cmap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;ord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;())]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The viewBox flip.&lt;/strong&gt; Font Y goes up, your bitmap rows go down. &lt;code&gt;(6 - ry)&lt;/code&gt; flips it. Forget this and your letters are upside down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name table completeness.&lt;/strong&gt; Some validators want license URL (name ID 14) alongside the license text (ID 13). Cheap to add, saves a rejection.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Verify it before you trust it
&lt;/h2&gt;

&lt;p&gt;Don't eyeball it — render every character:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PIL&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ImageFont&lt;/span&gt;
&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ImageFont&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;truetype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Boxel-Regular.ttf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# draw "HELLO hello 12345 Test!" and check: any boxes = missing glyph
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a cell comes out blank, the glyph or cmap entry is missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;I made three styles this way — square, dot-matrix, and stencil — by swapping the cell-drawing function (square vs circle vs notched). It's a family called &lt;strong&gt;&lt;a href="https://fontboxdl.com/fonts/boxel" rel="noopener noreferrer"&gt;Boxel&lt;/a&gt;&lt;/strong&gt;, free for personal use. The whole thing is a single script; no design tool ever opened.&lt;/p&gt;

&lt;p&gt;fonttools is one of those libraries that turns "I can't do that" into "oh, it's 80 lines." If you've ever wanted a custom display font and bounced off the editors, generating a pixel one in code is a fun afternoon. (And if you just want fonts to download, that's literally &lt;a href="https://fontboxdl.com/" rel="noopener noreferrer"&gt;what I build&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;What would you generate — a bitmap font, an icon font, something weirder?&lt;/p&gt;

</description>
      <category>python</category>
      <category>fonts</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Render any font to a crisp SVG path in the browser with opentype.js</title>
      <dc:creator>Zerrin Arslan</dc:creator>
      <pubDate>Tue, 23 Jun 2026 19:54:49 +0000</pubDate>
      <link>https://dev.to/fontbox/render-any-font-to-a-crisp-svg-path-in-the-browser-with-opentypejs-24j4</link>
      <guid>https://dev.to/fontbox/render-any-font-to-a-crisp-svg-path-in-the-browser-with-opentypejs-24j4</guid>
      <description>&lt;p&gt;A designer asked me for a logo word as an SVG path — not a PNG, not "the font file," an actual &lt;code&gt;&amp;lt;path&amp;gt;&lt;/code&gt; they could drop into Figma and a cutting machine. My first instinct was to screenshot it and trace it. Don't do that. If you have the font file, the outlines are already vectors — you just have to ask for them. Here's how I do it client-side with &lt;a href="https://github.com/opentypejs/opentype.js" rel="noopener noreferrer"&gt;opentype.js&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea
&lt;/h2&gt;

&lt;p&gt;A font glyph &lt;em&gt;is&lt;/em&gt; a set of Bézier curves. opentype.js parses a &lt;code&gt;.ttf&lt;/code&gt;/&lt;code&gt;.otf&lt;/code&gt; and hands you those curves as an SVG path string. No tracing, no quality loss, no server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Load the font and get a path
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;opentype&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;opentype.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;font&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;opentype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/fonts/MyFont.ttf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// getPath(text, x, y, fontSize) → a Path object&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;font&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Hello&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// .toSVG() gives you the &amp;lt;path d="..."/&amp;gt; markup&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pathMarkup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toSVG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 2 = decimal precision&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;pathMarkup&lt;/code&gt; is a real vector outline of your text in that font. Wrap it in an &lt;code&gt;&amp;lt;svg&amp;gt;&lt;/code&gt; and you're done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Size the viewBox correctly
&lt;/h2&gt;

&lt;p&gt;The gotcha is the canvas/viewBox. &lt;code&gt;getPath&lt;/code&gt; draws from a baseline, so you need the bounding box to avoid clipping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoundingBox&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// {x1, y1, x2, y2}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x2&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;bb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;pad&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y2&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;bb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;pad&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;svg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;svg xmlns="http://www.w3.org/2000/svg" viewBox="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;bb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;pad&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;bb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;pad&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;
  &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toSVG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;
&amp;lt;/svg&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the SVG is tightly cropped to the glyphs, whatever the text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Download it from the browser
&lt;/h2&gt;

&lt;p&gt;No backend needed — make a Blob and click a link:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Blob&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;svg&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/svg+xml&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;download&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text.svg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;revokeObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Things that bit me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CORS on the font fetch.&lt;/strong&gt; opentype.load does an XHR/fetch under the hood; the font has to be same-origin or CORS-enabled or it silently fails. Self-host the file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Licensing.&lt;/strong&gt; Converting a glyph to a path is still &lt;em&gt;using&lt;/em&gt; the font. Check the license allows it before you ship outlines of a commercial font.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Right-to-left / complex shaping.&lt;/strong&gt; &lt;code&gt;getPath&lt;/code&gt; does basic layout, not full HarfBuzz shaping. For Latin display text it's perfect; for Arabic/Indic you'll want a shaping engine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Precision vs file size.&lt;/strong&gt; &lt;code&gt;toSVG(2)&lt;/code&gt; is plenty for cutting/printing. Higher precision just bloats the path string.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why client-side is nice here
&lt;/h2&gt;

&lt;p&gt;The whole thing runs in the browser: the user picks a font, types text, and gets an SVG without a single byte hitting a server. That's exactly how I wired up the &lt;a href="https://fontboxdl.com/tools/svg-text-generator" rel="noopener noreferrer"&gt;font-to-SVG tool on FontBoxDL&lt;/a&gt; — handy if you want to see it in action or grab a quick cut file (it pulls from a library of &lt;a href="https://fontboxdl.com/" rel="noopener noreferrer"&gt;60k+ free fonts&lt;/a&gt; you can use as the source).&lt;/p&gt;

&lt;p&gt;opentype.js is one of those libraries that makes a "hard" task almost embarrassingly short. If you're doing anything with fonts in JS — metrics, kerning, glyph inspection — it's worth an afternoon.&lt;/p&gt;

&lt;p&gt;Anyone else using it in production? Curious what you've built.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>svg</category>
      <category>fonts</category>
    </item>
    <item>
      <title>Adding custom fonts to a Next.js app without the layout shift</title>
      <dc:creator>Zerrin Arslan</dc:creator>
      <pubDate>Mon, 15 Jun 2026 17:38:38 +0000</pubDate>
      <link>https://dev.to/fontbox/adding-custom-fonts-to-a-nextjs-app-without-the-layout-shift-5657</link>
      <guid>https://dev.to/fontbox/adding-custom-fonts-to-a-nextjs-app-without-the-layout-shift-5657</guid>
      <description>&lt;p&gt;If you've used Next.js in the last couple of years you've probably reached for &lt;code&gt;next/font&lt;/code&gt;. It's genuinely great — but I still see custom fonts done in ways that tank the Lighthouse score or make the page jump around on load. Here's how I actually wire up fonts in a Next.js app now, and the one thing that quietly fixes most of the layout shift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Google fonts: &lt;code&gt;next/font/google&lt;/code&gt; already self-hosts
&lt;/h2&gt;

&lt;p&gt;First, a thing a lot of people miss: &lt;code&gt;next/font/google&lt;/code&gt; doesn't load anything from Google's servers at runtime. At build time Next downloads the font files and serves them from your own origin. So you get the self-hosting win (no third-party request on the critical path) for free:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Inter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/font/google&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Inter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;subsets&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;latin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;swap&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;RootLayout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;inter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/body&amp;gt;&amp;lt;/&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;subsets: ["latin"]&lt;/code&gt; line matters — it subsets the file so you're not shipping glyphs for languages you don't use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Custom / paid fonts: &lt;code&gt;next/font/local&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;For a font that isn't on Google Fonts — a brand font, a freebie you downloaded, whatever — use &lt;code&gt;next/font/local&lt;/code&gt; and point it at a WOFF2 in your project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;localFont&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/font/local&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;display&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;localFont&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./fonts/MyFont-Regular.woff2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;400&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;normal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./fonts/MyFont-Bold.woff2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="na"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;700&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;normal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;swap&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;variable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--font-display&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then expose it as a CSS variable and use it wherever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;html&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;display&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;variable&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.heading&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--font-display&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your font is a &lt;code&gt;.ttf&lt;/code&gt;/&lt;code&gt;.otf&lt;/code&gt;, convert it to WOFF2 first — smaller and supported everywhere. I usually just drop it into &lt;a href="https://fontboxdl.com/tools/webfont-generator" rel="noopener noreferrer"&gt;FontBoxDL's webfont generator&lt;/a&gt; (browser-based, no install) and grab the WOFF2 back. And if you're still hunting for the font itself, the &lt;a href="https://fontboxdl.com/" rel="noopener noreferrer"&gt;free library&lt;/a&gt; has a pile of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing that actually fixes layout shift
&lt;/h2&gt;

&lt;p&gt;Here's the part people skip. Even with &lt;code&gt;display: swap&lt;/code&gt;, when the real font swaps in it usually has different metrics than the fallback — so text reflows and your CLS spikes. &lt;code&gt;next/font&lt;/code&gt; can fix this automatically if you give it a fallback to match against:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;display&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;localFont&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./fonts/MyFont-Regular.woff2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;swap&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;system-ui&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;arial&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;adjustFontFallback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Arial&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// generates a size-adjusted @font-face for the fallback&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;adjustFontFallback&lt;/code&gt; makes Next emit a fallback &lt;code&gt;@font-face&lt;/code&gt; with &lt;code&gt;size-adjust&lt;/code&gt;/&lt;code&gt;ascent-override&lt;/code&gt; tuned so the fallback occupies almost exactly the same space as your real font. The swap becomes nearly invisible. This one prop has done more for my CLS numbers than anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't import a font inside a component that re-renders.&lt;/strong&gt; Declare it at module scope (top of the file), once. Importing it in the render path defeats the optimization.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;variable&lt;/code&gt; vs &lt;code&gt;className&lt;/code&gt;:&lt;/strong&gt; use &lt;code&gt;className&lt;/code&gt; if it's your single global font; use &lt;code&gt;variable&lt;/code&gt; when you want multiple fonts available as CSS custom properties.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preload is automatic&lt;/strong&gt; for fonts used on the route — you don't need a manual &lt;code&gt;&amp;lt;link rel="preload"&amp;gt;&lt;/code&gt; like you would in a plain HTML setup.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  TL... actually, no TL;DR
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;next/font/google&lt;/code&gt; for Google fonts (it self-hosts), &lt;code&gt;next/font/local&lt;/code&gt; for everything else, ship WOFF2, and set &lt;code&gt;adjustFontFallback&lt;/code&gt; so the swap doesn't shove your layout around. Takes ten minutes and your Core Web Vitals will thank you.&lt;/p&gt;

&lt;p&gt;What's your go-to for custom fonts in Next — &lt;code&gt;next/font/local&lt;/code&gt;, or do you still hand-roll the &lt;code&gt;@font-face&lt;/code&gt;?&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>webdev</category>
      <category>fonts</category>
    </item>
    <item>
      <title>I stopped loading fonts from Google's CDN — here's the self-hosting setup that stuck</title>
      <dc:creator>Zerrin Arslan</dc:creator>
      <pubDate>Mon, 15 Jun 2026 16:42:33 +0000</pubDate>
      <link>https://dev.to/fontbox/i-stopped-loading-fonts-from-googles-cdn-heres-the-self-hosting-setup-that-stuck-2lpm</link>
      <guid>https://dev.to/fontbox/i-stopped-loading-fonts-from-googles-cdn-heres-the-self-hosting-setup-that-stuck-2lpm</guid>
      <description>&lt;p&gt;For years I just dropped the Google Fonts &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; in my &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; and moved on. One line, done, who cares. Right?&lt;/p&gt;

&lt;p&gt;Then I was staring at a Lighthouse report for a landing page that &lt;em&gt;felt&lt;/em&gt; sluggish, and there it was in the waterfall: a render-blocking hop to &lt;code&gt;fonts.googleapis.com&lt;/code&gt;, then a second one to &lt;code&gt;fonts.gstatic.com&lt;/code&gt; for the actual file. Two extra connections — DNS lookup, TLS handshake, the whole dance — before a single character painted. On my laptop? Invisible. On a mid-range Android over flaky mobile data? Very visible.&lt;/p&gt;

&lt;p&gt;So I bit the bullet and self-hosted. Took an afternoon, and I've done it on every project since. Here's the setup I've landed on after a bunch of trial and error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get a WOFF2 (and only WOFF2)
&lt;/h2&gt;

&lt;p&gt;WOFF2 is supported literally everywhere now and it's ~30% smaller than the old WOFF. You don't need the &lt;code&gt;.ttf&lt;/code&gt;, &lt;code&gt;.eot&lt;/code&gt;, &lt;code&gt;.svg&lt;/code&gt; fallback zoo from 2014 anymore. Just ship WOFF2.&lt;/p&gt;

&lt;p&gt;If you've got a &lt;code&gt;.ttf&lt;/code&gt;/&lt;code&gt;.otf&lt;/code&gt;, convert it with fonttools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;fonttools brotli
fonttools ttLib.woff2 compress MyFont.ttf   &lt;span class="c"&gt;# -&amp;gt; MyFont.woff2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Honestly I got tired of doing the CLI thing for one-off fonts, so I ended up building a little browser tool for it — &lt;a href="https://fontboxdl.com/tools/webfont-generator" rel="noopener noreferrer"&gt;FontBoxDL's webfont generator&lt;/a&gt; — drop a TTF/OTF in, get WOFF2 + a &lt;code&gt;@font-face&lt;/code&gt; block back, no install. Use whatever's less friction for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The @font-face block
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@font-face&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"MyFont"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sx"&gt;url("/fonts/myfont.woff2")&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;"woff2"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;font-weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;400&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;font-style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;normal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;font-display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;swap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The line that matters most here is &lt;code&gt;font-display: swap&lt;/code&gt;. It tells the browser "show the fallback text immediately, swap in the real font when it arrives." Without it you get FOIT — invisible text while the font loads — which is a worse experience than a quick font flash, basically every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preload the one font that matters
&lt;/h2&gt;

&lt;p&gt;Drop this in your &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"preload"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/fonts/myfont.woff2"&lt;/span&gt; &lt;span class="na"&gt;as=&lt;/span&gt;&lt;span class="s"&gt;"font"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"font/woff2"&lt;/span&gt; &lt;span class="na"&gt;crossorigin&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things people get wrong here, me included:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't preload everything.&lt;/strong&gt; Preload only the font(s) you actually use above the fold. If you preload six weights, you've just created a different bottleneck.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't forget &lt;code&gt;crossorigin&lt;/code&gt;.&lt;/strong&gt; I once left it off and spent a solid 20 minutes confused about why the font was being downloaded &lt;em&gt;twice&lt;/em&gt;. Fonts are always fetched in CORS mode, so the preload has to match or the browser throws your preloaded copy away.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Subset if you care about bytes
&lt;/h2&gt;

&lt;p&gt;A full font ships thousands of glyphs you will never render. If your site is English/Latin, subsetting can knock off 70%+:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pyftsubset MyFont.ttf &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--unicodes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"U+0000-00FF"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--flavor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;woff2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;myfont.subset.woff2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I don't always bother for a single light body font, but for display/heading fonts it's an easy win.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cache it like it'll never change (because it won't)
&lt;/h2&gt;

&lt;p&gt;Fingerprint the filename, then cache forever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;Cache-Control: public, max-age=31536000, immutable
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Don't skip the fallback stack
&lt;/h2&gt;

&lt;p&gt;Even with &lt;code&gt;swap&lt;/code&gt;, give it a real fallback so the first paint looks intentional and the layout doesn't lurch when the swap happens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"MyFont"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;-apple-system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BlinkMacSystemFont&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;"Segoe UI"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Roboto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;p&gt;That's the whole thing. Convert → &lt;code&gt;@font-face&lt;/code&gt; with &lt;code&gt;swap&lt;/code&gt; → preload the critical one (with &lt;code&gt;crossorigin&lt;/code&gt;!) → cache hard. It's maybe an hour of work and you get a real LCP/CLS improvement plus one fewer third party in your critical path.&lt;/p&gt;

&lt;p&gt;Anyone still on the Google CDN &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; for production — what's keeping you there? Genuinely curious if there's a case I'm missing.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>css</category>
      <category>performance</category>
      <category>fonts</category>
    </item>
  </channel>
</rss>
