Dan Stroot

Embed a Tweet in .mdx

Sometimes you just want to add a Tweet to a post. Should be simple, right? With .mdx you have some different choices that are worth considering.

Date:


2024-06-16 Update: OK, just use react-tweet. Somehow it gets around the twitter API debacle and does everything my custom component did including supporting light and dark modes. Just use it!

2023-06-01 Update: "X" has cut off free api access. You have to pay $100/mo for API access now so I no longer endorse this approach.


So, you are using Next.js with .mdx to render static pages and you want to embed a Tweet? What if you simply paste in Twitter's "embed code" - all done? You can also use the Tweet component from mdx-embed, which is a React component that you can use in your .mdx files. Or, you can roll you own for an even better solution.

Let's explore the options.

1

Simply paste the twitter embed code

If you simply paste the twitter embed code into your .mdx file, you will get a Tweet. It may not look great, and it will throw errors. Let's fix that.

Since .mdx is a combination of Markdown and JSX we have to follow JSX standards for embedded HTML. So the "class" attribute needs to be changed to "className". Then "charset" needs to be changed to "charSet". Then you you need to make sure no browser add-ins are blocking social media trackers, otherwise you will get errors. You also need to make sure your CSP policy allows the Twitter script(s) and other content to be loaded.

Unfortunately, When it works I still see console warnings:

  • "Warning: Did not expect server HTML to contain a <div> in <div>."
  • "Warning: validateDOMNesting(...): <a> cannot appear as a descendant of <a>."

In any case, here it is:

If that works for you then you are done. However, With Core Web Vitals becoming one of the biggest factors in search ranking using the classic Twitter embed iframe is not the best solution; it is slow to load and triggers Content Layout Shift (CLS) which hurts your Core Web Vitals score.

The "just paste in the embed code" approach didn't work as well as I hoped. It did not work consistently, it was slow, and I could not figure out how to make it work with Next.js dark mode dynamically.

2

Use a React component in .mdx

Enter MDX Embed which also gives you the capability of adding lots of different embeds in addition to Twitter. It's pretty awesome.

You just import the pre-built component you need and then use it in your .mdx file. The Tweet component also supports light and dark themes. Here is an example of the dark theme:

example.mdx
(javascript)
import { Tweet } from '../node_modules/mdx-embed/dist/components/twitter/tweet'
 
;<Tweet tweetLink="PaulieScanlon/status/1201514996838141952" theme="dark" />

This seems to work more consistently, and if you don't have light and dark modes it's great. I could not figure out how to change the Tweet's light/dark themes dynamically, and I still had to deal with content layout shift. Hmm... what to do? Would there be a way to get the Tweet and pre-render it to eliminate CLS and make my page load faster?

3

Roll your own custom React tweet component and API

If you are a "full control" kind of person read on. First, let's check out what Lee Robinson, Director of Developer Relations at Vercel, does on his personal blog. He has a Tweets page that is really fast here. His secret is pre-rendering the tweets and then embedding them in the page. Wait, what!?

He explains all here:

There is one big drawback to using the approach Lee used - all your tweets need to be "known" ahead of time to be pre-rendered. Imagine you have a CMS and people are creating content in the CMS using Markdown/MDX and they wish to add embedded YouTube videos, Tweets, etc. to their posts. You can't really use the same approach as Lee did. Maxime Heckel figured out a way and documented it on his blog. It seemed promising, but did seem a bit "hacky" to implement.

My Plan

I decided to try a middle ground. I will create a component that renders itself on page load - it's much faster and works consistently. However I still have CLS to contend with.

  1. Since MDX files can use/import React components let's use a custom component. To make this work we need a <CustomTweet> component that will be rendered in the browser. This component will accept the Tweet id as a prop, go get the tweet, and then render it. Like this: <CustomTweet id="abcdef123"/>
  2. Let's create a custom Next.js API to fetch the tweet. For the component above to retrieve a tweet we can re-use Lee's code and turn it into an API. This will return the Tweet data in JSON format.
  3. Finally we will use a tailwind styled component to render the tweet. I want this component to render a beautiful Tweet, with dynamic light/dark modes. In this case it's our <Tweet> component. This is also based off Lee's code.

