Dynamic link previews with a React SPA using AWS Lambda@Edge

Gareth Cronin
6 min readAug 13, 2022

--

I’ve written a couple of times about a progressive web app that I built called Bar Tool. It’s a mobile-first app that helps mixology enthusiasts like me riff on classic drink recipes. Last year I explained how I use a Google Sheet to hold a library of recipes and Firebase to store user data. I recently wrote how I added image uploads to the app for user recipes.

Bar Tool is working well, but I noticed that when I share a link to a specific recipe with someone on Slack, WhatsApp, Messenger, Discord etc, it just shows the bare URL. However, if I share a news story or a video from a commercial site, it renders a card with a title, description, and an image. Here’s an example of what happens in Discord when a YouTube video is shared:

Link preview in Discord

When I share a link to a cocktail recipe I would like something similar to happen. Ideally I’d like the recipe name, a short description, and a picture of the served drink. This data all lives in Google Sheets and Firebase already, so I started investigating.

I turns out that lining up the data for these previews is a bit of a dark art. Most platforms look for OpenGraph tags, which are HTML meta tags in the head of the HTML content. The big platforms have their own special tags that can be provided alongside these. For the main index.html page that is served up when a user visits the root of my app domain I settled on these:

This worked fine for serving up the generic title and description, but what I really want is something much more dynamic. If a user visits https://bartool.apps.cronin.nz/recipe/standard/Boulevardier then I’d like all those tag values to change to relevant names, descriptions, and images.

SPAs and meta tags

Like most React devs, I use Create React App (CRA) to manage my front end code. CRA’s template has a static public/index.html with a root div and a link tag in the head to load the main bundled JavaScript application. There isn’t any room in this for dynamic content, because the head has to be static in order to load the script!

I thought I might have found a solution with an NPM module called Helmet. Helmet provides the facility to mess with the content of the HTML head in React JSX code. I tried this with no joy. The social media platforms base their previews on the head that is first loaded in the HTML. They don’t attempt to execute the JavaScript before reading the meta tags.

The only solution on offer seemed to be to do some server-side rendering for the index.html page. A bit of Googling just turned up articles about running a Node.js/Express process to do just that. I could do that, but that would violate two of my golden principles for the stack I use for building tiny apps:

  • I only want to pay for what I use (scale-to-zero)
  • I don’t have time for maintenance activities (no patching servers, automated scale-up)

I host my apps in an AWS S3 bucket, with the AWS CloudFront CDN doing the HTTP serving and cacheing. If I have to run an Express service, I’m no longer getting the benefits of server-less infrastructure that support my golden principles. Ugh.

Edge compute

I’d seen and saved a number of articles on Hacker News about the growing support for compute at “the edge”. The cloud platforms have been adding services that allow developers to create lightweight functions that run out on or near the CDN. Traditional function-as-a-service (Google Cloud Functions, AWS Lambda) was limited to middle-tier compute, but new products are providing interesting server-less ways to do quick computations right near the user. I wondered if I could insert a function into the web pipeline that would swap out my default meta tags for dynamic ones based on the recipe that was being shared.

CloudFront Functions vs Lambda@Edge

This excellent guide explains the important differences between two AWS products for edge computing. This diagram summarises the architecture and where the compute happens:

https://trackit.io/cloudfront-functions-vs-lambdaedge-which-one-should-you-choose/

For my initial implementation, I knew I could get away with URL naming conventions to generate the dynamic content, but also that I would probably need to load the base index.html from it’s usual home in AWS S3 and that eventually I would also need to read from Firebase to do link previews for recipes contributed by users. CloudFront Functions don’t support filesystem access, so I decided to go with Lambda@Edge.

CloudFront and Lambda@Edge

CloudFront models serving web content as a “distribution” with a set of “cache behaviours”. My needs are usually very simple and I have one default cache behaviour for one distribution. When I update my web content in S3, I run an “invalidation” on the distribution and it copies the content out to the edge locations.

For this use case though, I wanted to maintain the default behaviour of serving the index.html out of S3, but if a user (or social media platform generating a link preview) browses directly to recipe URL, then I want to intercept it and serve up a dynamic set of meta tags. That means https://bartool.apps.cronin.nz returns the default index.html, whereas /recipe/standard/Bronx returns an index.html with custom values in the meta tags. In CloudFront terms, I want a cache behaviour for /recipe/standard/* where the “viewer request” “function association” is mapped to a Lambda that can generate the tags.

Lambda@Edge

AWS provide a basic tutorial on setting up a function in the console.

Forming a response

I started out trying to use an HTML parser to read my default index.html so I could programatically modify the DOM. I tried several and all of them choked at various points on a very minimal HTML file, so I gave up and went with the good old fashioned find-and-replace approach:

Swapping out meta tags

The handler for the Lambda is no different to writing a regular Lambda that has API Gateway or similar attached to it:

Deployment

Deploying Lambda@Edge with Github Actions was the first time I’ve run into a situation where there wasn’t a handy community action ready to go. I did find a useful NPM module for local deployments, but there was a bit of hand cranking to do to get it working correctly on Github.

This marketplace action worked for the actual Lambda deployment, but the way the CloudFront function association works makes the next part a bit tricky. CloudFront refuses to use the $LATEST version — it has to be a URN with an integer version on the end. But, the integer version is only available once the Lambda has been deployed. The Github action needs to:

  • deploy the Lambda code
  • find the most recent version that is not $LATEST
  • find the function association and update it to that version
  • invalidate the cache

I haven’t had to add shell calls to a Github Action before and I was very please to discover that aws-cli is already in the environment by default, with the ability to read credentials from secrets, and even jq is already installed!

My Github YAML ended up like this:

Some final troubleshooting

There were a few more gotchas when it came to testing link previews:

  • Google Chat/Hangouts/Meet doesn’t like relative URLs — I had to make the image URLs absolute
  • Slack is very permissive and was the first to render a preview, which misled me, because…
  • I’d left out the content-type header in my original Lambda response and WhatsApp, Discord, and Google refused to render a preview until I’d realised and added it
  • There are strict requirements on image size for most platforms (fortunately my images are pretty small anyway)
  • My Github action is still a bit flaky because the community action I’ve used for CloudFront updates doesn’t wait for the Lambda to become active — I’m going to add a poll for that to the steps

Final thoughts

Good news! I didn’t have to compromise on scale-to-zero and server-less to be able to serve link previews for social media. I’m going to keep chipping away at this, as I suspect there are more things I can do with this architecture to improve SEO performance for my app.

Discord preview for a cocktail recipe

--

--

Gareth Cronin
Gareth Cronin

Written by Gareth Cronin

Technology leader in Auckland, New Zealand: start-up founder, father of two, maker of t-shirts and small software products

Responses (2)