Static tweets in Astro

A component which properly embeds tweets when you’re using today’s hottest SSG.

2022-04-06

Update, 2022-04-12: The Astro team has come up with an astro-embed project which will accomplish everything described herein and much more, so I encourage you to use it rather than the component described herein. That said, perhaps this post will still be of some educational value, especially for those new to Astro.

Update from the future in general (!): Any displayed tweets in this or other posts obviously will be rendered by the most current code available when the site is on Hugo, so that code and what is shown below for Astro aren’t necessarily related.

Please use the site search page to find related posts.

Perhaps you saw one or both of my earlier posts about how to embed fully static (thus, not privacy-violating) tweets in the Eleventy and Hugo static site generators (SSGs). If not, you may want to read at least the first one for background because, in this post, I offer a similar—albeit briefer—piece about how to do this with the Astro SSG.

At least for now, Astro seems to be the darling of a considerable portion of the web development world. Better yet for my purposes and yours: after months of alpha-level breaking changes out the wazoo, it’s now stabilized considerably. In fact, a few days ago, the Astro team announced the first beta release of v.1.0.

As was true for my recent Hugo shortcode for this purpose, the Astro component which follows is fully compliant with v2 of the Twitter API, the version toward which Twitter is trying hard to drive developers.

The preliminaries

Install date-fns

First, you’ll need to install the date-fns package, so we can properly format the created_at date we’ll get from the Twitter API for each tweet this component will render:

npm i -D date-fns

Use a Bearer Token

As was true with the v2-compliant Hugo shortcode from that earlier post, you’ll need a Twitter-assigned Bearer Token, which you’ll then set within an environment variable with PUBLIC_ at the beginning of its name.1

  • For development, store it as PUBLIC_BEARER_TOKEN in your Astro project’s top-level .env file (suitably Git-ignored, of course).
  • For production and deployment, store it as PUBLIC_BEARER_TOKEN within your chosen host’s environment variables, however that’s done there.2

Build the component

Now, you can create the component in, of course, your Astro project’s top-level src/components/ folder (unless you’ve otherwise configured the site structure).

The following is my STweetV2.astro3 component, based on the logic and styling of my previously posted Hugo shortcode for interacting with v2 of the Twitter API:

Note: When I wrote this, a bug caused Astro to crash if an import statement was anywhere other than the top of the file, so that’s why I don’t have the comments at the top as might otherwise seem logical.

---
import { format } from "date-fns"

/*
	=======
	Based on...
	- https://github.com/hugomd/blog/blob/6ad96b24117255c2a9912c566ffd081bd9bbd6f1/layouts/shortcodes/statictweet.html
	- https://hugo.md/post/update-rendering-static-tweets/
	- https://github.com/KyleMit/eleventy-plugin-embed-tweet
	- https://github.com/rebelchris/astro-static-tweet/blob/master/StaticTweet.astro
	=======
*/

const { TweetID } = Astro.props

const BearerToken = import.meta.env.PUBLIC_BEARER_TOKEN
const jsonURL1 = "https://api.twitter.com/2/tweets?ids="
const jsonURL2 = "&expansions=author_id,attachments.media_keys&tweet.fields=created_at,text,attachments,entities,source&user.fields=name,username,profile_image_url&media.fields=preview_image_url,type,url,alt_text"

const response = await fetch(jsonURL1 + TweetID + jsonURL2, {
	method: "get",
	headers: {
		"Authorization": `Bearer ${BearerToken}`
	}
})
const Json = await response.json()
const JsonData = Json.data[0]
const JsonIncludes = Json.includes

let text = ''; let created_at = ''; let profile_image_url = ''; let name = ''; let username = ''

name = JsonIncludes.users[0].name
username = JsonIncludes.users[0].username
profile_image_url = JsonIncludes.users[0].profile_image_url
created_at = JsonData.created_at

text = JsonData.text

if (JsonData.entities.urls) {
	JsonData.entities.urls.forEach((url) => {
		if (!url.images) {
			if (!url.unwound_url) {
				if (url.display_url.includes ("buff.ly")) {
					text = text.replace(
						url.url,
						`<a href=${url.url} rel="noreferrer noopener">${url.display_url}</a>`
					)
				} else {
					text = text.replace(
						url.url,
						``
					)
				}
			} else {
				text = text.replace(
					url.url,
					`<a href=${url.url} rel="noreferrer noopener">${url.display_url}</a>`
				)
			}
		} else {
			text = text.replace(
				url.url,
				`<a href=${url.url} rel="noreferrer noopener">${url.display_url}</a>`)
		}
	})
}

if (JsonData.entities.mentions) {
	JsonData.entities.mentions.forEach((mention) => {
		text = text.replace(
			`@${mention.username}`,
			`<a rel="noreferrer noopener" href="https://twitter.com/${mention.username}">@${mention.username}</a>`
		)
	})
}

