Static tweets in Astro

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

April 6, 2022
Last modified April 12, 2022

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.

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 v.2 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 v.2-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 v.2 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} target="_blank" rel="noreferrer noopener">${url.display_url}</a>`
          )
        } else {
          text = text.replace(
            url.url,
            ``
          )
        }
      } else {
        text = text.replace(
          url.url,
          `<a href=${url.url} target="_blank" rel="noreferrer noopener">${url.display_url}</a>`
        )
      }
    } else {
      text = text.replace(
        url.url,
        `<a href=${url.url} target="_blank" rel="noreferrer noopener">${url.display_url}</a>`)
    }
  })
}

if (JsonData.entities.mentions) {
  JsonData.entities.mentions.forEach((mention) => {
    text = text.replace(
      `@${mention.username}`,
      `<a target="_blank" 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 target="_blank" 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}` target="_blank" 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}` target="_blank" rel="noreferrer noopener">{name}</a>
      <a class="tweet-author-handle" href=`https://twitter.com/${username}` target="_blank" 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" target="_blank" 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 3_1487140197125005314 from Twitter

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" />

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. ↩︎

Commenting by giscus.

Other posts

Next:

Previous: