Swizec Teller - a geek with a hatswizec.com

    Build privacy-focused blazing fast tweet embeds โ€“ CodeWithSwiz 30

    Twitter embeds every tweet on your page as an iframe. Loads 1.2MB of JavaScript, makes 20+ HTTP requests and uses 100+ DOM nodes. ๐Ÿ’ฉ

    You can feel your browser lagging when there's a few tweets up there. Like running with a parachute.

    Last week we set out to fix this with a Gatsby plugin for Twitter embeds without JavaScript. This week we got it working ๐Ÿค˜

    Swizec Teller published ServerlessHandbook.dev avatarSwizec Teller published ServerlessHandbook.dev@Swizec
    OMG IT WORKED! Look at those static tweet embeds so breezy and fast ๐Ÿฅณ

    The page *feels* lighter when you navigate ๐Ÿ˜
    Gonna opensource I think
    Tweet media

    Here's the full build, read on for a recap and how it works

    Full code at Github Gist, needs cleanup before opensourcing as a library ๐Ÿ˜‡

    CodeWithSwiz is a weekly live show. Like a podcast with video and fun hacking. Focused on experiments and open source. Join live Tuesday mornings

    The setup

    We're building a plugin for the gatsby-remark-embedder Gatsby plugin. Because that's what I use on my sites :)

    You can adapt this technique for anything. Even NextJS.

    It works like this:

    1. On page render (during build or server-side-render)
    2. Take tweet ID from URL
    3. Call Twitter API, get data
    4. Construct HTML
    5. Replace URL with HTML

    gatsby-remark-embedder runs my code on Markdown nodes that look potentially like tweet urls. You could turn it into an MDX plugin, or build a React component.

    When users load your page the tweets are there. In the initial HTML. No client-side JavaScript, no iframe shenanigans, no tracking.

    Grab tweet data from Twitter

    Last time we discovered that Twitter's oEmbed API, an embedding standard, doesn't give us everything we need. No author avatars, no like and reply counts, no media links.

    You can get those and more from Twitter API v2 /tweets/:id.

    We're using the opensource twitter-lite client. Looks solid, nice API, works great. ๐Ÿ‘Œ

    Initialize Twitter client

    A memoized function initializes the client.

    // src/StaticTwitterEmbed.js
    // Memoized twitter client instance
    let twitterClient = null
    async function getAppClient() {
    if (twitterClient) {
    return twitterClient
    }
    // init user-client
    const user = new Twitter({
    consumer_key: process.env.TWITTER_CONSUMER_KEY,
    consumer_secret: process.env.TWITTER_CONSUMER_SECRET,
    })
    // create app-client with higher limits
    const response = await user.getBearerToken()
    twitterClient = new Twitter({
    version: "2",
    extension: false,
    bearer_token: response.access_token,
    })
    return twitterClient
    }

    Global variable holds the client instance. If variable defined, return, if not, initialize.

    Each initialization logs into Twitter with an API call for the bearer token. You don't wanna do this for every tweet on your entire website.

    Get a tweet

    With an initialized and logged-in client, we can get a tweet using its ID.

    // src/StaticTwitterEmbed.js
    // fetch tweet from API
    async function getTweet(tweetId) {
    const client = await getAppClient()
    try {
    const tweet = await client.get(`tweets/${tweetId}`, {
    "tweet.fields": "public_metrics,created_at",
    expansions: "author_id,attachments.media_keys",
    "user.fields": "name,username,url,profile_image_url",
    "media.fields": "preview_image_url,url",
    })
    return tweet
    } catch (e) {
    console.log(e)
    }
    return null
    }

    For simplicity, we always get the client in this function. Makes logic self-contained and we aren't worried about overdoing it thanks to memoization.

    client.get(tweet/:id) fetches a tweet. Careful incantation of parameters gives us the extra info we need. Author, media, and metrics.

    Response looks like this:

    {
    data: {
    text: 'My man @Swizec went and sent me a signed first edition of this soon to be classic ๐Ÿ‘Š https://t.co/Gw8cTIY7pH',
    public_metrics: {
    retweet_count: 1,
    reply_count: 2,
    like_count: 18,
    quote_count: 0
    },
    attachments: {
    media_keys: [ '3_1388192274866126849', '3_1388192274874523648' ]
    },
    author_id: '68567860',
    id: '1388192278884331530',
    created_at: '2021-04-30T18:03:25.000Z'
    },
    includes: {
    media: [
    {
    media_key: '3_1388192274866126849',
    type: 'photo',
    url: 'https://pbs.twimg.com/media/E0PZhN9WQAEMhsJ.jpg'
    },
    // ...
    ],
    users: [
    {
    name: 'Adam Rackis',
    profile_image_url: 'https://pbs.twimg.com/profile_images/1183169082243375104/FwXKVe5H_normal.jpg',
    id: '68567860',
    username: 'AdamRackis',
    url: 'https://t.co/sJJo16akDF'
    }
    ]
    }
    }

    Fun gotcha: You have to request expansions: "author_id,attachments.media_keys" for media.fields and author.fields to work. Detail buried in API docs.

    A simplified tweet HTML

    The getHTML function tells gatsby-remark-embedder what to replace a Markdown node with. Tweet comes in as a URL.

    Before our plugin, tweets turned into <blockquote> elements that the Twitter JavaScript would turn into monster iframes on page load. Often with a big delay. Sometimes breaking on mobile.

    Now we do this ๐Ÿ‘‡

    // src/StaticTwitterEmbed.js
    async function getHTML(url) {
    const twitterUrl = url.replace("events", "moments")
    const tweetId = twitterUrl.split("/").pop()
    const tweet = await getTweet(tweetId)
    if (!tweet) {
    console.log("TWEET NOT FOUND", twitterUrl, tweetId)
    return ""
    }
    return buildTweetHTML(tweet)
    }

    We borrowed the events -> moments transform from the original plugin. Gonna need to test if that still works. ๐Ÿค”

    tweetId is the last element of the URL when split by /. This will break if you include any ?... cruft when embedding.

    Like I said, not ready yet for opensource :)

    Then we get the tweet and buildTweetHTML.

    buildTweetHTML

    This function is a mess. You can see it in full at line 63 of the Gist.

    It's a mess because we're handcrafting HTML and can't use React. No JSX transform deep in the bowels of a Gatsby plugin.

    And it's very procedural. Reads like a step-by-step recipe.

    1. Get the author
    // src/StaticTwitterEmbed.js
    // buildTweetHTML
    const author = tweet.includes.users.find(
    (user) => user.id === tweet.data.author_id
    )

    You can't guarantee author is the first user in the list. Gotta find by id.

    1. Reconstruct the tweet URL
    // src/StaticTwitterEmbed.js
    // buildTweetHTML
    const tweetURL = `https://twitter.com/${author.username}/status/${tweet.data.id}`
    1. Construct the author HTML
    // src/StaticTwitterEmbed.js
    // buildTweetHTML
    const authorHTML = `<a class="author" href="${author.url}"><img src="${author.profile_image_url}" loading="lazy" alt="${author.name} avatar" /><b>${author.name}</b>@${author.username}</a>`

    Great candidate for a React component huh ๐Ÿ˜…

    Rendered Author HTML
    Rendered Author HTML

    1. The tweet HTML is easy
    // src/StaticTwitterEmbed.js
    // buildTweetHTML
    const tweetHTML = `<blockquote>${tweet.data.text.replace(
    /https:\/\/t.co\/(\w+)/,
    ""
    )}</blockquote>`

    rendered tweet html
    rendered tweet html

    1. Media HTML is a list of images
    // src/StaticTwitterEmbed.js
    function buildMediaList(media) {
    const width = media.length > 1 ? "50%" : "100%"
    return media
    .map(
    (media) =>
    `<img src="${
    media.preview_image_url || media.url
    }" width="${width}" loading="lazy" alt="Tweet media" />`
    )
    .join("")
    }
    // buildTweetHTML
    const mediaHTML = tweet.includes.media
    ? `<div class="media">${buildMediaList(tweet.includes.media)}</div>`
    : ""

    Tried to use CSS for media widths and couldn't hack it. We know from experience that twitter renders images at 50% and in multiple rows.

    loading="lazy" is important for Lighthouse scores. Ensures you aren't loading images users can't see.

    Rendered media html
    Rendered media html

    1. Finally, the metadata HTML is a complete mess
    // src/StaticTwitterEmbed.js
    // buildTweetHTML
    const createdAtHTML = `<div class="time"><a href="${tweetURL}">${new Date(
    tweet.data.created_at
    ).toLocaleTimeString()} โ€“ ${new Date(
    tweet.data.created_at
    ).toLocaleDateString()}</a></div>`
    const likeIntent = `https://twitter.com/intent/like?tweet_id=${tweet.data.id}`
    const replyIntent = tweetURL
    const statsHTML = `<div class="stats"><a href="${likeIntent}" class="like">${likesSVG}${tweet.data.public_metrics.like_count}</a> <a href="${replyIntent}" class="reply">${repliesSVG}${tweet.data.public_metrics.reply_count}</a></div>`

    ๐Ÿคฎ

    But it works and that's what matters.

    Rendered metadata html
    Rendered metadata html

    1. Put it all together
    // src/StaticTwitterEmbed.js
    // buildTweetHTML
    return `<div><div class="static-tweet-embed">
    ${authorHTML}
    ${tweetHTML}
    ${mediaHTML}
    ${createdAtHTML}
    ${statsHTML}
    </div></div>`

    And you get a great looking tweet:

    Final rendered tweet
    Final rendered tweet

    The CSS styles

    You need CSS to make all that HTML look like a tweet.

    This little bit gets you most of the way there:

    div.static-tweet-embed {
    display: flex;
    flex-direction: column;
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
    Arial, sans-serif;
    max-width: 550px;
    width: 100%;
    margin-top: 10px;
    margin-bottom: 10px;
    border: 1px solid rgb(196, 207, 214);
    border-radius: 12px;
    padding: 12px 16px 4px 16px;
    }
    div.static-tweet-embed blockquote {
    margin: 0;
    font-size: 20px;
    padding: 0px;
    border: 0px;
    color: rgb(15, 20, 25);
    line-height: 24px;
    }

    System fonts, border, rounded corners, padding, correctly sized text.

    You can see (and borrow) the rest on the public gist.

    The result

    Your page feels faster. It's hard to explain, you have to try.

    Scroll to the bottom here ๐Ÿ‘‰ https://serverlesshandbook-4d7y3ge3f-swizec.vercel.app/

    Now scroll here ๐Ÿ‘‰ https://serverlesshandbook.dev

    Feel the difference? Reload at bottom for extra groan

    Swizec Teller published ServerlessHandbook.dev avatarSwizec Teller published ServerlessHandbook.dev@Swizec
    Twitter embeds every tweet on your page as an iframe. Loads 1.2MB of JavaScript, makes 20+ HTTP requests and uses 100+ DOM nodes. ๐Ÿ’ฉ

    I fixed it ๐Ÿ˜Š no client-side javascript, no tracking

    Read how it works, steal my code ๐Ÿ‘‰
    Tweet media

    Cheers,
    ~Swizec

    PS: yes, you can measure the impact with Lighthouse, it's not invisible โ€“ย 43 becomes 54 :)

    Swizec Teller published ServerlessHandbook.dev avatarSwizec Teller published ServerlessHandbook.dev@Swizec
    Couple tweaks to alts and the SEO score recovered

    Altho why alt="tweet media" is better for SEO than nothing ๐Ÿคทโ€โ™‚๏ธ
    Tweet media

    Did you enjoy this article?

    Published on May 12th, 2021 in CodeWithSwiz, Technical, Twitter, Gatsby, Livecoding

    Learned something new?
    Want to become an expert?

    Here's how it works ๐Ÿ‘‡

    Leave your email and I'll send you thoughtfully written emails every week about React, JavaScript, and your career. Lessons learned over 20 years in the industry working with companies ranging from tiny startups to Fortune5 behemoths.

    Join Swizec's Newsletter

    And get thoughtful letters ๐Ÿ’Œ on mindsets, tactics, and technical skills for your career. Real lessons from building production software. No bullshit.

    "Man, love your simple writing! Yours is the only newsletter I open and only blog that I give a fuck to read & scroll till the end. And wow always take away lessons with me. Inspiring! And very relatable. ๐Ÿ‘Œ"

    ~ Ashish Kumar

    Join over 10,000 engineers just like you already improving their careers with my letters, workshops, courses, and talks. โœŒ๏ธ

    Have a burning question that you think I can answer?ย I don't have all of the answers, but I have some! Hit me up on twitter or book a 30min ama for in-depth help.

    Ready to Stop copy pasting D3 examples and create data visualizations of your own? ย Learn how to build scalable dataviz components your whole team can understand with React for Data Visualization

    Curious about Serverless and the modern backend? Check out Serverless Handbook, modern backend for the frontend engineer.

    Ready to learn how it all fits together and build a modern webapp from scratch? Learn how to launch a webapp and make your first ๐Ÿ’ฐ on the side with ServerlessReact.Dev

    Want to brush up on your modern JavaScript syntax?ย Check out my interactive cheatsheet: es6cheatsheet.com

    By the way, just in case no one has told you it yet today: I love and appreciate you for who you areย โค๏ธ

    Created bySwizecwith โค๏ธ