How To Build a Self-Updating Twitter Banner With Dynamic Content

How To Build a Self-Updating Twitter Banner With Dynamic Content

Display your latest blog articles or your most recent followers with Lambda and EventBridge

The first impression of somebody clicking on your Twitter profile will be your header image. Why not display some dynamic content? There’s no rocket science involved to achieve this, and the possibilities are limitless.

In this article, I want to show you how to easily build this with a simple Node.js script, Twitter's and Medium's APIs, and AWS Lambda.

An overview:

  • getting credentials so we can use the Twitter API.

  • creating a Node.js script for retrieving details of our latest followers.

  • retrieving our latest blog article via Medium’s RSS feed.

  • updating a static header image with the images of our followers and our blog article title.

  • putting everything into a Lambda function on AWS which will be executed every 60 seconds.

The architecture of an self updated Twitter Header with Lambda and EventBridge

💡 Even though we’ll be creating infrastructure at AWS, this solution won’t result in any costs due to AWS’ free tier for Lambda.

Disclaimer and Credits: I’ve seen this first on Tibo’s timeline, so I’m definitely not the first one to provide a solution on this and you can also find a lot of other tutorials that helped me a lot when I was stuck at some point taking this journey myself.

Getting started

Let’s set up a new project either via npm or yarn and add the dependencies we need:

  • axios — a simple but powerful HTTP client

  • jimp — image manipulation

  • sharp — converting response buffers into images

  • twitter-api-client — communicating with the Twitter API

  • rss-to-json — for translating XML (Medium Feeds) to JSON

  • serverless — provisioning our AWS infrastructure via Serverless Framework. Install this globally!

We’ll only create a single scripting file at src/handler.js containing all of our needed operations.

Accessing the Twitter API

The next thing we need is access to the Twitter API. So we need to create a new client application at Twitter’s developer portal.

Twitter API

After creating your app, make sure to copy the API key and the API Secret Key. After getting back to the overview screen, select your new app and click on edit on the top right. Make sure to set full permissions (Read and Write and Direct Messages) so we can later access all of our needed information and update our banner.

Permissions on Twitter API

The last step is to create our Access Token and Secret. You can do this by selecting your app and switching to the tab Keys and token right beneath the headline.

Keys in Twitter API

Most likely, Twitter will also display your API Key and API Secret Key again together with your new Access Token and Access Token Secret. Note down everything. You can’t display those details again!

Initializing our Twitter client

Now that we’ve got our credentials, we can set up our client so we can communicate with Twitter’s API. At first, let’s put all of our keys and secrets and our Twitter & Medium handle into creds.json on the root level.

  "TWITTER_API_KEY": "5Hn9Z28...BW4lEzD",  
  "TWITTER_API_SECRET_KEY": "yndh2s5sXLuh...33sbWsbbREZYp4",  
  "TWITTER_API_ACCESS_TOKEN": "42399689...jQIBa3Ew4RDi5ed",  
  "TWITTER_API_ACCESS_SECRET": "QgYTQoIueX5S...JHk44vVuty9ewwo",  
  "TWITTER_HANDLE": "tpschmidt_",  
  "MEDIUM_HANDLE": "tpschmidt"  

Now we can create the client with the following code:

const { TwitterClient } = require('twitter-api-client')