Let me show you the results first. Here is my version. Here is the react-tweet version:

The Code

The first thing we want to do is create our own custom Tweet API. We will leverage Twitter's v2 REST API and return the Tweet in JSON format based on the Tweet ID. This is directly based on Lee Robinson's code. Here's more info: Twitter API v2 REST API.

/api/tweet/[tweetID].js
(javascript)
export default async function handler(req, res) {
  const { tweetID } = req.query
 
  if (!tweetID) {
    return res.status(400).json({
      error: 'Please provide a Tweet ID',
    })
  }
 
  // METHOD SWITCH
  switch (req.method) {
    case 'GET':
      return getTweet(tweetID)
    default:
      return res.status(405).end(`Method ${req.method} Not Allowed`)
  }
 
  // GET
  async function getTweet(tweetID) {
    const queryParams = new URLSearchParams({
      ids: [tweetID], // comma separated list of tweet IDs, we use just one here
      expansions:
        'author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id',
      'tweet.fields':
        'attachments,author_id,public_metrics,created_at,id,in_reply_to_user_id,referenced_tweets,text',
      'user.fields':
        'id,name,profile_image_url,protected,url,username,verified',
      'media.fields':
        'duration_ms,height,media_key,preview_image_url,type,url,width,public_metrics',
    })
 
    let tweets = {}
    try {
      tweets = await fetch(`https://api.twitter.com/2/tweets?${queryParams}`, {
        headers: {
          Authorization: `Bearer ${process.env.TWITTER_BEARER_TOKEN}`,
        },
      }).then((res) => res.json())
    } catch (err) {
      return res.status(400).json(err)
    }
 
    const getAuthorInfo = (author_id) => {
      return tweets.includes.users.find((user) => user.id === author_id)
    }
 
    const getReferencedTweets = (mainTweet) => {
      return (
        mainTweet?.referenced_tweets?.map((referencedTweet) => {
          const fullReferencedTweet = tweets.includes.tweets.find(
            (tweet) => tweet.id === referencedTweet.id,
          )
 
          return {
            type: referencedTweet.type,
            author: getAuthorInfo(fullReferencedTweet.author_id),
            ...fullReferencedTweet,
          }
        }) || []
      )
    }
 
    return tweets.data.reduce((allTweets, tweet) => {
      const tweetWithAuthor = {
        ...tweet,
        media:
          tweet?.attachments?.media_keys.map((key) =>
            tweets.includes.media.find((media) => media.media_key === key),
          ) || [],
        referenced_tweets: getReferencedTweets(tweet),
        author: getAuthorInfo(tweet.author_id),
      }
      return res.status(200).json(tweetWithAuthor, ...allTweets)
    }, [])
  }
}

The next thing we want to do is create our own custom Tweet MDX Component. It will call our API and render a Tweet. It's a pretty basic component. This is what we will to pass or import into our .mdx file:

CustomTweet.jsx
(javascript)
import { useState, useEffect } from 'react';
import { BASE_URL } from '../../lib/constants';
import { Tweet } from '../Tweet';
 
export const CustomTweet = ({ id }) => {y developer
  let [tweet, setTweet] = useState(null);
  const URL =
    process.env.NODE_ENV === 'development'
      ? `http://localhost:3000/api/tweet/${id}`
      : `${BASE_URL}/api/tweet/${id}`;
 
  useEffect(() => {
    const fetchData = async () => {
      const data = await fetch(URL).then((res) => res.json());
      setTweet(data);
    };
 
    fetchData();
  }, [URL]);
 
  return (
    <>
      {tweet && (
        <div className='md:w-4/5'>
          <Tweet tweet={tweet} />
        </div>
      )}
    </>
  );
};

