Lambda@Edge & DynamoDB for React Rich Social Media Sharing

Andy O'Sullivan
7 min readMay 21, 2023
Source

When you have a website that you’re running on a low budget, Serverless is a great way to go — cheap, easy and fast to get up and running. For my NOadsHERE.com site I use React, hosted on S3 using Cloudfront (all on AWS).

One problem I encountered however is when I wanted to setup social media sharing for pages on the site. If you paste in a link from a site onto Twitter, Facebook etc, a photo and description is automatically added to your post if the site has correctly setup the appropriate meta tags e.g. here’s a tweet I could write on Twitter which includes a link to a page on my site:

“Our review is up! #WhiteMenCantJump is much better than we thought it’d be! @jackharlow & @SinquaWalls are the heart of it, just a fun movie, full of jokes, music, insults and a lot of heart! Definitely worth a watch https://noadshere.com/movies/white-men-cant-jump-review

Twitter displays this as:

The image, title and description were all taken from the meta tags on that page on my site.

However — the problems start with how React uses meta tags. In a React site the meta tags are on the main html page, and look something like this:

<meta property="og:image" content="https://yoursite.com/image.png">
<meta property="og:title" content="Your Amazing Sitte">
<meta property="og:description" content="One site to rule them all">
<meta property="og:url" content="https://www.yoursite.com/">

React sites are basically just one page of html, where the content (but not the meta tags) gets changed dynamically as the user navigates around the “pages”. So there’s only one set of meta tags for the site, no matter what actual page you’re on — when you want to share a different url from the site, the same title, image, description etc will be shown, which is a problem!

The solution is to have a way to dynamically change the meta tags for each url on the site. One common way to handle this is via Server side rendering (SSR) where each page is rendered dynamically by your server when it is requested. However … my site has no server, it’s deployed statically on S3!

One solution could be to add a node server, but I didn’t want to be maintaining one, so instead I went the Serverless route and decided to use a Lambda@Edge function.

I based my solution off this tutorial — https://kennethwinner.com/2020/09/11/cloudfront-ssr/ but added DynamoDB and other changes to suit my needs.

High-level steps

Presuming the initial setup is:

  • React.js site deployed on S3, fronted by a Route 53 domain connected to a Cloudfront distribution.

the steps are:

  • Create a node.js Lambda
  • Use the Actions-> Deploy to Lambda@Edge option
  • This will create a new “version”:
  • Click into your new version and in the Trigger tab on the side panel you can add a new Trigger:

Make sure you select the correct Cloudfront distribution, and that you select “Origin request” for the Cloudfront event.

  • Here’s when it’s been added:

Now, each time your Cloudfront distribution receives an origin request, this Lambda will be called.

Lambda Code

What we want to do in the Lambda is to:

  • Check what the request is — origin or response
  • Check if it’s index.html or something else
  • Retrieve the meta details from DynamoDB for the request url
  • Replace the default meta tags in the index.html with the retrieved tags
  • Return the request
  • Enjoy lovely images and text with our social media shares

Start of our Lambda function:

import { GetCommand } from "@aws-sdk/lib-dynamodb";
import { ddbDocClient } from "./lib/ddbDocClient.js";

import axios from 'axios';
import * as path from 'path';

export const handler = async (event) => {
console.log('handler called');
const { config, request } = event.Records[0].cf;

switch (config.eventType) {
case 'origin-request':
console.log('origin-request');
return await processOriginRequest(request);
case 'origin-response':
console.log('origin-response');
return await processOriginResponse(event);
default:
console.log('default');
return request;
}
}

async function processOriginResponse(event) {
const { response } = event.Records[0].cf;
return response;
}

The processOriginRequest function:

async function processOriginRequest(request) {
let originalHost = request.headers.host[0].value;
let bucketDomain = request.origin.custom.domainName;

console.log('Host: ', originalHost);
if (!originalHost) {
console.log('No Host');
return request;
}

switch (request.uri) {
case '/index.html':
console.log('switch index.html');
break;
default:
console.log('switch default');
if (path.extname(request.uri) !== "") {
request.headers['x-forwarded-host'] = [{ key: 'X-Forwarded-Host', value: originalHost }];
request.headers.host = [{ key: 'Host', value: bucketDomain }];
return request;
}
}

try {
const siteURL = `http://${bucketDomain}/index.html`;
console.log('siteURL: ', siteURL);

const indexResult = await axios.get(siteURL);
let index = indexResult.data;

let metas = {};
try{
const data = await getItemFromDB(request.uri);
console.log("data: " + JSON.stringify(data));

if (data.isError){
console.log('item.isError:' + data.isError);
//use default meta tags
metas = {
OG_TITLE: 'Your Default Title',
OG_DESCRIPTION: 'Your Default Description',
OG_IMAGE: 'address of my deafult site image.jpg',
OG_URL: 'https://www.yoursite.com' + request.uri,
OG_IMAGE_ALT: 'Amazing alt text'
}
}else{
console.log('item.title: ' + data.item.title);
//use the returned meta tags
metas = {
OG_TITLE: data.item.title,
OG_DESCRIPTION: data.item.description,
OG_IMAGE: data.item.imageUrl,
OG_URL: 'https://www.yoursite.com' + request.uri,
OG_IMAGE_ALT: data.item.imageAlt
}
}
}catch(e){
console.log("db error: " + e);
}

//switch meta tags in index.html
console.log('metas:' + metas);
for (const meta in metas) {
if (metas.hasOwnProperty(meta)) {
index = index.replace(new RegExp(`__${meta}__`, 'g'), metas[meta]);
}
}

const responseHeaders = {
'content-type': [{key:'Content-Type', value: 'text/html'}],
};

//return the request
return {
status: 200,
statusDescription: 'HTTP OK',
body: index,
headers: responseHeaders,
}
} catch (err) {
console.log(JSON.stringify(err));
}

return request;
}