function getVariable(name) {
    if (fs.existsSync('../creds.json')) {
        return require('../creds.json')[name]
    return process.env[name]

const twitterClient = new TwitterClient({
    apiKey: getVariable('TWITTER_API_KEY'),
    apiSecret: getVariable('TWITTER_API_SECRET_KEY'),
    accessToken: getVariable('TWITTER_API_ACCESS_TOKEN'),
    accessTokenSecret: getVariable('TWITTER_API_ACCESS_SECRET'),

For local testing, we’ll directly fall back to our creds.json file and when deploying to AWS, we’ll use our environment variables which we’ll set up later on.

Retrieving Our Latest Followers

Let’s use the Twitter API client to get our latest followers. The response consists of a list of users that each contain profile_image_url_https.

We can download the avatars of the users and then resize the image via sharp and save it to a temporary file.

const axios = require('axios')
const sharp = require('sharp')

const numberOfFollowers = 3
const widthHeightFollowerImage = 90

async function saveAvatar(user, path) {
    console.log(`Retrieving avatar...`)
    const response = await axios({
        url: user.profile_image_url_https,
        responseType: 'arraybuffer'
    await sharp(
        .resize(widthHeightFollowerImage, widthHeightFollowerImage)

async function getImagesOfLatestFollowers() {
    console.log(`Retrieving followers...`)
    const data = await twitterClient
            screen_name: getVariable('TWITTER_HANDLE'),
            count: numberOfFollowers
    await Promise.all(data.users
        .map((user, index) => saveAvatar(user, `/tmp/${index}.png`)))

Working With Medium’s Personal RSS Feeds

Next, we want to get the latest blog article title from our own Medium feed. If you’re running a blog on Medium, all of your timelines metadata is available at [](

As we’re working with JavaScript, we want to covert the XML response into JSON with rss-to-json. Afterward, we can easily extract our title. Adding a little bit of padding left and right depending on the size of the title assures us that our title is always aligned centrally for a specific point on our header.

const Feed = require('rss-to-json')

async function getLatestArticleHeadline() {
    console.log(`Retrieving headline...`)
    const rss = await Feed.load(`${getVariable('MEDIUM_HANDLE')}`)
    const title = rss.items[0].title
    console.log(`Retrieved headline: ${title}`)
    // add padding left & right to align it properly
    const padding = ' '.repeat(Math.ceil((60 - title.length) / 2))
    return `${padding}${title}${padding}`;

Surely, you’re not limited to Medium. You can fetch anything at this point, even a plain HTML page, and extract the things you want to show.

Updating Our Banner Image

The tricky part is already done now. We’ve retrieved our dynamic content! Now we only need to add a base image for our header into our project into a new assets folder. Here’s an example of mine:

My Twitter Banner

As you see, I’ve left out space where I want to fill in the blog title as well as the pictures of the latest followers. Depending on your image, you have to later adjust the coordinates where we add the overlays for our dynamic content.

Additionally, I’m using another image to mask our follower’s images to have rounded borders. It’s just a white-filled circle with a black background. Make sure to also have it in the same size as our follower’s images (in the example we’re using 90x90 pixels). If you’re preferring squares, just remove the mask calls.

const Jimp = require('jimp')

async function createBanner(headline) {
    const banner = await`${__dirname}/../assets/${bannerFileName}`)
    const mask = await`${__dirname}/../assets/${maskFileName}`)
    const font = await Jimp.loadFont(Jimp.FONT_SANS_32_WHITE)
    // build banner
    console.log(`Adding followers...`)
    await Promise.all([...Array(numberOfFollowers)].map((_, i) => {
        return new Promise(async resolve => {
            const image = await`/tmp/${i}.png`)
            const x = 600 + i * (widthHeightFollowerImage + 10);
            console.log(`Appending image ${i} with x=${x}`)
            banner.composite(image.mask(mask, 0, 0), x, 350);
    console.log(`Adding headline...`)
    banner.print(font, 380, 65, headline);
    await banner.writeAsync('/tmp/1500x500_final.png');

There’s not a lot of magic involved:

  • we’re loading our assets and our pre-saved follower’s images

  • we’re setting font, color, and size for our text

  • we add our images and our headline to the banner

The result is a new file at 1500x500_final.png file at /tmp.

My Twitter Banner with the latest data

Finally, we just need to update our new header on Twitter!

async function uploadBanner() {
    console.log(`Uploading to twitter...`)
    const base64 = await fs.readFileSync('/tmp/1500x500_final.png', { encoding: 'base64' });
    await twitterClient.accountsAndUsers
        .accountUpdateProfileBanner({ banner: base64 })

Automating Everything With Lambda and Eventbridge Rules

Now we’ve got some local scripts to update our banner. But we want to run this continuously and automatically as often as the Twitter API allows it.

We can easily do this by leveraging AWS Lambda. If you haven’t started with AWS yet, just register yourself (it’s free and you’ll start with additional free limits in the first year, even though there’s a lot of free base contingent for several services like Lambda already).

Afterward, there are just a few things you need to do beforehand:

  • visit your Security Credentials to generate a new key and corresponding secret.

  • install the AWS CLI (e.g., via homebrew).

  • run aws configure to set your credentials from the first steps.

That’s it! You’re good to go.

We already installed Serverless Framework globally so we can initialize a new template file by running sls or serverless. We’ll end up with a new serverless.yml template file that defines all of our infrastructures.

What we want to have at the end:

  • a Lambda function that runs our script.

  • a Lambda Layer that contains our dependencies.

  • an EventBridge rule that triggers our function every 60 seconds.

With Serverless Framework, that’s super simple to build and only a few lines of code.

service: twitter-banner

frameworkVersion: '2'

  appName: twitter-banner

  lambdaHashingVersion: 20201221
  name: aws
  runtime: nodejs12.x
  region: eu-central-1
  logRetentionInDays: 7

      artifact: deploy/
    name: ${self:custom.appName}-common-layer
    description: Common dependencies for Lambdas
      - nodejs12.x
    retain: false

  individually: true

    handler: src/handler.handler
      artifact: deploy/
    name: ${self:custom.appName}
    description: Function to regularly update the Twitter banner
    reservedConcurrency: 1
    memorySize: 2048
    timeout: 10
      - { Ref: CommonLambdaLayer }
      - schedule: rate(1 minute)
      TWITTER_API_KEY: ${file(creds.json):TWITTER_API_KEY}
      TWITTER_HANDLE: ${file(creds.json):TWITTER_HANDLE}
      MEDIUM_HANDLE: ${file(creds.json):MEDIUM_HANDLE}

Let’s have a look at the three major parts:

  • provider — defines that we want to use AWS and sets a few basics like that we want our logs to expire after seven days so we don’t introduce any costs in the future.

  • layers — defines our layer that contains all of our node_modules dependencies.

  • functions — that's where we create our Lambda function.

With the events field, we define that EventBridge will invoke our function regularly.

Before we get to the packaging of our files, we need to add a proxy function to our script which will be later invoked by Lambda.

module.exports.handler = async () => {
    await getImagesOfLatestFollowers()
    const title = await getLatestArticleHeadline()
    await createBanner(title)
    await uploadBanner()

If you have a second look, you’ll see that we referenced src/handler.handler in the serverless.yml. In our case, the source can be found at src/handler.js. This tells Lambda where to start the execution of our node script.

Final part: packaging our function and our layer.

#!/usr/bin/env bash

rm -rf deploy
mkdir deploy

# packaging lambda
zip -rq deploy/ src/* assets/*

# packaging layer
mkdir -p deploy
rm -rf tmp/common
mkdir -p tmp/common/nodejs
cp package.json tmp/common/nodejs
pushd tmp/common/nodejs 2>&1>/dev/null
  docker run -v "$PWD":/var/task lambci/lambda:build-nodejs12.x npm install --no-optional --only=prod
popd 2>&1>/dev/null
pushd tmp/common 2>&1>/dev/null
    rm nodejs/package.json
    zip -r ../../deploy/ . 2>&1>/dev/null
popd 2>&1>/dev/null
if [[ ! -f deploy/ ]];then
    echo "Packaging failed! Distribution package ZIP file could not be found."
    exit 1

That’s it! We can now just deploy our whole stack via sls deploy and we’re good to go. If somethings not working out, have a look at the corresponding CloudWatch log streams to find the underlying issue. If everything runs smooth, you’ll see our debug logs:

INFO   Retrieving followers...  
INFO   Retrieving avatar...  
INFO   Retrieving avatar...  
INFO   Retrieving avatar...  
INFO   Retrieving headline...  
INFO   Retrieved headline: Distributed Tracing Matters  
INFO   Adding followers...  
INFO   Appending image 0 with x=600  
INFO   Appending image 1 with x=700  
INFO   Appending image 2 with x=800  
INFO   Adding headline...  
INFO   Uploading to twitter...

If you made it here and everything worked out: nice work! 🎉

💡 Don’t be scared that your code will be invoked every 60 seconds because as mentioned before, there’s a free tier for Lambda on AWS which includes 1m executions and computation seconds per month. As our function only takes a few seconds to finish, we’ll never exceed this in any way.

You can find all of the code on GitHub. If there are any issues or you’re stuck with the descriptions, don’t hesitate on messaging me on a contact channel of your choice.

Wrap up

Even though it’s not a unique thing anymore, having some fun with dynamic content on your Twitter timeline can have benefits in building your audience. Also, you maybe learned something new and enjoyed the process of building this.

Besides that, there are basically no limits on what you can do and add. You can also automate updates to your bio or your avatar.

Be creative, and show it to the world.