Making dynamic Twitter header.

December 10, 2021

4 min read

Recently I saw a Twitter header that displayed images of new followers dynamically. I fell in love with this idea so I decided to create my own.

It should be simple, I will just write a simple script that will take a background image, via Twitter API downloads list of followers, their profile images and puts them into that background image. After that, via the same API, it will upload the image as a new header.

As a true developer I decided to Google how to do this and I found this amazing article by Thobias Schimdt. I shamelessly copied most of his code. I decided to deploy it differently (not on AWS). In this article, I will go over my changes.

In the end, my code looks like this.

const { TwitterClient } = require('twitter-api-client')
const axios = require('axios')
const sharp = require('sharp')
const Feed = require('rss-to-json')
const Jimp = require('jimp')
const fs = require('fs')

const numberOfFollowers = 3
const widthHeightFollowerImage = 90

function getVariable(name) {

    if (fs.existsSync(`${__dirname}/creds.json`)) {
        return require(`${__dirname}/creds.json`)[name]
    return process.env[name]

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 })

async function createBanner(headline) {
    const banner = await`${__dirname}/assets/banner.png`)
    const mask = await`${__dirname}/assets/mask.png`)
    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, x, 360);
    console.log(`Adding headline...`)
    banner.print(font, 380, 250, headline);
    await banner.writeAsync('/tmp/1500x500_final.png');

async function getLatestArticleHeadline() {
    console.log(`Retrieving headline...`)
    const rss = await Feed.parse(`${getVariable('RSS_FEED')}`)
    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}`;

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...`)
    try {
        const data = await twitterClient
            screen_name: getVariable('TWITTER_HANDLE'),
            count: numberOfFollowers
        await Promise.all(data.users
            .map((user, index) => saveAvatar(user, `/tmp/${index}.png`)))
      } catch (err) {


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'),

exports.handler = async () => {
    await getImagesOfLatestFollowers()
    const title = await getLatestArticleHeadline()
    await createBanner(title)
    await uploadBanner()
    return {
        statusCode: 200,
        body: JSON.stringify({ status: 'ok' }),

The background image I use is created by Canva Twitter Header Tool you can create an amazing header even without being good at designing things.

For Twitter API to let you download your follower info, you need to have something called Elevated API level access. More about it here.

I decided to deploy it as Netlify function. So my code is saved in the netlify/function/header.js file.

To launch this locally you can do

npm run-func netlify/functions/header.js handler

You can add this into your package.json file like this:

    "scripts": {
        "generate": "run-func netlify/functions/header.js handler"
    "dependencies": {
        "axios": "^0.24.0",
        "jimp": "^0.16.1",
        "rss-to-json": "^2.0.2",
        "run-func": "^1.0.5",
        "sharp": "^0.29.3",
        "twitter-api-client": "^1.4.0"

I store my assets in the netlify/functions/assets folder. For Netlify to deploy those files with your function you need to tell it so. You can do it with netlify.toml file in the root of your project.

  included_files = ["netlify/functions/**"]

To deploy to Netlify, just push all your code to GitHub. Login/signup to Netlify and choose your GitHub repo. Netlify will do all the magic for you. In a few seconds they will provide you with a URL you can call to trigger your function.

Great. Now we need to run this regularly so that we can catch all the new followers and articles. To do so I decided to use EasyCron. It's a super easy-to-use platform where you can say. OK call this URL every minute. For our use case, this is will be enough and will be free.

Now we have it all. We can enjoy our awesome free dynamic Twitter header.

If you like this article you can follow me on Twitter.

← Back to blog