back

Truncating URLs the right way

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.

How it works

The algorithm has three steps:

  1. Parse the URL with the native URL API. Strip the protocol. Extract host and pathname separately.
  2. Allocate the character budget between domain and path, proportional to their original lengths, minus one character for the / separator.
  3. Truncate each part independently using middle-ellipsis: keep the start, keep the end, replace the middle with ....

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.

The truncation function

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 ....

The URL badge formatter

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.

What it produces

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

Different max lengths

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.

Why 55

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.

Custom text override

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.

Why two-fold

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.