Search
  • Dmitry Amelchenko

Updating SEO og: meta tags in Single Page Apps on the fly



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:



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...

19 views

Recent Posts

See All

I'm building my killer mobile app on graphql. On the backend I'm using AWS AppSync -- I'm still not bought into the magic of Amplify -- there is just a way too much magic going on behind the scenes, w