DEV Community

Cover image for Truncating og:title and og:description Without Breaking Emoji, Urdu or Your Markup
Abid Ali
Abid Ali

Posted on

Truncating og:title and og:description Without Breaking Emoji, Urdu or Your Markup

Most link preview advice assumes a person writes every title and description by hand. On a lot of real sites that isn't true. Titles come from a CMS, a product feed, a user profile or a forum thread. Descriptions are cut from the first paragraph of a Markdown body. And the code that does the cutting is usually one line:

const description = post.body.slice(0, 160) + "...";
Enter fullscreen mode Exit fullscreen mode

That line has at least four bugs, and every one of them ends up in a link preview card that someone else sees in a chat. This post walks through them and builds a small, tested set of helpers you can drop into a Next.js (or any Node) project.

Bug 1: slice counts UTF-16 code units, not characters

JavaScript strings are sequences of UTF-16 code units. Most Latin letters are one unit, but emoji outside the Basic Multilingual Plane are two, and many "single" emoji are several code points joined together.

const s = "Ship it πŸ‡΅πŸ‡°";
s.slice(0, 9);  // "Ship it \ud83c"  -> a lone surrogate, rendered as οΏ½
s.slice(0, 10); // "Ship it πŸ‡΅"       -> half a flag, rendered as a letter in a box
Enter fullscreen mode Exit fullscreen mode

The Pakistani flag is two regional indicator symbols, each two code units long. Cut in the wrong place and the preview shows a replacement character or a stray letter. Skin-tone modifiers and ZWJ sequences like πŸ§‘β€πŸ’» break the same way.

The same problem hits scripts with combining marks. Urdu, Arabic and Hindi text often carries diacritics that are separate code points attached to the previous letter. Cutting between them leaves a mark floating on nothing.

What you actually want to count is grapheme clusters: the things a reader perceives as one character. Modern JavaScript has a built-in for that.

Intl.Segmenter: graphemes and words without a dependency

Intl.Segmenter is available in current Node.js versions and in all major browsers. It can split text into graphemes, words or sentences using the platform's Unicode data:

const g = new Intl.Segmenter(undefined, { granularity: "grapheme" });
[...g.segment("Ship it πŸ‡΅πŸ‡°")].length; // 9, not 11
Enter fullscreen mode Exit fullscreen mode

With granularity: "word" it also tells you where words start and end, which matters for the next bug.

Bug 2: cutting in the middle of a word

"Fixing link previews for prod..." reads worse than "Fixing link previews for…". A good clip backs up to the last word boundary that fits, then adds an ellipsis. It should also strip trailing punctuation and spaces so you don't get "previews,…".

There's one edge case: a single very long token, like a URL or a long code identifier. Backing up to a word boundary could leave almost nothing, so fall back to a grapheme cut when the word-based result is too short.

Here is the helper:

// lib/clip.ts
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
const words = new Intl.Segmenter(undefined, { granularity: "word" });

export function countGraphemes(s: string): number {
  let n = 0;
  for (const _ of graphemes.segment(s)) n++;
  return n;
}

export function clip(input: string, max: number, ellipsis = "…"): string {
  const text = input.replace(/\s+/g, " ").trim();
  if (countGraphemes(text) <= max) return text;

  const budget = max - countGraphemes(ellipsis);
  let out = "";
  let used = 0;
  for (const { segment } of words.segment(text)) {
    const n = countGraphemes(segment);
    if (used + n > budget) break;
    out += segment;
    used += n;
  }

  // One very long "word" (a URL, a long identifier): cut by grapheme instead.
  if (used < budget * 0.6) {
    out = Array.from(graphemes.segment(text), (s) => s.segment)
      .slice(0, budget)
      .join("");
  }

  return out.replace(/[\s.,;:!?\-–—]+$/u, "") + ellipsis;
}
Enter fullscreen mode Exit fullscreen mode

Two notes. The budget includes the ellipsis, so clip(text, 70) never returns more than 70 graphemes. And I use the single character … rather than three dots, which saves two characters of a tight budget.

A grapheme count is still not a pixel width. Wide characters (CJK, many emoji) take more room than Latin letters, and every app uses its own font and card width. Treat your limits as a sensible budget, not a guarantee of where the app will cut.

Bug 3: the description still contains Markdown

