Skip to Content
Introduction

Introduction

react-x-embed allows you to embed X (Twitter) posts in your React application when using Next.js, Vite, and more. This library does not require an API key. Posts can be rendered statically, preventing the need to include an iframe and additional client-side JavaScript.

This library is fully compatible with React Server Components. Learn more .

It is a fork of vercel/react-tweet . See what’s different below.

Try it

Paste any post URL or ID to render it. This page is a static export with no server behind it — the post is fetched in your browser with useTweet, which is what Tweet falls back to wherever React Server Components aren’t available.

Installation

Install react-x-embed using your package manager of choice:

pnpm add react-x-embed
yarn add react-x-embed
npm install react-x-embed

Now follow the usage instructions for your framework or builder:

Important: Before going to production, we recommend enabling cache for the X API as server IPs might get rate limited by X.

What’s different from react-tweet

Every change is anchored to a real payload. The repo ships captured syndication responses alongside X’s own rendered HTML, CSS, and computed geometry for the same posts, so layout is measured against X rather than guessed at.

Crashes fixed. Videos served without an mp4 rendition took down the page, because the player dereferenced a variant that didn’t exist. Malformed payloads did the same: a deleted or suspended account returns a post-shaped object with no user, throwing on the first property access. Responses are now validated before rendering and degrade to the “not found” state.

Layout matched to X. A 9:16 video rendered at 177.8% padding — nearly twice as tall as the embed is wide. It’s now capped the way X caps it.

Video that plays. X’s HLS rendition is offered as a <source> before the mp4, fixing playback in Safari, and the AbortError on the play button is gone.

Sharper images. Avatars use the 400px rendition instead of the 48×48 one, photos ship a srcset, and each image’s box is filled with the dominant colour X computed for it while it loads.

Link previews. The syndication API returns a fully populated card for any post sharing a link — title, description, domain, image. It was never rendered. See TweetCard.

Accessible and overridable. Interactive targets meet the WCAG 2.2 minimum, and container margin and max-width can actually be overridden — previously they tied on specificity with consumer utility classes, so whichever stylesheet loaded last won.

What it still can’t do

The syndication API is the only endpoint that works without credentials, and it returns one post at a time. Timelines, profiles, search, threads and reposts are out of reach — every other endpoint is credential-gated or returns nothing. A repost resolves to the original post and can’t be distinguished from it. Engagement data is limited to favorite_count and conversation_count.

Choosing a theme

The prefers-color-scheme CSS media feature is used to select the theme of the tweet.

Toggling theme manually

The closest data-theme attribute on a parent element can determine the theme of the tweet. You can set it to light or dark, like so:

<div data-theme="dark"> <Tweet id="2040511285998313827" /> </div>

Alternatively, a parent with the class light or dark will also work:

<div className="dark"> <Tweet id="2040511285998313827" /> </div>

Updating the theme

In CSS Modules, you can use the :global selector to update the CSS variables used by themes:

.my-class :global(.react-tweet-theme) { --tweet-body-font-size: 1rem; }

For Global CSS the usage of :global is not necessary.

Enabling cache for the X API

Rendering posts requires making a call to X’s syndication API. Getting rate limited by that API is very hard but it’s possible if you’re relying only on the default SWR endpoint (react-tweet.vercel.app/api/tweet/:id) as the IPs of the server are making many requests to the syndication API. This also applies to RSC where the API endpoint is not required but the server is still making the request from the same IP.

Note: that default endpoint is upstream’s public deployment, kept so client-side rendering works with zero configuration. It is a shared, rate-limited service that this project does not operate — host your own route before going to production.

To prevent this, you can use a db like Redis or Vercel KV  to cache the tweets. For example using Vercel KV :

import { Suspense } from 'react' import { TweetSkeleton, EmbeddedTweet, TweetNotFound } from 'react-x-embed' import { fetchTweet, Tweet } from 'react-x-embed/api' import { kv } from '@vercel/kv' async function getTweet( id: string, fetchOptions?: RequestInit ): Promise<Tweet | undefined> { try { const { data, tombstone, notFound } = await fetchTweet(id, fetchOptions) if (data) { await kv.set(`tweet:${id}`, data) return data } else if (tombstone || notFound) { // remove the tweet from the cache if it has been made private by the author (tombstone) // or if it no longer exists. await kv.del(`tweet:${id}`) } } catch (error) { console.error('fetching the tweet failed with:', error) } const cachedTweet = await kv.get<Tweet>(`tweet:${id}`) return cachedTweet ?? undefined } const TweetPage = async ({ id }: { id: string }) => { try { const tweet = await getTweet(id) return tweet ? <EmbeddedTweet tweet={tweet} /> : <TweetNotFound /> } catch (error) { console.error(error) return <TweetNotFound error={error} /> } } const Page = async ({ params }: { params: Promise<{ tweet: string }> }) => { const { tweet } = await params return ( <Suspense fallback={<TweetSkeleton />}> <TweetPage id={tweet} /> </Suspense> ) } export default Page

The full example lives in apps/next-app, which you can run locally with pnpm dev --filter=next-app....

If you’re using Next.js then using unstable_cache works too.

Last updated on