Embed/Preview inline links with React/Next.js and Rehype/MDX
Create blog posts with Advanced MDX Dynamic Content, React components and APIs
- Next.js
- React
- MDX
- rehype
- Link Previews
Despite my ongoing goal to start writing consistently, my blog has mostly served as a playground for trying out new ideas. It’s become a space where I can experiment with design, functionality, and unique features, keeping the technical side of blogging alive even with limited content. Inspired by , I wanted to share some of the small but impactful elements I’m experimenting with to enhance the reading experience.
One feature I love is the ability to preview links seamlessly. Much like Notion’s approach, I’ve implemented a system where links display both a favicon and a mini screenshot on hover. This provides readers with context before they even click, improving navigation and making the links more engaging. It’s a subtle feature but adds a polished touch, showing readers exactly where they're headed.
What do we want to achieve?
- Expand inline links with the favicon and title (optional) of the linked page using a custom MDX component
- Preview the link on hover; it will be any image that is found in the meta tags of the linked page or otherwise a screenshot of the page
- Ability to change all links in the content to use the new component automatically, using custom remark and rehype plugins
How the pieces fit together
People sometimes ask whether this should be a remark plugin (operating on the markdown AST) or a rehype plugin (operating on the HTML AST). The honest answer from this blog's implementation: it is both, and each one earns its stage.
- A remark plugin rewrites every external
http(s)link in a post into a<Preview>JSX element while the document is still markdown - A rehype plugin rewrites internal
/blog/category/...links into<Preview internal>elements after the markdown has become HTML (by then, autolinked headings and other transforms have settled, and links exist as plain<a>elements) - An API route (
/api/preview) fetches and caches the metadata for a URL: title, description, favicon, and a preview image - The Preview component renders the inline favicon and title, and a rich hover card with the image or post metadata
Both plugins are registered in , so every post gets this treatment with zero per-post effort. The markdown stays plain markdown; [Visit OpenAI](https://openai.com) is all an author ever writes.
Step 1: The remark plugin for external links
The plugin () visits every link node in the markdown tree and, if it is a public external URL, swaps the node for an MDX JSX element. Loopback hosts are skipped since the preview endpoint could never fetch them safely:
Step 2: The rehype plugin for internal links
Internal links get their own pass (). It matches anchors whose href fits the blog's URL shape, extracts the link text (which becomes the inline title), and replaces the <a> with a <Preview internal> element:
The internal flag matters downstream: internal previews never hit the network for metadata scraping. The API route resolves them straight from the contentlayer documents instead.
Step 3: The preview API route
The component cannot fetch arbitrary pages from the browser (CORS, and I would rather not ship scraping logic to every reader), so metadata extraction lives in a Next.js route handler: . Given ?url=... it:
- Resolves internal URLs locally: a
/blog/...URL is looked up in the generated contentlayer documents and answered with the post's title, summary, category, tags, date, and reading time; no outbound request at all - Fetches external URLs defensively: through a
safeFetchwrapper that validates every redirect hop (SSRF protection), with a 15 second timeout, a 1 MB response cap, and a reader that stops downloading as soon as</head>arrives, since that is where all the metadata lives - Extracts metadata with
node-html-parser:og:title/twitter:title/<title>fallbacks for the title, the same cascade for description and image, and the favicon with Google's favicon service as a last resort. If no image is found, a screenshot URL is synthesized so there is always something to show on hover - Special-cases platforms: YouTube links get the video thumbnail, GitHub repos get their OpenGraph card image, and Wikipedia articles get the first paragraph as a text excerpt instead of a screenshot
- Caches aggressively, including errors: an in-memory map keeps successful lookups for an hour and failures for five minutes, so a dead link does not trigger a fetch storm but also does not stay marked dead forever. Error payloads are returned with HTTP 200 and an
errorflag for consistent client handling, but withCache-Control: no-storeso browsers never pin a transient failure
Step 4: The Preview component
The client side lives in my design system: with its data layer in . The hook does the unglamorous work: an LRU cache with TTLs (again shorter for errors), deduplication of concurrent requests for the same URL, abort-on-unmount, and a stale-response guard so a slow fetch for a previous URL can never overwrite the current one. npm package links are synthesized entirely locally because npmjs.com blocks metadata fetches.
The component then renders one of a few states:
- Loading: a small skeleton (a pulsing favicon square and title bar) holds the link's place inline
- Error: the link still works, it just degrades. If the API confirmed the target is gone (400, 404, or 410), the link renders in red with a slashed-link icon; transient failures render as a plain neutral link
- Success (inline): the page's favicon (with an icon fallback if it fails to load) followed by the cleaned-up title. Titles get de-noised:
GitHub - owner/repo: descriptionbecomesowner/repo, suffixes like- Wikipediaor- Stack Overfloware stripped, and everything is capped at 60 characters - Hover card: a (with a subtle framer-motion entrance that respects
prefers-reduced-motion). External links show the preview image or Wikipedia excerpt plus description and site name; internal links show a richer post card with category, summary, published date, reading time, and tags
Every link in this very post is going through that pipeline right now, which is the nicest property of the whole setup: the feature documents itself.