If your description comes from a Markdown body, the first 160 characters may contain **bold**, [link text](https://...), an image or the start of a code block. Platforms don't render Markdown in a card, so readers see the raw syntax.

A rough plain-text pass is enough for a preview:

// lib/plain-text.ts
export function toPlainText(md: string): string {
  return md
    .replace(/```
{% endraw %}
[\s\S]*?
{% raw %}
```/g, " ")          // fenced code
    .replace(/`([^`]*)`/g, "$1")              // inline code
    .replace(/!\[[^\]]*\]\([^)]*\)/g, " ")    // images
    .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")  // links -> their text
    .replace(/<[^>]+>/g, " ")                 // stray HTML
    .replace(/^\s{0,3}(#{1,6}|>|[-*+]|\d+\.)\s+/gm, "") // headings, quotes, list markers
    .replace(/(\*\*|\*|~~)(?=\S)(.+?)(?<=\S)\1/g, "$2")       // bold, italic, strike
    .replace(/(?<!\w)(__|_)(?=\S)(.+?)(?<=\S)\1(?!\w)/g, "$2") // _italic_, not snake_case
    .replace(/\s+/g, " ")
    .trim();
}
Enter fullscreen mode Exit fullscreen mode

This isn't a Markdown parser, and it doesn't try to be. If you already parse Markdown to an AST (remark, markdown-it), take the text nodes from there instead. But for a card description, this gets rid of the syntax people notice.

Also skip content that makes a bad summary even as plain text: a table of contents, "Updated on..." lines, or a leading image caption. If an author wrote an explicit excerpt, always prefer it.

Bug 4: escaping, and double escaping

If you build the <head> with template strings (a custom server, an email template, a static site script), a title like Tabs vs "Spaces" & other wars breaks the attribute:

<meta property="og:title" content="Tabs vs "Spaces" & other wars">
Enter fullscreen mode Exit fullscreen mode

Escape attribute values when you write HTML by hand:

export function escapeAttr(s: string): string {
  return s
    .replace(/&/g, "&amp;")
    .replace(/"/g, "&quot;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;");
}
Enter fullscreen mode Exit fullscreen mode

React and the Next.js Metadata API escape values for you, so in that setup the opposite bug is more common: the CMS or an RSS import already stored the title as Tabs &amp; Spaces, React escapes it again, and the card literally shows &amp;. If your source data contains entities, decode them once on the way in (a library like entities or he does this properly) and let the framework escape on the way out.

Putting it together in the App Router

// app/posts/[slug]/page.tsx
import type { Metadata } from "next";
import { clip } from "@/lib/clip";
import { toPlainText } from "@/lib/plain-text";
import { getPost } from "@/lib/posts";

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);

  const title = clip(post.title, 70);
  const description = clip(post.excerpt ?? toPlainText(post.body), 160);

  return {
    title,
    description,
    openGraph: { title, description, url: `/posts/${slug}`, type: "article" },
    twitter: { card: "summary_large_image", title, description },
  };
}
Enter fullscreen mode Exit fullscreen mode

The relative url only works if metadataBase is set in your root layout; otherwise set an absolute URL. The 70 and 160 limits are budgets I'm comfortable with, not official numbers. Platforms change how much they show, and many will truncate further on their own. The point is that when the text is cut, it's cut by you, cleanly, and not by slice.

Tests that catch the regressions

These run with the built-in node:test runner, no extra packages:

// clip.test.mjs
import test from "node:test";
import assert from "node:assert/strict";
import { clip, countGraphemes } from "./clip.mjs";

test("leaves short text alone and collapses whitespace", () => {
  assert.equal(clip("  Hello   world ", 20), "Hello world");
});

test("never splits a flag or a ZWJ emoji", () => {
  const out = clip("Launch week πŸ‡΅πŸ‡°πŸ§‘β€πŸ’» starts on Monday", 14);
  assert.ok(out.isWellFormed()); // no lone surrogates
  assert.ok(countGraphemes(out) <= 14);
});

test("cuts at a word boundary", () => {
  assert.equal(clip("Fixing link previews for product pages", 20), "Fixing link…");
});

test("falls back to a grapheme cut for one long token", () => {
  const out = clip("https://example.com/a/very/long/path/without/spaces", 20);
  assert.equal(countGraphemes(out), 20);
});
Enter fullscreen mode Exit fullscreen mode

String.prototype.isWellFormed() is newer than Intl.Segmenter; it's in Node 20 and current browsers. On older runtimes, check for lone surrogates with a regex instead.

Check the result where it matters

Unit tests tell you the strings are clean. They don't tell you how the card reads. After deploying, I paste a page with a long, emoji-heavy or Urdu title into the Open Graph preview on OG Scope to see the title, description and tags a server-side fetch actually gets, before anyone shares the link. Keep in mind that the cards there are estimates built from the public HTML, not screenshots from each app.

The fix for all four bugs fits in under fifty lines. It's worth doing once, because every title your system generates goes through it.


I'm Abid Ali, a freelance full stack developer in Karachi working with TypeScript, Next.js and React. I built OG Scope, a free link preview checker.

Top comments (0)