And finally here is the Tweet component. It will take the tweet object and render it. Again, thanks to Lee Robinson's code. However, I've tweaked it a bit to improve the avatar and text styling to make it look a smidge better. Dynamic dark mode for the win! Unfortunately, this approach is super easy to use but doesn't pre-render the tweet so there is still content layout shift.

Tweet.jsx
(javascript)
import Image from 'next/image'
import { format } from 'date-fns'
 
/**
 * Supports plain text, images, and quote tweets.
 * Styles use !important to override Tailwind .prose inside MDX.
 * Inspiration: https://github.com/leerob/leerob.io/blob/main/lib/twitter.ts
 */
 
export const Tweet = ({ tweet }) => {
  const {
    text,
    id,
    author,
    media,
    created_at,
    public_metrics,
    referenced_tweets,
  } = tweet
  const createdAt = new Date(created_at)
  const image = author.profile_image_url.replace('_normal', '_200x200') // higher res
 
  // construct our tweet URLs to use later
  const authorUrl = `https://twitter.com/${author.username}`
  const likeUrl = `https://twitter.com/intent/like?tweet_id=${id}`
  const retweetUrl = `https://twitter.com/intent/retweet?tweet_id=${id}`
  const replyUrl = `https://twitter.com/intent/tweet?in_reply_to=${id}`
  const tweetUrl = `https://twitter.com/${author.username}/status/${id}`
 
  // make embedded URLs clickable
  const URLify = (string) => {
    const urls = string.match(
      /((((https?):\/\/)|(w{3}\.))[\-\w@:%_\+.~#?,&\/\/=]+)/g,
    )
 
    if (urls) {
      urls.forEach(function (url) {
        string = string.replace(
          url,
          '<a target="_blank" href="' + url + '">' + url + '</a>',
        )
      })
    }
 
    return string
  }
 
  const urlified = URLify(text)
 
  // make hashtags blue
  const highlightHashTags = (string) => {
    const tags = string.match(/(#+[a-zA-Z0-9(_)]{1,})/g)
 
    if (tags) {
      tags.forEach(function (tag) {
        string = string.replace(
          tag,
          '<span class="text-blue-600 dark:text-blue-300">' + tag + '</span>',
        )
      })
    }
 
    return string
  }
 
  const tagified = highlightHashTags(urlified)
 
  // make @names blue
  const highlightNames = (string) => {
    const names = string.match(/(@+[a-zA-Z0-9(_)]{1,})/g)
 
    if (names) {
      names.forEach(function (tag) {
        string = string.replace(
          tag,
          '<span class="text-blue-600 dark:text-blue-300">' + tag + '</span>',
        )
      })
    }
 
    return string
  }
 
  const namified = highlightNames(tagified)
 
  const quoteTweet =
    referenced_tweets && referenced_tweets.find((t) => t.type === 'quoted')
 
  return (
    <div className="border-1 tweet my-4 w-full rounded-xl border border-gray-300 p-4 dark:border-gray-700">
      <div className="flex">
        <a
          className="flex h-12 w-12"
          href={authorUrl}
          target="_blank"
          rel="noopener noreferrer"
        >
          <Image
            alt={author.username}
            height={48}
            width={48}
            src={image}
            className="rounded-full"
          />
        </a>
        <a
          href={authorUrl}
          target="_blank"
          rel="noopener noreferrer"
          className="author ml-2 flex flex-col text-base !no-underline"
        >
          <span
            className="flex items-center font-bold leading-5 !text-gray-900 dark:!text-gray-100"
            title={author.name}
          >
            {author.name}
            {author.verified ? (
              <svg
                aria-label="Verified Account"
                className="ml-1 inline h-5 w-5 text-[#1d9bf0]"
                viewBox="0 0 24 24"
              >
                <g fill="currentColor">
                  <path d="M22.5 12.5c0-1.58-.875-2.95-2.148-3.6.154-.435.238-.905.238-1.4 0-2.21-1.71-3.998-3.818-3.998-.47 0-.92.084-1.336.25C14.818 2.415 13.51 1.5 12 1.5s-2.816.917-3.437 2.25c-.415-.165-.866-.25-1.336-.25-2.11 0-3.818 1.79-3.818 4 0 .494.083.964.237 1.4-1.272.65-2.147 2.018-2.147 3.6 0 1.495.782 2.798 1.942 3.486-.02.17-.032.34-.032.514 0 2.21 1.708 4 3.818 4 .47 0 .92-.086 1.335-.25.62 1.334 1.926 2.25 3.437 2.25 1.512 0 2.818-.916 3.437-2.25.415.163.865.248 1.336.248 2.11 0 3.818-1.79 3.818-4 0-.174-.012-.344-.033-.513 1.158-.687 1.943-1.99 1.943-3.484zm-6.616-3.334l-4.334 6.5c-.145.217-.382.334-.625.334-.143 0-.288-.04-.416-.126l-.115-.094-2.415-2.415c-.293-.293-.293-.768 0-1.06s.768-.294 1.06 0l1.77 1.767 3.825-5.74c.23-.345.696-.436 1.04-.207.346.23.44.696.21 1.04z" />
                </g>
              </svg>
            ) : null}
          </span>
          <span className="!text-gray-500" title={`@${author.username}`}>
            @{author.username}
          </span>
        </a>
        <a
          className="ml-auto"
          href={authorUrl}
          target="_blank"
          rel="noopener noreferrer"
        >
          <svg
            aria-label="Twitter Logo"
            className="h-6 w-6 text-[#1d9bf0]"
            viewBox="328 355 335 276"
            xmlns="http://www.w3.org/2000/svg"
          >
            <g fill="currentColor">
              <path d="M630 425a195 195 0 0 1-299 175 142 142 0 0 0 97-30 70 70 0 0 1-58-47 70 70 0 0 0 31-2 70 70 0 0 1-57-66 70 70 0 0 0 28 5 70 70 0 0 1-18-90 195 195 0 0 0 141 72 67 67 0 0 1 116-62 117 117 0 0 0 43-17 65 65 0 0 1-31 38 117 117 0 0 0 39-11 65 65 0 0 1-32 35Z" />
            </g>
          </svg>
        </a>
      </div>
      <div
        className="mb-1 mt-4 whitespace-pre-wrap text-xl !text-gray-700 dark:!text-gray-300"
        dangerouslySetInnerHTML={{ __html: namified }}
      />
      {media && media.length ? (
        <div
          className={
            media.length === 1
              ? 'my-2 inline-grid grid-cols-1 gap-x-2 gap-y-2'
              : 'my-2 inline-grid grid-cols-2 gap-x-2 gap-y-2'
          }
        >
          {media.map((m) => (
            <Image
              key={m.media_key}
              alt={text}
              height={m.height}
              width={m.width}
              src={m.url}
              className="rounded"
            />
          ))}
        </div>
      ) : null}
      {quoteTweet ? <Tweet {...quoteTweet} /> : null}
      <a
        className="text-sm !text-gray-500 hover:!underline"
        href={tweetUrl}
        target="_blank"
        rel="noopener noreferrer"
      >
        <time
          title={`Time Posted: ${createdAt.toUTCString()}`}
          dateTime={createdAt.toISOString()}
        >
          {format(createdAt, 'h:mm a - MMM d, y')}
        </time>
      </a>
      <hr className="!mb-2 !mt-2" />
      <div className="mt-2 flex !text-sm !text-gray-700 dark:!text-gray-300">
        <a
          className="mr-4 flex items-center !text-gray-500 transition hover:!text-blue-600 hover:!underline"
          href={likeUrl}
          target="_blank"
          rel="noopener noreferrer"
        >
          <svg className="mr-2" width="20" height="20" viewBox="0 0 24 24">
            <path
              className="fill-current"
              d="M12 21.638h-.014C9.403 21.59 1.95 14.856 1.95 8.478c0-3.064 2.525-5.754 5.403-5.754 2.29 0 3.83 1.58 4.646 2.73.813-1.148 2.353-2.73 4.644-2.73 2.88 0 5.404 2.69 5.404 5.755 0 6.375-7.454 13.11-10.037 13.156H12zM7.354 4.225c-2.08 0-3.903 1.988-3.903 4.255 0 5.74 7.035 11.596 8.55 11.658 1.52-.062 8.55-5.917 8.55-11.658 0-2.267-1.822-4.255-3.902-4.255-2.528 0-3.94 2.936-3.952 2.965-.23.562-1.156.562-1.387 0-.015-.03-1.426-2.965-3.955-2.965z"
            />
          </svg>
          <span>{new Number(public_metrics.like_count).toLocaleString()}</span>
        </a>
        <a
          className="mr-4 flex items-center !text-gray-500 transition hover:!text-blue-600 hover:!underline"
          href={replyUrl}
          target="_blank"
          rel="noopener noreferrer"
        >
          <svg className="mr-2" width="20" height="20" viewBox="0 0 24 24">
            <path
              className="fill-current"
              d="M14.046 2.242l-4.148-.01h-.002c-4.374 0-7.8 3.427-7.8 7.802 0 4.098 3.186 7.206 7.465 7.37v3.828c0 .108.045.286.12.403.143.225.385.347.633.347.138 0 .277-.038.402-.118.264-.168 6.473-4.14 8.088-5.506 1.902-1.61 3.04-3.97 3.043-6.312v-.017c-.006-4.368-3.43-7.788-7.8-7.79zm3.787 12.972c-1.134.96-4.862 3.405-6.772 4.643V16.67c0-.414-.334-.75-.75-.75h-.395c-3.66 0-6.318-2.476-6.318-5.886 0-3.534 2.768-6.302 6.3-6.302l4.147.01h.002c3.532 0 6.3 2.766 6.302 6.296-.003 1.91-.942 3.844-2.514 5.176z"
            />
          </svg>
          <span>{new Number(public_metrics.reply_count).toLocaleString()}</span>
        </a>
        <a
          className="mr-4 flex items-center !text-gray-500 transition hover:!text-blue-600 hover:!underline"
          href={retweetUrl}
          target="_blank"
          rel="noopener noreferrer"
        >
          <svg className="mr-2" width="20" height="20" viewBox="0 0 24 24">
            <path
              className="fill-current"
              d="M23.77 15.67c-.292-.293-.767-.293-1.06 0l-2.22 2.22V7.65c0-2.068-1.683-3.75-3.75-3.75h-5.85c-.414 0-.75.336-.75.75s.336.75.75.75h5.85c1.24 0 2.25 1.01 2.25 2.25v10.24l-2.22-2.22c-.293-.293-.768-.293-1.06 0s-.294.768 0 1.06l3.5 3.5c.145.147.337.22.53.22s.383-.072.53-.22l3.5-3.5c.294-.292.294-.767 0-1.06zm-10.66 3.28H7.26c-1.24 0-2.25-1.01-2.25-2.25V6.46l2.22 2.22c.148.147.34.22.532.22s.384-.073.53-.22c.293-.293.293-.768 0-1.06l-3.5-3.5c-.293-.294-.768-.294-1.06 0l-3.5 3.5c-.294.292-.294.767 0 1.06s.767.293 1.06 0l2.22-2.22V16.7c0 2.068 1.683 3.75 3.75 3.75h5.85c.414 0 .75-.336.75-.75s-.337-.75-.75-.75z"
            />
          </svg>
          <span>
            {new Number(public_metrics.retweet_count).toLocaleString()}
          </span>
        </a>
      </div>
    </div>
  )
}

Sharing is Caring

Edit this page