Skip to content

Instantly share code, notes, and snippets.

@RakaDoank
Last active May 5, 2024 15:37
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save RakaDoank/05da35887051a8f999aabc23d30194e3 to your computer and use it in GitHub Desktop.
Save RakaDoank/05da35887051a8f999aabc23d30194e3 to your computer and use it in GitHub Desktop.
How do i achieve SEO Meta in Server While The Web Project is Single Page Application (SPA)?

Are you having a trouble about SEO Meta in server while your project is SPA only like React Router or Vue Router?

Can i guess you also don't have so much time for migration on the new framework that can handle server side rendering out of the box like Next.js, Nuxt.js, Gatsby, etc.

Setting the SEO meta in server like <title>, <meta name="description"/> are actually useful for search engine, and also good for showing your summary content in social media app by pasted the full url in Whatsapp, IG, Twitter, etc.

Enough the intermezzo, here we go.

The concept is actually simple, modify the index.html (from bundled source) before it served to the client.

As an case, you have Marketplace web. So it has the Product Detail Page.

Usually, you call a REST API to get the product data in client.
And you show the content based on the API result.

For this case, it is also similar like what we will do in server with Cheerio.
Call the API, but instead of set the content, we only modify the `<title>`, `<meta name="description"/>`, etc.

We need Cheerio to modify the index.html, and Express.js (or other) to serve the modified version of index.html.

  1. First, just install Cheerio and Express.js

  2. Normally, here is the sample of how to serve the index.html file using Express.js (not yet modified by Cheerio). Create server.js in the project folder (same level as package.json or in a subfolder)

const express = require('express')
const path = require('path')
const DotEnv = require('dotenv')

const app = express()
DotEnv.config()

const PORT = process.env.PORT || 5000

// The folder name of build result
// Like in React Vite, it would be 'dist'. Or in create-react-app, it would be 'build'
const productionDir = 'dist'

// serve up production assets
app.use( express.static(productionDir) )

// For all Routing, usually you just serve the index.html
// The content based on pathname route would be handled in client side, like by React Router
app.get('*', (req, res) => {
  res.sendFile( path.resolve(__dirname, productionDir, 'index.html') )
})

app.listen(port, () => {
  console.log('Listening on port ' + port + '!')
})

Try to build the project first, like in React Vite and create-react-app npm run build, and then, execute node server or node whatever or node whatever/server or nodemon server if you want to use Nodemon to get node server auto restarting each changes.

If it fail, fix it first.

  1. In this step, we will use the Cheerio to modify the <title> and <meta name="description"/>
const express = require('express')
const fs = require('fs')
const path = require('path')
const DotEnv = require('dotenv')

const app = express()
DotEnv.config()

const cheerio = require('cheerio') // import the cheerio
const fs = require('fs') // we also need File System from Node.js to get the index.html file first

const PORT = process.env.PORT || 5000

const productionDir = 'dist'

app.use( express.static(productionDist) )

/**
 * Only in the product detail page
 * We modify the title and description
 */
app.get('/product/:slug', (req, res) => {
  fs.readFile(`${productionDir}/index.html`, (e, file) => {
    if(e) {
      // handle the error. It's up to you
      // It's rare case, but never play it down
    } else {
      // Call the API to get the data
      fetch('https://theapi.com/what/data/you/are/looking/for/' + req.params.slug)
        .then(res => {
          if(res) {
            return res.json()
          }

          throw new Error('No data')
        })
        .then(res => {
          // Here the Cheerio
          const cheerioFile = cheerio.load(file)
          const contextHead = cheerioFile('head')
          
          // Using Cheerio is like an old school jQuery
          // i can't confirm cheerio has limit, or it has full capability of jQuery
          
          // Modify the title. MAKE SURE IT HAS TITLE TAG IN THE index.html FILE, OTHERWISE YOU HAVE TO CREATE THE NEW TAG AND APPEND IT.
          cheerioFile('title', contextHead).text(res.theData.theTitle)
          
          // Modify the description. MAKE SURE IT HAS META DESCRIPTION TAG IN THE index.html FILE, OTHERWISE YOU HAVE TO CREATE THE NEW TAG AND APPEND IT.
          cheerioFile('[name="description"], [property="og:description"], [name="twitter:description"]', contextHead).attr('content', res.theData.theDescription)
          
          // Modify the keywords, SAME WARNING LIKE ABOVE
          cheerioFile('[name="keywords"]', contextHead).attr('content', keywords)
          
          // ...And other modification you like...
          
          // Here the example of how to create the description tag programatically
          contextHead.append(`<meta name="description" content="${res.theData.theDescription}"/>`)
          
          // DANGER!
          // ALSO MAKE SURE YOUR DATA CONTENT IS NOT BREAKING THE HTML SYNTAX
          
          // Now serve the modified file
          res.send( cheerioFile.html() )
          // DONE
        })
        .catch(err => {
          console.log('Error: ', err.message)
          // Handle the error of the API
          
          // It's up to you
          // in this example, we just serve the file.
          // I assumed you render the "Error" or "Under Construction" page like
          res.type('html').send(file)
        })
    }
  })
})

app.get('*', (req, res) => {
  res.sendFile( path.resolve(__dirname, productionDir, 'index.html') )
})

app.listen(port, () => {
  console.log('Listening on port ' + port + '!')
})

DONE.

When you tried to paste the url of the Product Detail page to Whatsapp, or other social media, or any SEO tester, it should've shown the SEO meta content.

Any HTML tags in the index.html can be modified, just don't break your SPA, like modiying the id of <div> as entry point of React app, and etc.

Important! You should've know the basic principal of SPA.

The specific server route like app.get('/product/:slug') only execute once
since your project is SPA that handling the route by React Router or Vue Router
that doesn't need to call the server specific route like we write the cheerio in it.
Unless, your client is cliking the link of product detail page by 'Open Link in New Tab' or refresh the page.

Modify the `<title>`, `<meta name="description"/>` again in your SPA when the route is changed.
The next is up to the client now. So your title page is updated each route in accordance to the actual content.

And you stil need to get the data in client.

Tips

  • JSON-LD

const jsonLd = '<script type="application/ld+json">' + JSON.stringify({
  '@context': 'https://schema.org/',
  '@type': 'Product',
  name: res.theData.theTitle,
  description: res.theData.theDescription,
  brand: {
    '@type': 'Brand',
    name: res.theData.theBrand,
  },
  image: [
    res.theData.theImage.theSquareRatio,
    res.theData.theImage.the3_4Ratio,
    res.theData.theImage.the9_16Ratio,
  ],
  url: 'https://yourweburl.com/product/' + req.params.slug,
  offers: {
    '@type': 'Offer',
    availability: 'https://schema.org/LimitedAvailability',
    itemCondition: 'https://schema.org/NewCondition',
    priceCurrency: 'USD',
    ...otherOffersProps
  },
  ...otherJsonLdProps
}) + '</script'>
  
cheerioFile('head').prepend(jsonLd)
  • Reuse The API Data You Get From Server

const theData = '<script type="text/javascript">window.YOUR_INITIAL_DATA=' + JSON.stringify(YourApiResponse) + '</script>'
cheerioFile('body').append(theData)

And then, in your SPA, you can actually get the data back

const theData = window.YOUR_INITIAL_DATA
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment