@MaximeHeckel

Static Tweets with MDX and Next.js

June 1, 2021 / 14 min read

Last Updated: June 1, 2021

While migrating my blog to Next.js, I took the opportunity to address the big performance pitfalls that were degrading the reader's experience in the previous version. With Core Web Vitals becoming one of the biggest factors in search ranking in 2021, I needed to get my act together and finally find workarounds to these issues before they impact my overall traffic.

One of those issues was embed tweets. I often find myself in need to quote or reference a tweet in my MDX blog posts. However, using the classic Twitter embed iframe is not the best solution for that: they are slow to load and triggers a lot of Content Layout Shift (CLS) which hurts the performance of my blog.

Thankfully, by leveraging some of Next.js' key features, a bit of hacking, and also the awesome work from Vercel's Head of DevRel Lee Robinson, we can get around this problem and have tweets in MDX based pages that do not require an iframe and load instantly 🚀 like this one:

📨 just sent the latest issue of my newsletter! Topics for this one include - looking back at one year of learning in public⭐️ - my writing process ✍️ - what's coming up next on my blog! Curious but not yet subscribed? You can read it right here 👇 https://t.co/xQRm1wrNQw

Curious how it works? Let's take a look at the solution I managed to put together to solve this problem and some MDX/Next.js magic ✨.

Coming up with a plan

The original inspiration for this solution comes from @leerob himself: a few months ago he came up with a video titled Rebuilding the Twitter Embed Widget! which covers the following:

  • ArrowAn icon representing an arrow
    what are the issues with the classic embed tweets?
  • ArrowAn icon representing an arrow
    how to leverage the Twitter API to fetch the content of tweets
  • ArrowAn icon representing an arrow
    how to build a <Tweet /> component to display the content of a tweet with the output of the Twitter API
  • ArrowAn icon representing an arrow
    how to put these pieces together to display a predefined list of tweets in a Next.js page.

However, after watching this video, one could indeed follow this method to get a predefined list of tweets to render on a dedicated route/page in a Next.js project, but this still doesn't quite solve the problem for tweets in MDX-based pages 🤔. Thus I came up with the following plan to address this gap:

Diagram showcasing the process to extract the Static Tweets out of the MDX document and render them in a Next.js page