if (JsonData.entities.hashtags) {
	JsonData.entities.hashtags.forEach((hashtag) => {
		text = text.replace(
			`#${hashtag.tag}`,
			`<a rel="noreferrer noopener" href="https://twitter.com/hashtag/${hashtag.tag}?src=hash&ref_src=twsrc">#${hashtag.tag}</a>`
		)
	})
}

text = text.replace(/(?:\r\n|\r|\n)/g, '<br/>')

let imageItems = ''

if (JsonIncludes.media) {
	JsonIncludes.media.forEach((item) => {
		if (item.url) {
			imageItems = imageItems + `<img class="tweet-img" src=${item.url} alt="" /><br />`
		}
	})
}

---

<blockquote class="tweet-card">
	<div class="tweet-header">
		<a class="tweet-profile" href=`https://twitter.com/${username}` rel="noreferrer noopener">
			<img src={profile_image_url} alt=`Twitter avatar for ${username}` />
		</a>
		<div class="tweet-author">
			<a class="tweet-author-name" href=`https://twitter.com/${username}` rel="noreferrer noopener">{name}</a>
			<a class="tweet-author-handle" href=`https://twitter.com/${username}` rel="noreferrer noopener">@{username}</a>
		</div>
	</div>
	<p class="tweet-body" set:html={text} />
	<span set:html={imageItems} />
	<div class="tweet-footer">
		<a href=`https://twitter.com/${username}/status/${TweetID}` class="tweet-date" rel="noreferrer noopener">{format(new Date(created_at), "MMMM d, yyyy • h:mm aa")}</a>&nbsp;<span class="legal">(UTC)</span>
	</div>
</blockquote>

Call the component

Tip: use aliases

Make things easier on yourself—i.e., avoiding a lot of relative file references—by using Astro’s aliases feature. In your project’s appropriate file (jsconfig.json or, if you’re using TypeScript, tsconfig.json), have at least the following:

{
	"compilerOptions": {
		"baseUrl": ".",
		"paths": {
			"@components/*": ["src/components/*"]
		}
	}
}

From here on, we can refer to a component’s location as simply @components (i.e., without having to figure out how many ../ parts to add to components for the relative reference which otherwise would be necessary).

Another nice thing about using Astro aliases is that most Astro-savvy code editors, such as Visual Studio Code when it’s running the Astro support extension, automatically “know” about the aliases once you set them.

Calling from an .astro file

To use the component in an .astro file, put this in the top of the file’s Component Script part:

---
import STweetV2 from '@components/STweetV2.astro'

. . . and call it within the file as follows, supplying the tweet’s ID as the value of TweetID:

<STweetV2 TweetID="1487140202141425673" />

.  . . which gives you (with appropriate CSS4):

Got my swag! @CloudflareDev #CloudflareDevChallenge2021
Image from tweet 1487140202141425673

As was true for my Hugo stweetv2 shortcode, I wrote this component to add “(UTC)” after the date because, once you use this in production, the remote web server will return the tweet’s created_at information in whatever time zone the server uses—which almost certainly is UTC.

Calling from Markdown

To use the component in a Markdown file in Astro, you’d simply put this in the top of the front matter:

---
setup: |
	import STweetV2 from '@components/STweetV2.astro'

. . . along with any other such items that belong in the setup object, of course.

Finally, down in the Markdown itself, call the imported component the same way as we described above for its use in the .astro file:

<STweetV2 TweetID="1487140202141425673" />

Update, 2022-07-26: Astro has since moved to using MDX, rather than Markdown, for including components in one’s markup. Be sure to check the most current Astro documentation for full details.

An escapee from the lab?

As I noted recently, I’m now once again actively experimenting with Astro, having let it be for a while when it was a tad too skittish for my limited skill set. The component I’ve provided in this post is one example of that experimentation.

If you’re building a site with Astro, perhaps this component can be at least a starting point toward your embedding tweets without endangering your visitors’ privacy.


  1. Based on what I’ve read in recent weeks, I think the requirement to start the environment variable’s name with PUBLIC_ is related to the interaction between Astro and the Vite package it uses for server operations in both development and production. ↩︎

  2. To repeat what I said in the two previous posts about this subject: “.  .  . you’ll have to supply .  . . [the credentials] to your site host, so it can access them during each build (e.g., here are instructions for Netlify, Vercel, and Cloudflare Pages).” ↩︎

  3. As of this writing, this site’s syntax highlighting doesn’t “know” what an .astro file is, so I’m improvising by telling the highlighter it’s JavaScript. That’s partly true, since the top part is JS, but the bottom part is a mixture of JS and HTML. ↩︎

  4. Use your browser’s Inspector tool on the displayed tweet to see how I styled it. Of course, you should feel free to handle styling as you see fit. ↩︎

View/hide comments

Commenting by giscus.

Next:

Previous: