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.
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:
Boom! 💥 just finished my first @gatsbyjs theme!
Introducing... Gatsby Theme GatStats.
If you'd like
#dataviz
for your
#tech
blog have a look 👀
👉 https://t.co/viyC822cqI pic.twitter.com/98tohJkO8W
— Paul Scanlon (@PaulieScanlon)
December 2, 2019
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.
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:
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?
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.
- 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"/>
- 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.
- 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:
"Net Neutrality" is Obamacare for the Internet; the Internet should not operate at the speed of government.
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.
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:
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.
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>
)
}