The core of this plan happens at build time when every page/article of the blog gets generated:

  1. ArrowAn icon representing an arrow
    When processing a given path, we get its corresponding MDX document content by reading a static .mdx file.
  2. ArrowAn icon representing an arrow
    Each MDX file can use/import React components. When it comes to handling tweets, I planned on using the following interface/component: <StaticTweet id="abcdef123"/> where the id prop contains the id of the tweet I want to render.
  3. ArrowAn icon representing an arrow
    Then, by using some regex magic (I'll detail the code later in this article) we can extract each StaticTweet component from the content of the MDX document, and finally get a list of tweet ids where each id represents a tweet we want to eventually render.
  4. ArrowAn icon representing an arrow
    This list of tweet ids is then returned in getStaticProps and used to fetch each tweet from the Twitter API and eventually get a map of tweet ids to tweet content (see first code snippet below). This map will help us find the content associated with each static tweet.
  5. ArrowAn icon representing an arrow
    Finally, the most "hacky" part of this implementation: rendering each tweet declared in the MDX document with the proper content (you'll see why it's "hacky" in the next part 😄).

Sample map of tweet ids to tweet content

1
const tweets = {
2
'1392141438528458758': {
3
created_at: '2021-05-11T15:35:58.000Z',
4
text:
5
"📨 just sent the latest issue of my newsletter!\n\nTopics for this one include\n- looking back at one year of learning in public⭐️\n- my writing process ✍️\n- what's coming up next on my blog!\n\nCurious but not yet subscribed? You can read it right here 👇\nhttps://t.co/xQRm1wrNQw",
6
id: '1392141438528458758',
7
public_metrics: {
8
retweet_count: 1,
9
reply_count: 0,
10
like_count: 6,
11
quote_count: 0,
12
},
13
author_id: '116762918',
14
media: [],
15
referenced_tweets: [],
16
author: {
17
profile_image_url:
18
'https://pbs.twimg.com/profile_images/813646702553010176/rOM8J8DC_normal.jpg',
19
verified: false,
20
id: '116762918',
21
url: 'https://t.co/CePDMvig2q',
22
name: 'Maxime',
23
protected: false,
24
username: 'MaximeHeckel',
25
},
26
},
27
'1386013361809281024': {
28
attachments: {
29
media_keys: ['3_1386013216527077377'],
30
},
31
created_at: '2021-04-24T17:45:10.000Z',
32
text:
33
"24h dans le volume d'une Fiat 500 avec trois amis et pourtant on se sent comme chez soi... à 400 km d'altitude ! Superbe performance technique et opérationelle de toutes les équipes qui nous ont entrainés et encadrés pour ce voyage 👏 https://t.co/kreeGnnLUM",
34
id: '1386013361809281024',
35
public_metrics: {
36
retweet_count: 8578,
37
reply_count: 959,
38
like_count: 101950,
39
quote_count: 627,
40
},
41
author_id: '437520768',
42
media: [
43
{
44
type: 'photo',
45
url: 'https://pbs.twimg.com/media/EzwbrVEX0AEdSDO.jpg',
46
width: 4096,
47
media_key: '3_1386013216527077377',
48
height: 2731,
49
},
50
],
51
referenced_tweets: [],
52
author: {
53
profile_image_url:
54
'https://pbs.twimg.com/profile_images/1377261846827270149/iUn8fDU6_normal.jpg',
55
verified: true,
56
id: '437520768',
57
url: 'https://t.co/6gdcdKt160',
58
name: 'Thomas Pesquet',
59
protected: false,
60
username: 'Thom_astro',
61
},
62
},
63
};

The implementation: a mix of regex, static site generation, and a hack

Now that we went through the plan, it's time to take a look at the implementation. There are 3 major pieces to implement:

  1. ArrowAn icon representing an arrow
    Using regex to find all the occurrences of StaticTweet and eventually get a list of tweet ids from the MDX document.
  2. ArrowAn icon representing an arrow
    In getStaticProps, i.e. during static site generation, use that list of tweet ids to fetch their corresponding tweets with the Twitter API and return the map of tweets to id so the Next.js page can use it as a prop.
  3. ArrowAn icon representing an arrow
    Define the StaticTweet component.

Extracting static tweets from an MDX document

Our first step consists of getting the list of ids of tweets we want to later fetch during the "static site generation" step. For that, I took the easy path: **using regex to find each occurrence of ** StaticTweet when reading the content of my MDX file.

Most MDX + Next.js setups, including this blog, have a function dedicated to reading and parsing the content of MDX files/documents. One example of such function can be found in Vercel's own tutorial to build an MDX-based blog with Next.JS: getDocBySlug. It's in this function that we'll extract each StaticTweet and build the list of ids:

Extraction of each occurrence of StaticTweet

1
import matter from 'gray-matter';
2
import { serialize } from 'next-mdx-remote/serialize';
3
4
// Regex to find all the custom static tweets in a MDX file
5
const TWEET_RE = /<StaticTweet\sid="[0-9]+"\s\/>/g;
6
7
const docsDirectory = join(process.cwd(), 'docs')
8
9
export function getDocBySlug(slug) {
10
const realSlug = slug.replace(/\.md$/, '')
11
const fullPath = join(docsDirectory, `${realSlug}.md`)
12
const fileContents = fs.readFileSync(fullPath, 'utf8')
13
const { data, content } = matter(fileContents)
14
15
/**
16
* Find all occurrence of <StaticTweet id="NUMERIC_TWEET_ID"/>
17
* in the content of the MDX blog post
18
*/
19
const tweetMatch = content.match(TWEET_RE);
20
21
/**
22
* For all occurrences / matches, extract the id portion of the
23
* string, i.e. anything matching the regex /[0-9]+/g
24
*
25
* tweetIDs then becomes an array of string where each string is
26
* the id of a tweet.
27
* These IDs are then passed to the getTweets function to be fetched from
28
* the Twitter API.
29
*/
30
const tweetIDs = tweetMatch?.map((mdxTweet) => {
31
const id = mdxTweet.match(/[0-9]+/g)![0];
32
return id;
33
});
34
35
const mdxSource = await serialize(source)
36
37
return {
38
slug: realSlug,
39
frontMatter: data,
40
mdxSource,
41
tweetIDs: tweetIDs || []
42
}
43
}

Here, we execute the following tasks:

  • ArrowAn icon representing an arrow
    extract each occurrence of StaticTweet
  • ArrowAn icon representing an arrow
    extract the value of the id prop
  • ArrowAn icon representing an arrow
    return the array of ids along with the content of the article

Build a map of tweet ids to tweet content

This step will be a bit easier since it mostly relies on @leerob's code to fetch tweets that he detailed in his video. You can find his implementation on his blog's repository. My implementation is the same as his but with Typescript type definitions.

At this stage, however, we still need to do some little edits in our getStaticProps function and Next.js page:

  • ArrowAn icon representing an arrow
    Get the tweet ids out of the getDocBySlug
  • ArrowAn icon representing an arrow
    Fetch the content associated with each tweet id
  • ArrowAn icon representing an arrow
    Return the map of tweet ids to tweet content
  • ArrowAn icon representing an arrow
    Read the map of ids tweet ids to tweet content in the Next.js page code.

Fetch the list of tweets and inject the content in the page

1
import Image from 'next/image';
2
import { MDXRemote } from 'next-mdx-remote';
3
import { Heading, Text, Pre, Code } from '../components';
4
5
const components = {
6
img: Image,
7
h1: Heading.H1,
8
h2: Heading.H2,
9
p: Text,
10
code: Pre,
11
inlineCode: Code,
12
};
13
14
export default function Post({ mdxSource, tweets }) {
15
console.log(tweets); // prints the map of tweet id to tweet content
16
17
return <MDXRemote {...mdxSource} components={components} />;
18
}
19
20
export async function getStaticProps({ params }) {
21
const { mdxSource, frontMatter, slug, tweetIDs } = getDocBySlug(params.slug);
22
23
// Fetch the tweet content of each tweet id
24
const tweets = tweetIDs.length > 0 ? await getTweets(tweetIDs) : {};
25
26
return {
27
props: {
28
frontMatter,
29
mdxSource,
30
slug,
31
tweets,
32
},
33
};
34
}

Define the StaticTweet component

This is where the core of this implementation resides, and also where things get a bit hacky 😬.

We can now, at build time, for a given path, get the content of all the tweets present in a corresponding MDX document. But now the main problem is: how can we render that content?

It's at this stage that I kind of hit a wall, and had to resolve to use, what I'd call, "unconventional patterns" and here are the reasons why:

  • ArrowAn icon representing an arrow
    we can't override the interface of my MDX component. MDX makes us use the same interface between the definition of the component and how it's used in the MDX documents, i.e. in our case it takes one id prop, so it can only be defined with an id prop. Thus we can't simply define an MDX component for StaticTweet and call it a day.
  • ArrowAn icon representing an arrow
    our map of tweet ids to tweet content is only available at the "page" level, and thus can't be extracted out of that scope.

One way to fix this is to define the StaticTweet component inline, i.e. inside the Next.js page, and use the map returned by getStaticProps in the definition of the component:

Definition of the StaticTweet component used in MDX documents

1
import Image from 'next/image';
2
import { MDXRemote } from 'next-mdx-remote';
3
import { Heading, Text, Pre, Code, Tweet } from '../components';
4
5
const components = {
6
img: Image,
7
h1: Heading.H1,
8
h2: Heading.H2,
9
p: Text,
10
code: Pre,
11
inlineCode: Code,
12
};
13
14
export default function Post({ mdxSource, tweets }) {
15
const StaticTweet = ({ id }) => {
16
// Use the tweets map that is present in the outer scope to get the content associated with the id passed as prop
17
return <Tweet tweet={tweets[id]} />;
18
};
19
20
return (
21
<MDXRemote
22
{...mdxSource}
23
components={{
24
// Append the newly defined StaticTweet component to the list of predefined MDX components
25
...components,
26
StaticTweet,
27
}}
28
/>
29
);
30
}

Usually, I'd not define a React component this way and even less with external dependencies that are not passed as props, however in this case:

  • ArrowAn icon representing an arrow
    it's only to render static data, thus that map will never change after the static site generation
  • ArrowAn icon representing an arrow
    it's still a valid Javascript pattern: our StaticTweet component definition is inherently a Javascript function and thus has access to variables outside of its inner scope.

So, it may sound a bit weird but it's not a red flag I promise 😄.

The result

We now have everything in place to render Static Tweets in our Next.js + MDX setup so let's take a look at a couple of examples to show what this implementation is capable of.

In the MDX document powering this same blog post, I added the following StaticTweets:

1
<StaticTweet id="1397739827706183686" />
2
3
<StaticTweet id="1386013361809281024" />
4
5
<StaticTweet id="1384267021991309314" />

The first one renders a standard tweet:

The following one renders a tweet with images:

24h dans le volume d'une Fiat 500 avec trois amis et pourtant on se sent comme chez soi... à 400 km d'altitude ! Superbe performance technique et opérationelle de toutes les équipes qui nous ont entrainés et encadrés pour ce voyage 👏 https://t.co/kreeGnnLUM

24h dans le volume d'une Fiat 500 avec trois amis et pourtant on se sent comme chez soi... à 400 km d'altitude ! Superbe performance technique et opérationelle de toutes les équipes qui nous ont entrainés et encadrés pour ce voyage 👏 https://t.co/kreeGnnLUM

Finally, the last one renders a "quote tweet":

Just updated some of my projects to fix the missing headers, thank you @leeerob for sharing https://t.co/njBo8GLohm 🔒 and some of your tips! Just a note for Netlify users: you will have to add the headers either in your netlify.toml or a header file https://t.co/RN65w73I4r

Learned about https://t.co/RAxyJCKWjZ today 🔒 Here's how to take your Next.js site to an A. https://t.co/APq7nxngVw

Learned about https://t.co/RAxyJCKWjZ today 🔒

Here's how to take your Next.js site to an A. https://t.co/APq7nxngVw

And the best thing about this implementation: the resulting will remain as fast no matter how many tweets you add in your MDX document!

Pretty sweet right? ✨

Conclusion

First of all, thank you @leerob for the original inspiration for this implementation 🙌! This was yet another moment where I saw how Next.js and static site generation can shine.

I hope you all liked this little extension of Lee's static tweets tutorial. Adding support for MDX-based pages while keeping the interface clean was no easy feat as you can see but the result is definitely worth the effort and hours of tinkering put into this.

I'm still looking to improve the <Tweet /> component as I'm writing these words. There are yet a few elements that remain to be tackled in my current implementation, such as:

  • ArrowAn icon representing an arrow
    figuring out a clean/secure way to parse links, right now they just render as text
  • ArrowAn icon representing an arrow
    providing a better way to render a grid of images, as of now some images might see their aspect ratio altered
  • ArrowAn icon representing an arrow
    parsing numbers, i.e. displaying 118k instead of 118000 when it comes to likes, retweets, or replies

It's not perfect but for now, it will do! I revisited previous blog posts that referenced tweets and replaced them with this new component to guarantee the best reading experience. If you have any suggestions or ideas on how I could further improve how tweets are rendered on my blog, as always, don't hesitate to reach out! I love hearing your feedback!

Liked this article? Share it with a friend on Bluesky or Twitter or support me to take on more ambitious projects to write about. Have a question, feedback or simply wish to contact me privately? Shoot me a DM and I'll do my best to get back to you.

Have a wonderful day.

– Maxime

A solution to remove sluggish Twitter embed iframes and load the tweets in your blog posts at the speed of light.