URL badges show up everywhere. Link previews, share cards, browser extensions, CMS dashboards. You need to display a URL in a compact space and keep it recognizable. The naive approach is to chop the whole string at some character limit and slap on an ellipsis. The problem is that often kills the most useful part of the URL.
https://preetsuthar.me/writing/stop-using-useeffect-for-everything
truncated to 55 characters gives you
preetsuthar.me/writing/stop-using-useeffect-for-everyth....
You kept the entire domain, most of the path, and lost the last few
characters that distinguish this page from another. Not great.
The fix is to truncate the domain and path independently. Parse the URL, allocate a character budget to each half, and truncate them separately. The domain stays readable. The path stays readable. The middle of each gets compressed instead of the end of the whole string.
The algorithm has three steps:
URL API. Strip the protocol.
Extract host and pathname separately.
/ separator.
....
The key insight is step 2. If the domain is short and the path is long, the path gets most of the budget. If the domain is long and the path is short, the domain gets most of it. Neither side steals from the other.
function truncateMiddle(str: string, max: number): string {
if (str.length <= max) return str;
const ellipsis = "...";
const available = max - ellipsis.length;
const frontLen = Math.ceil(available / 2);
const backLen = Math.floor(available / 2);
return str.slice(0, frontLen) + ellipsis + str.slice(-backLen);
}
Nothing clever. If the string fits, return it. Otherwise, split the budget
in half, keep that many characters from the front and back, and connect
them with ....
function formatUrlBadge(url: string, maxLength: number = 55): string {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return truncateMiddle(url, maxLength);
}
const domain = parsed.host;
const path = parsed.pathname.replace(/\/$/, "");
// No meaningful path - just truncate the domain
if (!path || path === "") {
return truncateMiddle(domain, maxLength);
}
const separator = "/";
const totalRaw = domain.length + path.length;
const available = maxLength - separator.length;
// Both fit - no truncation needed
if (totalRaw <= maxLength) {
return domain + path;
}
// Allocate budget proportionally
const domainBudget = Math.max(
Math.round((domain.length / totalRaw) * available),
8
);
const pathBudget = Math.max(available - domainBudget, 8);
const truncatedDomain = truncateMiddle(domain, domainBudget);
const truncatedPath = truncateMiddle(path, pathBudget);
return truncatedDomain + truncatedPath;
}
The Math.max(..., 8) floor prevents either side from
collapsing to something unreadable. You always get at least 8 characters
for the domain and 8 for the path.
Short URLs pass through unchanged:
https://example.com
-> example.com
https://x.com/post/123
-> x.com/post/123
Long paths get middle-truncated while the domain stays intact:
https://preetsuthar.me/writing/stop-using-useeffect-for-everything
-> preetsuthar.me/writing/stop-using...fect-for-everything
Long domains get truncated while a short path stays intact:
https://www.thisisaverylongdomainname.example.com/short
-> www.thisis...ample.com/short
Both sides truncate proportionally when everything is long:
https://www.thisisaverylongdomainname.example.com/and/this/is/also/a/very/long/path/to/some/resource
-> www.thisis...ample.com/and/this/is/al...o/some/resource
Deep nested paths keep the start and end visible:
https://github.com/vercel/next.js/blob/canary/packages/next/src/server/app-render/action-handler.ts
-> github.com/vercel/next.js/blob/...der/action-handler.ts
Long opaque IDs compress cleanly:
https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgVE2upms/edit
-> docs.google.com/spreadsheets/d/1Bx...bs74OgVE2upms/edit
The same URL at different budgets. The algorithm scales smoothly:
max=30 preet...r.me/writing...rything
max=40 preetsuthar.me/writing/sto...-everything
max=55 preetsuthar.me/writing/stop-using...fect-for-everything
max=70 preetsuthar.me/writing/stop-using-useeffect-for-everything
At max=30, both sides compress. At max=70,
everything fits and nothing is truncated. The default of 55 keeps badges
within roughly 50–80% screen width on most layouts, which feels right for
inline display.
It's not magic. It's the point where most real-world URLs either fit
completely or lose just enough middle to stay recognizable. Lower and you
start cutting into domain readability on anything beyond
short.io. Higher and you're not saving much space. 55 is a
reasonable default. Adjust it based on where the badge renders.
Sometimes you don't want the URL at all. A link titled "Stop using useEffect for everything" is more useful than any truncated URL. Support a text override and only fall back to the formatted URL when no label is provided. The truncation exists for the common case where all you have is the raw URL.
A single truncation pass treats the URL as flat text. But URLs aren't flat. The domain tells you where you are. The path tells you what you're looking at. Truncating them as one string means one of those signals gets sacrificed for the other. Splitting the budget preserves both.