Entry point to my React JS Single Page App is https://www.wisaw.com
You can navigate around the app by clicking different links and buttons in the app -- it will update the browser URL bar correctly, and even the meta tags required by the SEO will also be properly updated dynamically via really cool react module called helmet.
You can navigate to https://www.wisaw.com/photos/23377 , and see the meta tags updating correctly in the developers console, however, if you view page source for that URL -- it will always show the contents of the bare bones index.html file, which was used to bootstrap react app.
If you type the URL https://www.wisaw.com/photos/23377 into the browser, and hit enter -- it will not be able to map that URL to a specific resource in s3 bucket, so it will redirect it to index.html and, instead of 404, will forcefully return 200 http response (this is how my CloudFront distribution is configured -- see below). As such it will still load index.html, which will bootstrap React app into the only div tag specified in the body, and, only then , it will use @react-navigation to render the proper route, which corresponds to the requested URL.
Conclusion -- index.html is a static file, which always shows as a page source, regardless of the URL you are requesting (not to be confused with the dynamic DOM which you can inspect in the developer tools), either by navigating to it via following application links or/and buttons, or entering the link into the Browser's URL bar.
Most of Search engine crowler bots these days typically do execute JavaScript to honer dynamic nature of SPAs. However, when you post a link like https://www.wisaw.com/photos/23377 to one of social media sites (Twitter, FaceBook, LinkedIn), or share it with your friend via SMS -- it will look for OG tags in the html source, and will not find any OG tags (remember, index.html is static), and will not render any image previews.
The first things that comes to mind -- we have the URL string available in the request, if ,some how, we can intercept the HTTP request, and dynamically inject the OG tags to the response body based on the context, it should work.
And this is exactly what we are about to describe in our solution down below.
First, let's see how to define the needed elements in CDK (read the inline comments which explain how it works):
// describing the bucket which hosts the react SPA code
const webAppBucket =
s3.Bucket.fromBucketName(
this,
`wisaw-client`,
`wisaw-client`
)
webAppBucket.grantPut(generateSiteMap_LambdaFunction)
webAppBucket.grantPutAcl(generateSiteMap_LambdaFunction)
// lambda@edge function for ingecting OG meta tags on the fly
const injectMetaTagsLambdaFunction =
new cloudfront.experimental.EdgeFunction(
this,
`${deployEnv()}_injectMetaTagsLambdaFunction`,
{
// let's pick the latest runtime available
runtime: lambda.Runtime.NODEJS_16_X,
code: lambda.Code.fromAsset(path.join(__dirname, '../lambda-fns/lambdas/injectMetaTagsLambdaFunction')),
handler: 'index.main',
// the max memory size for Lambda Edge function is 128 MB,
// which is significantly lower than for regular Lambda function
// Hopefully this will not make my lambda function to execute on
// the lower end hardware,
// and will still allocate fastest infrastructure -- I want
// my Lambda Edge to be As Fast as Possible and not introduce
// too much latency
memorySize: 128,
// The lambda Edge max timeout is 5 sec (unlike in regular Lambda),
// which is good -- we do not want our Lambda Edge to ever
// become a bottleneck for the entire system
timeout: cdk.Duration.seconds(5),
// logRetention is declared like this:
// const logRetention = logs.RetentionDays.TWO_WEEKS
logRetention,
}
)
// Origin access identity for cloudfront to access the bucket
const myCdnOai =
new cloudfront.OriginAccessIdentity(this, "CdnOai");
webAppBucket.grantRead(myCdnOai);
// Describing the CloudFrontWebDistribution -- remember
// to add the proper CNAME to your DNS when you
// create a new CloudFrontWebDistribution.
// I do it manually, but you can probably figure out how
// to script in in CDK, especially if you are using Route53
new cloudfront.CloudFrontWebDistribution
(this, "wisaw-distro", {
originConfigs: [
{
// this CloudFrontWebDistribution works with the bucket
// where we deploy our react app code
s3OriginSource: {
s3BucketSource: webAppBucket,
originAccessIdentity: myCdnOai,
},
behaviors: [
{
// see errorConfigurations down below which will define
// the default behavior
isDefaultBehavior: true,
compress: true,
},
{
// for any request that matches the /photos/* pattern,
// it will use the following definition
pathPattern: 'photos/*',
compress: true,
allowedMethods:
cloudfront.CloudFrontAllowedMethods.ALL,
minTtl: cdk.Duration.days(10),
maxTtl: cdk.Duration.days(10),
defaultTtl: cdk.Duration.days(10),
forwardedValues: {
queryString: true,
cookies: {
forward: 'all'
}
},
// this is the function which will execute for this pathPattern
lambdaFunctionAssociations: [
{
// it will invoke the function during
// cloudfront.LambdaEdgeEventType.VIEWER_REQUEST lifecycle stage
eventType:
cloudfront.LambdaEdgeEventType.VIEWER_REQUEST,
// see the function source code down below
lambdaFunction: injectMetaTagsLambdaFunction,
includeBody: true, // it really does not matter
},
]
}
],
},
],
aliasConfiguration: {
acmCertRef: "arn:aws:acm:us-east-1:963958500685:certificate/538e85e0-39f4-4d34-8580-86e8729e2c3c",
// our CloudFrontWebDistribution will be attached to our app url
names: ["www.wisaw.com"]
},
errorConfigurations: [
{
errorCode: 403,
responseCode: 200,
errorCachingMinTtl: 31536000,
responsePagePath: "/index.html"
},
{
// when we request like https://www.wisaw.com/search/Leaf,
// it will respond with index.html and will forcefully return 200
errorCode: 404,
responseCode: 200,
errorCachingMinTtl: 31536000,
responsePagePath: "/index.html"
}
],
})
And now, let's see how the Lambda@Edge function looks like:
// entry point
// the function is very light weight, it does not import any
// external packages, it supposed to add minimal latency
// to our request/response loop
export async function main
(event: any = {}, context: any, callback: any) {
// console.log({event: JSON.stringify(event)})
const { request} = event.Records[0].cf
// let's scrape image identifier from the url
const imageId = request.uri.replace('/photos/', '')
console.log({imageId})
// the following line is a copy/paste from the index.html
// deployed to the s3 bucket. We could read it dynamically,
// but the goal is to make this function as fast as possible.
// The original index.html file for react SPA does not change
// often if ever. As such, we can safely use a clone of it.
const index =
// don't forget to escape \! -- that's the only modification
// that needs to be applied to the minified index.html
`
<\!doctype html><html lang="en" prefix="og: http://ogp.me/ns#" xmlns="http://www.w3.org/1999/xhtml" xmlns:fb="http://ogp.me/ns/fb#"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="google-site-verification" content="RQGZzEN0xtT0w38pKeQ1L8u8P6dn7zxfu03jt0LGgF4"/><link rel="preconnect" href="https://www.wisaw.com"/><link rel="preconnect" href="https://s3.amazonaws.com"/><link rel="manifest" href="/manifest.json"/><link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.webp"/><link rel="icon" type="image/webp" href="/favicon-32x32.webp" sizes="32x32"/><link rel="icon" type="image/webp" href="/favicon-16x16.webp" sizes="16x16"/><link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5"/><meta name="theme-color" content="#ffffff"/><link rel="preload" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta3/dist/css/bootstrap.min.css" as="style" integrity="sha384-eOJMYsd53ii+scO/bJGFsiCZc+5NDVN2yr8+0RDqr0Ql0h+rP48ckxlpbzKgwra6" crossorigin="anonymous" onload='this.onload=null,this.rel="stylesheet"'/><script defer="defer" src="/static/js/main.8ee2345d.js"></script><link href="/static/css/main.e548762f.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
`
// let's add the context specific meta tags to the <head>
// this should be self explanatory
const body = index.replace('<head>',
`<head>
<meta name="image" property="og:image" content="https://wisaw-img-prod.s3.amazonaws.com/${imageId}" />
<meta name="description" property="og:description" content="Check out What I saw Today" />
<meta property="og:title" content="wisaw photo ${imageId}" />
<meta property="og:url" content="https://www.wisaw.com/photos/${imageId}" />
<meta property="og:site_name" content="wisaw.com" />
<link rel="canonical" href="https://www.wisaw.com/photos/${imageId}" />
<meta name="twitter:title" content="wisaw (What I Saw) photo ${imageId}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://wisaw-img-prod.s3.amazonaws.com/${imageId}" />
`
)
// let's define the response object
const response = {
status: '200',
statusDescription: 'OK',
headers: {
'cache-control': [{
key: 'Cache-Control',
value: 'max-age=100'
}],
'content-type': [{
key: 'Content-Type',
value: 'text/html'
}]
},
body,
}
// and return it
callback(null, response)
}
That's all folks!
Remember to test your solution with:
LinkedIn post inspector -- https://www.linkedin.com/post-inspector/
Facebook sharing debugger -- https://developers.facebook.com/tools/debug/
Twitter Card Validator -- https://cards-dev.twitter.com/validator
The full code can be found in my public github repo -- https://github.com/echowaves/WiSaw.cdk
The CDK stack definition -- https://github.com/echowaves/WiSaw.cdk/blob/main/lib/wi_saw.cdk-stack.ts
And the Lambda@Edge function -- https://github.com/echowaves/WiSaw.cdk/blob/main/lambda-fns/lambdas/injectMetaTagsLambdaFunction/index.ts
Have fun coding...
Comments