Why I Skipped rehype-slug for Heading Anchors
When you want to add anchor IDs to blog headings (the kind that lets you jump straight to #section-name), search around and you'll find pretty much one answer: rehype-slug, fully automated. Tons of articles cover it, and the implementation is a single line: .use(rehypeSlug).
But this blog doesn't use it. Instead, I went with the quietly tedious route of using remark-heading-id alone, generating anchors only for headings where I've hand-written {#foo}. It's a minority choice, so I figured I'd write down why.
The Overwhelming Majority: rehype-slug, Fully Automated
rehype-slug is a plugin that auto-assigns id attributes to every heading just by adding a single line to your unified pipeline.
.use(rehypeSlug)
It turns the heading text into a slug (lowercase + hyphenated) and uses that as the id. Japanese headings get folded right into the slug too (and the result ends up URL-encoded).
<!-- ## Keystatic とは -->
<h2 id="keystatic-とは">Keystatic とは</h2>
Add rehype-autolink-headings and you also get a # icon next to each heading, letting readers copy the deep link URL. Most engineering blogs and docs sites use this pattern. MDN, GitHub READMEs, the Next.js docs — all of them.
Super easy, and it just works.
So why didn't I use it?
Three Reasons I Skipped It
1. It Fragments a Multilingual Blog
This blog publishes Japanese articles alongside AI-translated English versions. Let rehype-slug auto-generate IDs and the same section ends up with completely different anchors in JA and EN.
/ja/posts/dual-cms#ハマりポイント-1-markdoc-テーブル問題
/en/posts/dual-cms#gotcha-1-markdoc-table-issue
If you want to read "the English version of this Japanese section," you can't jump there directly with the anchor preserved. Cross-language deep links just don't work, which is a serious gap for a multilingual blog.
2. Japanese URLs Look Awful
With the rehype-slug approach, deep link URLs contain raw Japanese like #keystatic-とは. Functionally it works, but paste it into a browser or social media preview and it gets URL-encoded into #keystatic-%E3%81%A8%E3%81%AF.
The original meaning of the Japanese heading? Completely gone. Total gibberish.
And they're long. Japanese text gets converted to hexadecimal UTF-8 byte sequences, so a single full-width Japanese character becomes 9 alphanumeric characters, sometimes 12. (Example: "𠮷" → %F0%A0%AE%B7)
Accidentally write a slightly longer heading and you'll end up with an absurdly long, cryptic-looking URL.
It doesn't affect functionality, but how the URL looks when shared on social media really gets to me. I mean, it's ugly.
For an engineering blog, it clashes with a certain aesthetic — the desire to keep things looking clean.
That alone was enough reason to pass on it.
3. Too Many Anchors With No Real Intent
rehype-slug mechanically stamps an id on every single heading.
Regardless of whether the author actually intends "this is a section meant for deep linking," every minor subheading, every throwaway sub-section gets an anchor.
It's a visual problem — the HTML source gets cluttered.
But more fundamentally, it feels like an information design problem: meaningless anchors bury the ones you actually intended.
There are maybe three reasons you'd want to deep link to a heading.
- Linking from another article saying "see this section"
- Sharing a specific section on social media saying "read this part"
- A reader saving it to bookmarks or notes
All three require the location to have standalone reference value. Nobody benefits from being able to deep link to a minor subheading.
Anchors Only Where I Mean Them
So here's where I landed: skip rehype-slug entirely and use just remark-heading-id.
.use(remarkHeadingId) // parse {#id} and assign it to the heading
.use(rehypeAutolinkHeadings, {
test: (node) => node.tagName === "h2", // only add anchor <a> to h2s
// ...
})
remark-heading-id parses the Pandoc-derived ## Heading {#my-id} syntax and assigns an id only to those headings. If the author doesn't write {#my-id}, no id gets assigned. Fully manual mode.
## Keystatic とは // plain → no id
## Markdoc テーブル問題 {#markdoc-table-issue} // explicit → id="markdoc-table-issue"
I paired this with rehype-autolink-headings so a chain icon appears next to any h2 that has an id. The icon only appears where I intended a deep link. Unmarked headings stay clean.
<h2>Keystatic とは</h2> <!-- no icon, clean -->
<h2 id="markdoc-table-issue">
Markdoc テーブル問題
<a class="heading-anchor" href="#markdoc-table-issue">
<span class="heading-anchor-icon"></span> <!-- chain icon -->
</a>
</h2>
All three complaints from earlier? Solved.
- Only intentional anchors exist (zero noise)
{#my-id}can be written as a short English form, so URLs stay clean (#markdoc-table-issue)- Write the same
{#my-id}in both JA and EN versions, and you get the same URL for cross-language deep linking
Little Gotchas I Ran Into
A few small traps and lessons came up during implementation, so here are some notes.
rehype-sanitize Sneaks in user-content-
If you have rehype-sanitize in the pipeline, by default it automatically prefixes every id with user-content-.
This is the "clobbering prevention" feature in hast-util-sanitize, a security measure to prevent user-submitted HTML from colliding with page-level ids like id="login".
Unnecessary for a blog that just builds my own Markdown, so I disabled it in the schema:
const sanitizeSchema = {
...defaultSchema,
clobberPrefix: "", // disable user-content-
// ...
};
Without knowing this, you write #markdoc-table-issue thinking it'll work, but the actual id is #user-content-markdoc-table-issue and the link silently fails. A real bummer when it happens.
Behavior When Editing in Keystatic
This blog also uses Keystatic (a Markdoc-based, Git-backed CMS), and some syntax gets rewritten when Markdown passes through it (GFM tables get converted to {% table %} format, for instance — see another post for details).
Whether the Pandoc-derived {#id} would survive editing in Keystatic wasn't something I could know in advance, so I tested it for real.
The result: fully preserved. Keystatic's parser seems to treat {#id} as unknown syntax and passes it through as plain text. Confirmed safe to use.
Protecting It in the Translation Pipeline
I also needed to make sure AI translation preserves {#my-id}.
LLMs sometimes get "helpful" and translate the contents inside braces (changing {#markdoc-table-issue} to {#markdocテーブル問題}, for example).
So I had to spell out the rule in the prompt for scripts/translate.ts.
Preserve curly-brace annotations verbatim — do not translate their contents.
Examples: heading anchors like `## Heading {#my-id}`,
image/element attributes like `{width=400px align=right}`.
The image {width=... align=...} uses the same Pandoc-style attribute syntax, so I rolled it into the same protection rule while I was at it.
The Design Tradeoff
There is a downside, to be fair.
Some eccentric reader could show up wanting to deep-link to "this exact part of this article!" only to find the intended subsection has no anchor. It could happen.
But honestly, on this minor blog where I'm just spewing rambling thoughts, the odds of that are basically nil, and if it ever did happen, they can just link to the article itself.
There are cases where rehype-slug's full automation is the right call, and which approach you pick depends on how your blog is used.
| Criterion | Better for rehype-slug auto | Better for remark-heading-id alone |
|---|---|---|
| Languages | Single language | Multilingual, want shared anchors |
| Usage | High search/social traffic, headings stay fixed post-publish | Loyal readers, headings get rewritten post-publish |
| Anchor design | Auto-stamp every heading | Manually mark intended sections only |
This blog leans toward the latter.
As I mentioned, it's an obscure personal blog with a rock-bottom reader count, headings get rewritten constantly while drafts are in progress, and it runs in both JA and EN.
The act of writing {#foo} and signaling "this is a section I'm okay with being referenced externally" fits the information design here better than rehype-slug's convenience.
Wrap-up
I touched on this in another post too, but one of the maxims many engineers live by is "don't reinvent the wheel."
That said, I think the decision to "not use" a convenient wheel deserves just as much consideration as the decision to use one.
rehype-slug is an excellent plugin, but automation comes with costs (intent, URL cleanliness, cross-language consistency). Once you realize that tradeoff doesn't suit your blog, it's fine to consider options beyond pure convenience — that's what I took away this time around. This is starting to sound like a slow-life pep talk, but...
Writing {#my-id} by hand every time is a little tedious, but the process of pausing to decide "is this section worth deep-linking to?" has a nice side effect: it raises the quality of the article's information design itself.
Treat it not as a chore but as a milestone for design thinking, and it's actually kind of nice.
By the way, I looked back over this very article wondering if any section was worth a {#bar}, but sadly... nope, nothing really qualified (lol)