Note the meta tags are retrieved in this line:

const data = await getItemFromDB(request.uri);

with the getItemFromDB function:

async function getItemFromDB (itemUrl){
console.log("getItemFromDB: itemUrl:" + itemUrl);
const params = {
TableName : 'your_table_name',
Key: {
url: itemUrl
}
}

try {
const data = await ddbDocClient.send(new GetCommand(params));
console.log("Success :", data.Item);
if (typeof data.Item === 'undefined') {
console.log("that url doesn't exist in the database");
return {isError: true};
}else{
return {isError: false, item: data.Item};
}
} catch (err) {
console.log("Error", err);
return {isError: true};
}
}

That’s the main index.js. We also have ddbClient.js:

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
// Set the AWS Region.
const REGION = "us-east-1"; //e.g. "us-east-1"
// Create an Amazon DynamoDB service client object.
const ddbClient = new DynamoDBClient({ region: REGION });
export { ddbClient };

and ddbDocClient.js:

/* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
SPDX-License-Identifier: Apache-2.0
ABOUT THIS NODE.JS EXAMPLE: This example works with the AWS SDK for JavaScript (v3),
which is available at https://github.com/aws/aws-sdk-js-v3. This example is in the 'AWS SDK for JavaScript v3 Developer Guide' at
https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/dynamodb-examples.html.

Purpose:
ddbDocClient.js is a helper function that creates an Amazon DynamoDB service document client.

*/
// snippet-start:[dynamodb.JavaScript.tables.createdocclientv3]
// Create service client module using ES6 syntax.
import { DynamoDBDocumentClient} from "@aws-sdk/lib-dynamodb";
import {ddbClient} from "./ddbClient.js";

const marshallOptions = {
// Whether to automatically convert empty strings, blobs, and sets to `null`.
convertEmptyValues: true, // false, by default.
// Whether to remove undefined values while marshalling.
removeUndefinedValues: true, // false, by default.
// Whether to convert typeof object to map attribute.
convertClassInstanceToMap: false, // false, by default.
};

const unmarshallOptions = {
// Whether to return numbers as a string instead of converting them to native JavaScript numbers.
wrapNumbers: false, // false, by default.
};

const translateConfig = { marshallOptions, unmarshallOptions };

// Create the DynamoDB Document client.
const ddbDocClient = DynamoDBDocumentClient.from(ddbClient, translateConfig);

export { ddbDocClient };
// snippet-end:[dynamodb.JavaScript.tables.createdocclientv3]

Finally, let’s also thow in the package.json for good measure:

{
"name": "your-lambda--name",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "You",
"license": "ISC",
"dependencies": {
"aws-sdk": "^2.1304.0",
"axios": "^1.2.2"
},
"type": "module"
}

Note — anytime you change the Lambda code, you’ll need to create a new version of the Lambda@Edge deployment.

DynamoDB

You’ll obviously need a DynamoDB table — we’re using a field called “url” as the table’s key — that’s used when querying the db:

const params = {
TableName : 'your_table_name',
Key: {
url: itemUrl
}
}

On your React site

For all that to work, you’ll need to change the meta tags on your actual site’s index.html to this:

<meta property="og:image" content="__OG_IMAGE__" />
<meta property="og:image:alt" content="__OG_IMAGE_ALT__" />
<meta property="og:title" content="__OG_TITLE__" />
<meta property="og:description" content="__OG_DESCRIPTION__" />
<meta property="og:url" content="__OG_URL__" />

They are used by this piece of code we added earlier:

//switch meta tags in index.html
console.log('metas:' + metas);
for (const meta in metas) {
if (metas.hasOwnProperty(meta)) {
index = index.replace(new RegExp(`__${meta}__`, 'g'), metas[meta]);
}
}

and that should be it!

I don’t think I’ve forgotten any steps, if you think I have please leave a comment below and I’ll take a look! Otherwise, I hope this helps some folks who are hitting the same problem I was!

Here’s some more official info about Lambda@Edge: https://aws.amazon.com/blogs/networking-and-content-delivery/lambdaedge-design-best-practices/

Feel free to connect on LinkedIn or Twitter!

--

--