How to Embed Tweets on Your Website — 3 Methods, Measured
Every guide to embedding tweets shows the same thing: click the three dots, choose Embed post, paste the snippet. That works, and for a single tweet in a blog post it is the right answer. It stops being the right answer the moment you embed twenty tweets on a landing page, care about Core Web Vitals, run a strict cookie-consent setup, or want the tweet to match your design system.
This guide covers all three ways to embed tweets on a website — the copy-paste embed, the oEmbed API, and static tweet cards rendered from API data — with numbers we measured ourselves (script payload, cache headers, endpoint latency) and runnable code for each. It finishes with a decision table so you can pick the method that fits your page instead of the one that is easiest to explain.
Method 1 — Copy-paste the embed code (no code, one tweet)
On x.com, open the tweet, click the share icon or the three-dot menu, and choose Embed post. That opens publish.x.com with the tweet URL pre-filled. You can also paste any tweet URL into publish.x.com directly. Adjust the options (dark theme, hide the parent tweet in a reply, language) and copy the snippet.
What you paste is a plain holding the tweet text, author and permalink, followed by . The blockquote is the fallback: if the script is blocked by an ad blocker or consent manager, readers still see the tweet as a quote with a link. When the script runs, it swaps each blockquote for an iframe-rendered card with media, counts and the X branding.
In a CMS: many content platforms auto-embed a tweet URL pasted on its own line by calling the oEmbed endpoint described below — WordPress's Embed block is the best-known example. Check your platform's embed settings before pasting raw HTML.
Where it breaks down: every page that uses the snippet pays for widgets.js, and if you paste the snippet five times you get five tags (the browser dedupes the download, but the HTML gets messy). The card height is unknown until the iframe renders, so content below it jumps — a layout-shift (CLS) cost. And you cannot style the card beyond theme and width.
What widgets.js actually costs — measured
Most embed tutorials never say what the script weighs. We fetched https://platform.twitter.com/widgets.js (the official platform.x.com/widgets.js URL redirects there) and read the response headers:
The script itself is modest. The real cost is what happens next: an iframe per tweet, each fetching its own HTML, CSS and images, plus a logging beacon. A page with one embed barely notices. A testimonial wall with thirty tweets can add a lot of network requests and main-thread work, and every card resizes when it finishes loading.
Mitigations if you stay on widgets.js: load the script once per page (pass omit_script=1 in oEmbed and add one script tag yourself), lazy-load it only when a tweet scrolls into view, reserve vertical space with a min-height on the container to reduce layout shift, and add data-dnt="true" to opt the embed out of personalization.
Method 2 — The oEmbed API (free, server-side, cacheable)
The oEmbed endpoint is how CMSs auto-embed tweets, and you can call it yourself: GET https://publish.x.com/oembed?url=. X's oEmbed documentation lists it as JSON only, no authentication required, not rate limited. The old publish.twitter.com/oembed host still works but answers with a 301 redirect to publish.x.com first.
We timed it: three direct calls to publish.x.com/oembed took 0.96–1.05 seconds each; the same call through the publish.twitter.com redirect took 2.2–4.9 seconds. Either way it is far too slow to call on every page view, which is exactly why the response tells you to cache it.
The parameters worth knowing for single tweets: maxwidth (220–550 px, default 325), hide_media, hide_thread (drops the collapsed parent tweet on replies, which makes height more predictable), omit_script, align, lang, theme (light or dark) and dnt. X explicitly does not support maxheight for tweets and always returns height: null — tweets are text and have no fixed height.
Here is the real response for the first tweet ever posted (omit_script=true):
{"url":"https://x.com/jack/status/20", "author_name":"jack", "author_url":"https://x.com/jack", "html":"…just setting up my twttr…
", "width":550, "height":null, "type":"rich", "cache_age":"3153600000", "provider_name":"X", "version":"1.0"}
cache_age is 3,153,600,000 seconds — about 100 years. In practice that means: fetch once, store the html field in your database next to the tweet URL, and never call the endpoint again for that tweet unless you change options. The snippet below does exactly that with a file cache.
# pip install requests
# Server-side oEmbed with a cache: one network call per tweet, ever (until you purge).
import json, pathlib, requests
CACHE = pathlib.Path(".oembed_cache")
CACHE.mkdir(exist_ok=True)
def embed_html(tweet_url: str, theme: str = "light") -> str:
key = CACHE / (tweet_url.rstrip("/").split("/")[-1] + f"_{theme}.json")
if key.exists():
return json.loads(key.read_text())["html"]
r = requests.get(
"https://publish.x.com/oembed", # call x.com directly; twitter.com adds a 301 hop
params={
"url": tweet_url,
"omit_script": "1", # we load widgets.js once per page, not once per tweet
"dnt": "true", # opt the embed out of personalization
"theme": theme,
"hide_thread": "1", # predictable height for reply tweets
},
timeout=10,
)
r.raise_for_status()
data = r.json() # keys: html, author_name, author_url, width, cache_age, ...
key.write_text(json.dumps(data))
return data["html"]
print(embed_html("https://x.com/jack/status/20"))
# <blockquote class="twitter-tweet" data-dnt="true"><p lang="en" dir="ltr">just setting up my twttr</p>
# — jack (@jack) <a href="https://x.com/jack/status/20?ref_src=twsrc%5Etfw">March 21, 2006</a></blockquote>
Lazy-load embeds with twttr.widgets.createTweet
If you want X's own rendering but not its upfront cost, skip the blockquote markup entirely and create embeds from JavaScript only when they are about to be seen. The X for Websites JavaScript API exposes twttr.widgets.createTweet(tweetId, targetElement, options), which returns a Promise that resolves once the iframe is in place. Options include conversation (none hides the parent tweet), cards (hidden suppresses link previews), theme, width, align, lang and dnt.
Pass the tweet ID as a string. Tweet IDs are 64-bit integers; JavaScript numbers lose precision above 2^53, so 463440424141459456 written as a number silently becomes a different ID and the embed fails. Store IDs as strings end to end.
For single-page apps: after a client-side route change, new blockquotes are not scanned automatically. Call twttr.widgets.load(container) on the new content, or use createTweet as below, which sidesteps the scan.
The pattern below loads widgets.js only when the first tweet slot comes within 400 px of the viewport, then renders each tweet as it approaches. Pages that never scroll to the tweets never download the script.
<!-- One widgets.js per page, loaded only when a tweet scrolls near the viewport -->
<div class="tweet-slot" data-tweet-id="20"></div>
<div class="tweet-slot" data-tweet-id="463440424141459456"></div>
<script>
let widgetsReady = null;
function loadWidgets() {
if (widgetsReady) return widgetsReady;
widgetsReady = new Promise((resolve) => {
window.twttr = window.twttr || { _e: [], ready(f) { this._e.push(f); } };
const s = document.createElement("script");
s.src = "https://platform.twitter.com/widgets.js";
s.async = true;
document.head.appendChild(s);
window.twttr.ready(resolve);
});
return widgetsReady;
}
const io = new IntersectionObserver((entries) => {
entries.forEach(async (entry) => {
if (!entry.isIntersecting) return;
io.unobserve(entry.target);
const twttr = await loadWidgets();
// Tweet IDs MUST be strings: they exceed Number.MAX_SAFE_INTEGER.
await twttr.widgets.createTweet(entry.target.dataset.tweetId, entry.target, {
dnt: true,
conversation: "none",
theme: document.documentElement.classList.contains("dark") ? "dark" : "light",
});
});
}, { rootMargin: "400px" });
document.querySelectorAll(".tweet-slot").forEach((el) => io.observe(el));
</script>
Method 3 — Static tweet cards with zero third-party JavaScript
The fastest embed is one that is not an embed. Fetch the tweet's data on your server, render a normal HTML card with your own CSS, and link back to the original. No iframe, no external script, no layout shift (you know the content at render time), no third-party cookies, and the card matches your design system. Vercel's open-source react-tweet library popularized this approach for React; its README describes rendering tweets statically with no iframe and no extra client-side JavaScript.
You need the tweet's text, author, avatar and counts. react-tweet reads an undocumented syndication endpoint that its own docs warn can rate-limit server IPs. A documented API is more dependable for production. With TwitterAPI.io, one GET /twitter/tweets?tweet_ids= call returns full tweet objects — text, author (name, userName, profilePicture, isBlueVerified), likeCount, retweetCount, replyCount, quoteCount, viewCount, createdAt, entities and quoted/retweeted tweets — for a batch of IDs.
Follow X's display requirements. X's developer terms include display requirements for showing posts outside its apps: the author's profile picture, display name and @username must be shown and link to their X profile, the post text must appear unaltered below the author, and the timestamp must not be dropped. The snippet below keeps avatar, name and handle linked to the profile, the verbatim text, and the timestamp linked to the post's permalink.
# pip install requests
# Static tweet cards: fetch tweet data in one batch call, render your own HTML.
# Zero third-party JavaScript on the page; you control layout, caching and CLS.
import html, os, requests
API_KEY = os.environ["TWITTERAPI_IO_KEY"]
def fetch_tweets(ids: list[str]) -> list[dict]:
r = requests.get(
"https://api.twitterapi.io/twitter/tweets",
params={"tweet_ids": ",".join(ids)},
headers={"X-API-Key": API_KEY},
timeout=15,
)
r.raise_for_status()
return r.json().get("tweets", [])
def tweet_card(t: dict) -> str:
a = t["author"]
return f"""
<figure class="tweet-card">
<figcaption>
<a href="https://x.com/{a['userName']}">
<img src="{html.escape(a['profilePicture'])}" alt="" width="40" height="40" loading="lazy">
{html.escape(a['name'])} <span>@{a['userName']}</span>
</a>
</figcaption>
<blockquote lang="{t.get('lang', 'en')}">{html.escape(t['text'])}</blockquote>
<footer>
<a href="{t['url']}"><time>{html.escape(t['createdAt'])}</time></a>
· {t['likeCount']:,} likes · {t['retweetCount']:,} reposts · {t['replyCount']:,} replies
</footer>
</figure>"""
if __name__ == "__main__":
ids = ["20", "463440424141459456"]
cards = "\n".join(tweet_card(t) for t in fetch_tweets(ids))
with open("tweets.html", "w") as f:
f.write(cards)
print(f"wrote {len(ids)} cards -> tweets.html")
# Cost: twitterapi.io bills $0.15 per 1K tweets returned -> $0.0003 for these 2.
Static cards in Next.js or React (server-rendered, cached hourly)
In a framework with server rendering, the static approach is a few lines. The component below runs on the server, fetches a batch of tweets with a one-hour revalidation window, and ships plain HTML to the browser. Counts stay roughly fresh without a request per page view: with revalidate: 3600, a page that embeds 20 tweets costs at most 24 batch fetches a day no matter how much traffic it gets.
Keep the API key in a server-only environment variable (never a NEXT_PUBLIC_ one), and lazy-load avatars with explicit width and height so they cannot shift layout.
// app/components/TweetCard.tsx -- Next.js server component, rebuilt at most hourly.
// Renders on the server; ships zero JavaScript for the embed itself.
type Tweet = {
id: string; url: string; text: string; createdAt: string; likeCount: number; retweetCount: number;
author: { name: string; userName: string; profilePicture: string };
};
async function getTweets(ids: string[]): Promise<Tweet[]> {
const res = await fetch(
`https://api.twitterapi.io/twitter/tweets?tweet_ids=${ids.join(",")}`,
{ headers: { "X-API-Key": process.env.TWITTERAPI_IO_KEY! }, next: { revalidate: 3600 } },
);
if (!res.ok) return [];
return (await res.json()).tweets ?? [];
}
export default async function TweetCards({ ids }: { ids: string[] }) {
const tweets = await getTweets(ids);
return tweets.map((t) => (
<figure key={t.id} className="tweet-card">
<figcaption>
<a href={`https://x.com/${t.author.userName}`}>
<img src={t.author.profilePicture} alt="" width={40} height={40} />
{t.author.name} @{t.author.userName}
</a>
</figcaption>
<blockquote>{t.text}</blockquote>
<footer>
<a href={t.url}><time>{t.createdAt}</time></a> · {t.likeCount.toLocaleString()} likes
</footer>
</figure>
));
}
Embedding a timeline (profile or list feed)
To show a live feed instead of single tweets, X supports two embedded timeline types: Profile (latest posts from one public account) and List (latest posts from a public list). Likes, Collections and Moments timelines were retired on January 13, 2023, per X's timeline documentation, so older tutorials that show them no longer work.
Generate the code at publish.x.com by pasting a profile or list URL; you get an anchor plus widgets.js. Size it with data-width / data-height, cap the number of posts with data-tweet-limit (1–20), and strip the frame with data-chrome="noheader nofooter noborders transparent". If you hide the header, X's docs say you must add your own attribution and link to the source timeline.
Reliability caveat: timeline embeds load posts through X's logged-out syndication service. In our own checks from a single IP, the profile-timeline endpoint returned HTTP 429 (rate limited) on some requests and HTTP 200 on others within the same day, and community reports describe blank timelines too. Treat embedded timelines as best-effort. If the feed matters to the page — a status page or a product-update wall — fetch the account's latest posts on your server (TwitterAPI.io GET /twitter/user/last_tweets?userName= returns up to 20 per page) and render static cards on a schedule.
Which method should you use? Decision table + cost
Cost of the data-driven methods. Copy-paste and oEmbed are free. Static cards need tweet data. On TwitterAPI.io that is $0.15 per 1,000 tweets returned (twitterapi.io/pricing), with a $0.00015 minimum per call. The official X API charges $0.005 per post read on its pay-per-use plan (docs.x.com pricing), deduplicated within a UTC day.
Per tweet returned, $0.005 ÷ $0.00015 ≈ 33× cheaper on TwitterAPI.io. Note the honest exception in the first row: because X dedupes repeat reads of the same post within a day, small sets refreshed many times a day cost about the same on both. The gap opens as the number of distinct tweets grows.
# 1) Free embed HTML for one tweet (no key). Cache the "html" field.
curl -s "https://publish.x.com/oembed?url=https://x.com/jack/status/20&omit_script=1&dnt=true"
# 2) Tweet data for static cards, many IDs per call (TwitterAPI.io key).
curl -s "https://api.twitterapi.io/twitter/tweets?tweet_ids=20,463440424141459456" \
-H "X-API-Key: $TWITTERAPI_IO_KEY"
# -> {"tweets": [{"id": "20", "text": "just setting up my twttr", "author": {...},
# "likeCount": ..., "retweetCount": ..., "createdAt": "..."}, ...], "status": "success"}
Questions readers ask
How do I embed a tweet in HTML?
Open the tweet on x.com, click the share icon or three-dot menu, choose Embed post, and copy the snippet from publish.x.com. Paste it into your HTML. It is a plus a tag that loads widgets.js, which converts the blockquote into a rendered tweet card.
Is the Twitter (X) oEmbed API free?
Yes. https://publish.x.com/oembed needs no API key or authentication and X's documentation lists it as not rate limited. It returns the embed HTML as JSON, with a cache_age of about 100 years, so cache the result instead of calling it on every page view.
Can I embed a tweet without an iframe or widgets.js?
Yes. Fetch the tweet's data (text, author, avatar, counts) on your server and render your own HTML card. React's react-tweet library does this, and you can do it in any stack with a tweet API such as TwitterAPI.io's /twitter/tweets endpoint. Follow X's display requirements: avatar, display name and @username linked to the profile, unaltered text, and the timestamp.
Why does my embedded tweet show only as plain text?
The blockquote is the fallback shown when widgets.js does not run. Common causes: an ad blocker or consent manager blocking platform.twitter.com, a Content-Security-Policy that does not allow that host, or a single-page app that added the blockquote after the script already scanned the page — call twttr.widgets.load() after inserting new content.
Can I embed a Twitter (X) feed on my website?
Yes, as a Profile or List timeline generated at publish.x.com (Likes, Collections and Moments timelines were retired in January 2023). Timelines load through X's logged-out syndication service, which can be unreliable, so for business-critical feeds fetch the latest posts server-side and render them yourself.
Do embedded tweets slow down my page?
Somewhat. In our measurement widgets.js is 27,731 bytes gzipped, and each embedded tweet then renders inside its own iframe, which adds requests and a layout shift when the card resizes. One embed is negligible; many embeds on one page are noticeable. Lazy-loading or static cards remove most of the cost.
Do embedded tweets update if the tweet is edited or deleted?
Embeds rendered by widgets.js are drawn by X at view time, so they reflect X's current copy of the post. The blockquote text you pasted, and any static card you built, is a snapshot from when you fetched it — refresh static cards on a schedule and remove cards for tweets that no longer return data.
Continue
- X Developer Platform — oEmbed API (parameters, no auth, cache_age)
- X for Websites — JavaScript API (createTweet, widgets.load)
- X for Websites — Embedded timelines (retired timeline types)
- X developer terms — display requirements for posts
- vercel/react-tweet — static tweet rendering for React
- oEmbed specification
- X API pay-per-use pricing (per post read)
- Twitter (X) API — cluster hub
- Twitter (X) API in Python — complete guide
- Every tweet metadata field explained
- Extract tweet image and media URLs
- Bulk-download Twitter (X) photos and videos
- TwitterAPI.io vs the official X API
- X API cost breakdown
- TwitterAPI.io pay-per-use pricing
Stop reading. Start building.
Starter credits cover real testing on real data. Google sign-in, no card, no application queue.
Get an API key