banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

Dynamic Meta Tag Practices for Single Page Applications (SPA) Deployed on Vercel

This article is part of the technical details for implementing the Separate main application and external pages and SEO support PR for Follow. Due to the numerous technical details involved, this will be one of a series of articles.

In the previous article, Deploying an OpenGraph Image Service on Vercel, we introduced how to deploy a Node.js server using Vercel.

In this article, we will use this server to do more things. One of the tasks is to optimize the SEO of the SPA application by adding the OG Image information we generate into the Meta tags through meta tags.

We also need to mount these two projects to the same domain, utilizing Vercel's Rewrite feature.

Using Node.js Server to Proxy HTML#

Introduction to OpenGraph#

Generally, social media platforms scrape the HTML source of a site and obtain the content of the meta tags to display different content. Often, these scraping engines do not have the capability to render JS, so we need to use a Node.js server to proxy the HTML of the SPA and dynamically insert the required meta information into the HTML based on the URL path.

OpenGraph information is one of the most common types of data scraped by social media. A typical OpenGraph meta tag is shown below:

<meta property="og:title" content="Everyday Moments | 静かな森" /><meta
  property="og:description"
  content="Another half month has passed. What has happened these days worth recording? A sudden burst of interest. At the end of last month, the domestic Hearthstone server reopened. I recall having played Hearthstone for a while a long time ago, bringing the whole dormitory into the pit during college, but later I got addicted to coding and abandoned it. This time, the reopening has quite a few benefits, giving away all the cards from last year, allowing me to log back in and receive a wave of benefits. After playing for a few days, I gradually got familiar with the new"
/><meta
  property="og:image"
  content="https://innei.in/og?data=%257B%2522type%2522%253A%2522note%2522%252C%2522nid%2522%253A%2522182%2522%257D"
/><meta property="og:type" content="article" /><meta
  name="twitter:card"
  content="summary_large_image"
/><meta name="twitter:title" content="Everyday Moments" /><meta
  name="twitter:description"
  content="Another half month has passed. What has happened these days worth recording? A sudden burst of interest. At the end of last month, the domestic Hearthstone server reopened. I recall having played Hearthstone for a while a long time ago, bringing the whole dormitory into the pit during college, but later I got addicted to coding and abandoned it. This time, the reopening has quite a few benefits, giving away all the cards from last year, allowing me to log back in and receive a wave of benefits. After playing for a few days, I gradually got familiar with the new"
/><meta
  name="twitter:image"
  content="https://innei.in/og?data=%257B%2522type%2522%253A%2522note%2522%252C%2522nid%2522%253A%2522182%2522%257D"
/>

The effect on X is as follows:

image

Using Monorepo to Separate SPA Applications#

In an SPA application, not all routes need SEO optimization; often, only a few pages that need to be shared require it. For example, in the Follow application, only the routes under /share need SEO optimization.

For example: the dynamically inserted meta tags for https://app.follow.is/share/users/innei are:

image

Now let's work on separating the projects. Before we start, we need to understand how the SPA application works. A typical directory structure of an SPA application compiled by Vite is as follows:

dist
├── assets
│   ├── index.12345678.css
│   ├── index.12345678.js
│   └── index.12345678.js.map
├── index.html

Here, index.html is the entry file for our SPA application. When the browser accesses the / route, it loads this file and dynamically renders the page based on the JS files.

We only need to use Node.js to proxy this file and dynamically insert the meta tags based on the URL path.

To separate the SPA project, we need to create another SPA application and place the routes that need SEO optimization into this project. In this process, we may need to copy a large number of shared components, which may also need to be refactored. Using Monorepo can effectively solve this problem.

For example, in the original project, apps/renderer is a complete SPA application. Now we create a new app named server, located at apps/server, which is a proxy server. Create a directory for storing frontend code, such as apps/server/client.

image

We replicate a directory structure identical to apps/renderer and place the required routes into this project. Extract common modules, such as components and utility functions, from apps/renderer into the packages directory. This process can be incremental. During the refactoring, to avoid large conflicts caused by a massive commit and prolonged downtime, we can first extract common modules from apps/renderer into packages by copying the code without changing the original code's reference relationships, and use packages references in the new application. For example, we create a packages/components directory and extract some components from apps/renderer/src/components.

Create a package.json file for the package, such as:

{
  "name": "@follow/components",
  "version": "0.0.1",
  "private": true,
  "sideEffects": false,
  "exports": {
    "./tailwind": "./assets/index.css",
    "./modules/*": "./src/modules/*",
    "./common/*": "./src/common/*",
    "./icons/*": "./src/icons/*",
    "./ui/*": "./src/ui/*",
    "./providers/*": "./src/providers/*",
    "./hooks/*": "./src/hooks/*",
    "./atoms/*": "./src/atoms/*",
    "./dayjs": "./src/utils/dayjs.ts",
    "./utils/*": "./src/utils/*"
  },
  "peerDependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  },
  "dependencies": {}
}

This package does not need to be compiled, so we can directly export the source code. Here, we use the exports field to specify the exported files, using wildcards to export multiple directories. Common styles can also export a common CSS file. For example, we use TailwindCSS.

@import './colors.css'; /* Custom colors */
@import './tailwind.css'; /* TailwindCSS */
@import './font.css'; /* Custom fonts */

@tailwind base;
@tailwind components;
@tailwind utilities; /* Other */

In apps/server, reference this package:

pnpm i @follow/components@workspace:*

At this point, in apps/server/client, we can directly reference this package to use the encapsulated components:

For example:

import { Button } from '@follow/components/ui/button/index.js'

By the way, if you are using TailwindCSS, you may also need to change the content field.

export default resolveConfig({
  content: [
    './client/**/*.{ts,tsx}',
    './index.html',
    './node_modules/@follow/components/**/*.{ts,tsx}', // Add this line
  ],
})

The process of extracting components and encapsulating common modules is very lengthy and painful. In the refactoring of Follow, I extracted a total of 8 packages and moved and modified nearly ten thousand lines of code.

image

Since we previously copied the code from apps/renderer, after the refactoring is complete, we also need to modify all code references in renderer at once, such as changing ~/components to @follow/components, and then deleting all components and code that have been migrated from the renderer.

Node.js Server Proxying HTML#

This part is divided into two sections: one is handling the binding of the Vite Dev Server and the Node.js server in the development environment, allowing us to modify the HTML generated by the Vite Dev Server and inject meta tags. The second is handling the compiled index.html file in the production environment.

First, let's talk about the development environment. Here, we use Fastify as an example of a Node.js server.

We can achieve binding with the Vite Dev Server in the following way.

let globalVite: ViteDevServer
export const registerDevViteServer = async (app: FastifyInstance) => {
  const vite = await createViteServer({
    server: { middlewareMode: true },
    appType: 'custom',

    configFile: resolve(root, 'vite.config.mts'), // Path to vite config
    envDir: root,
  })
  globalVite = vite

  // @ts-ignore
  app.use(vite.middlewares)
  return vite
}

During the App initialization phase:

const app = Fastify({})

app.register(fastifyRequestContext)
await app.register(middie, {
  hook: 'onRequest',
})

if (isDev) {
  const devVite = require('./src/lib/dev-vite')
  await devVite.registerDevViteServer(app)
}

Create a route for handling HTML:

import { parseHTML } from 'linkedom'

app.get('*', async (req, reply) => {
  const url = req.originalUrl

  const root = resolve(__dirname, '../..')

  const vite = require('../lib/dev-vite').getViteServer()
  try {
    let template = readFileSync(
      path.resolve(root, vite.config.root, 'index.html'), // Vite dev uses index.html path
      'utf-8',
    )
    template = await vite.transformIndexHtml(url, template) // Use vite to transform
    const { document } = parseHTML(template)

    reply.type('text/html')
    reply.send(document.toString())
  } catch (e) {
    vite.ssrFixStacktrace(e)
    reply.code(500).send(e)
  }
})

Thus, we have achieved HTML proxying.

Dynamically Inserting Meta Tags#

Based on the above, we make slight modifications to the * route:

app.get('*', async (req, reply) => {
  const url = req.originalUrl

  const root = resolve(__dirname, '../..')

  const vite = require('../lib/dev-vite').getViteServer()
  try {
    let template = readFileSync(
      path.resolve(root, vite.config.root, 'index.html'),
      'utf-8',
    )
    template = await vite.transformIndexHtml(url, template)
    const { document } = parseHTML(template)
    await injectMetaToTemplate(document, req, reply) // Inject meta tags into HTML here
    reply.type('text/html')
    reply.send(document.toString())
  } catch (e) {
    vite.ssrFixStacktrace(e)
    reply.code(500).send(e)
  }
})

Define the type of Meta:

interface MetaTagdata {
  type: 'meta'
  property: string
  content: string
}

interface MetaOpenGraph {
  type: 'openGraph'
  title: string
  description?: string
  image?: string | null
}

interface MetaTitle {
  type: 'title'
  title: string
}

export type MetaTag = MetaTagdata | MetaOpenGraph | MetaTitle

Implement the injectMetaToTemplate function:

async function injectMetaToTemplate(
  document: Document,
  req: FastifyRequest,
  res: FastifyReply,
) {
  const injectMetadata = await injectMetaHandler(req, res).catch((err) => {
    // Handle different meta tag injections based on URL path in injectMetadata
    if (isDev) {
      throw err
    }
    return []
  })

  if (!injectMetadata) {
    return document
  }

  for (const meta of injectMetadata) {
    switch (meta.type) {
      case 'openGraph': {
        const $metaArray = buildSeoMetaTags(document, { openGraph: meta })
        for (const $meta of $metaArray) {
          document.head.append($meta)
        }
        break
      }
      case 'meta': {
        const $meta = document.createElement('meta')
        $meta.setAttribute('property', meta.property)
        $meta.setAttribute('content', xss(meta.content))
        document.head.append($meta)
        break
      }
      case 'title': {
        if (meta.title) {
          const $title = document.querySelector('title')
          if ($title) {
            $title.textContent = `${xss(meta.title)} | Follow`
          } else {
            const $head = document.querySelector('head')
            if ($head) {
              const $title = document.createElement('title')
              $title.textContent = `${xss(meta.title)} | Follow`
              $head.append($title)
            }
          }
        }
        break
      }
    }
  }

  return document
}

import xss from 'xss'

export function buildSeoMetaTags(
  document: Document,
  configs: {
    openGraph: {
      title: string
      description?: string
      image?: string | null
    }
  },
) {
  const openGraph = {
    title: xss(configs.openGraph.title),
    description: xss(configs.openGraph.description ?? ''),
    image: xss(configs.openGraph.image ?? ''),
  }

  const createMeta = (property: string, content: string) => {
    const $meta = document.createElement('meta')
    $meta.setAttribute('property', property)
    $meta.setAttribute('content', content)
    return $meta
  }

  return [
    createMeta('og:title', openGraph.title),
    createMeta('og:description', openGraph.description),
    createMeta('og:image', openGraph.image),
    createMeta('twitter:card', 'summary_large_image'),
    createMeta('twitter:title', openGraph.title),
    createMeta('twitter:description', openGraph.description),
    createMeta('twitter:image', openGraph.image),
  ]
}

Implement the injectMetaHandler function:

import { match } from 'path-to-regexp'

export async function injectMetaHandler(
  req: FastifyRequest,
  res: FastifyReply,
): Promise<MetaTag[]> {
  const apiClient = createApiClient()
  const upstreamOrigin = req.requestContext.get('upstreamOrigin')
  const url = req.originalUrl

  for (const [pattern, handler] of Object.entries(importer)) {
    const matchFn = match(pattern, { decode: decodeURIComponent })
    const result = matchFn(url)

    if (result) {
      const parsedUrl = new URL(url, upstreamOrigin)

      return await handler({
        // Context can be passed here as needed
        params: result.params as Record<string, string>,
        url: parsedUrl,
        req,
        apiClient,
        origin: upstreamOrigin || '',
        setStatus(status) {
          res.status(status)
        },
        setStatusText(statusText) {
          res.raw.statusMessage = statusText
        },
        throwError(status, message) {
          throw new MetaError(status, message)
        },
      })
    }
  }

  return []
}

In injectMetaHandler, we match paths like /share/feeds/:id using path-to-regexp, then find the corresponding handler from the importer map and return a MetaTag array.

The importer map should be an automatically generated map based on the SPA application's routes, for example:

import i1 from '../client/pages/(main)/share/feeds/[id]/metadata'
import i2 from '../client/pages/(main)/share/lists/[id]/metadata'
import i0 from '../client/pages/(main)/share/users/[id]/metadata'

export default {
  '/share/users/:id': i0,
  '/share/feeds/:id': i1,
  '/share/lists/:id': i2,
}

In Follow, the SPA route definitions are generated based on the filesystem tree, so we can write a helper watcher based on this feature.

import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { dirname } from 'node:path'
import { fileURLToPath } from 'node:url'

import chokidar from 'chokidar'
import { glob } from 'glob'

const __dirname = dirname(fileURLToPath(import.meta.url))

async function generateMetaMap() {
  const files = await glob('./client/pages/(main)/**/metadata.ts', {
    cwd: path.resolve(__dirname, '..'),
  })

  const imports: string[] = []
  const routes: Record<string, string> = {}

  files.forEach((file, index) => {
    const routePath = file
      .replace('client/pages/(main)', '')
      .replace('/metadata.ts', '')
      .replaceAll(/\[([^\]]+)\]/g, ':$1')

    const importName = `i${index}`
    imports.push(`import ${importName} from "../${file.replace('.ts', '')}"`)
    routes[routePath] = importName
  })

  const content =
    '// This file is generated by `pnpm run meta`\n' +
    `${imports.join('\n')}\n
export default {
${Object.entries(routes)
  .map(([route, imp]) => `  "${route}": ${imp},`)
  .join('\n')}
}
`

  const originalContent = await fs.readFile(
    path.resolve(__dirname, '../src/meta-handler.map.ts'),
    'utf-8',
  )
  if (originalContent === content) return
  await fs.writeFile(
    path.resolve(__dirname, '../src/meta-handler.map.ts'),
    content,
    'utf-8',
  )
  console.info('Meta map generated successfully!')
}

async function watch() {
  const watchPath = path.resolve(__dirname, '..', './client/pages/(main)')
  console.info('Watching metadata files...')

  await generateMetaMap()
  const watcher = chokidar.watch(watchPath, {
    ignoreInitial: false,
  })

  watcher.on('add', () => {
    console.info('Metadata file added/changed, regenerating map...')
    generateMetaMap()
  })

  watcher.on('unlink', () => {
    console.info('Metadata file removed, regenerating map...')
    generateMetaMap()
  })

  watcher.on('change', () => {
    console.info('Metadata file changed, regenerating map...')
    generateMetaMap()
  })

  process.on('SIGINT', () => {
    watcher.close()
    process.exit(0)
  })
}

if (process.argv.includes('--watch')) {
  watch().catch(console.error)
} else {
  generateMetaMap().catch(console.error)
}

Now create a metadata.ts file under the route and export a function that defines the metadata.

image

import { defineMetadata } from '~/meta-handler'

export default defineMetadata(
  async ({ params, apiClient, origin, throwError }) => {
    const listId = params.id
    const list = await apiClient.lists.$get({ query: { listId } })

    const { title, description } = list.data.list
    return [
      {
        type: 'openGraph',
        title: title || '',
        description: description || '',
        image: `${origin}/og/list/${listId}`,
      },
      {
        type: 'title',
        title: title || '',
      },
    ]
  },
)

So far, in the dev environment, we have achieved proxying the Vite Dev Server and dynamically inserting meta tags. Next, we will handle the differences in the production environment.

Proxying HTML in Production Environment#

In the production environment, we need to proxy the compiled HTML file.

app.get('*', async (req, reply) => {
  const template = readFileSync(
    path.resolve(root, '../dist/index.html'), // This is the compiled HTML file
    'utf-8',
  )
  const { document } = parseHTML(template)
  await injectMetaToTemplate(document, req, reply)

  reply.type('text/html')
  reply.send(document.toString())
})

Directly replacing the filesystem in platforms like Vercel may not work well, as the compiled artifacts will not be available in the API call environment, leading to a "file not found" error when deployed to Vercel.

We can solve this problem by directly packaging the compiled HTML string.

import { mkdirSync } from 'node:fs'
import fs from 'node:fs/promises'
import path from 'node:path'

mkdirSync(path.join(__dirname, '../.generated'), { recursive: true })

async function generateIndexHtmlData() {
  const indexHtml = await fs.readFile(
    path.join(__dirname, '../dist/index.html'),
    'utf-8',
  )
  await fs.writeFile(
    path.join(__dirname, '../.generated/index.template.ts'),
    `export default ${JSON.stringify(indexHtml)}`,
  )
}

async function main() {
  await generateIndexHtmlData()
}

main()

In the above script, after the SPA project is compiled, we read the compiled HTML file and write it to a .generated directory, then export the HTML string.

Modify the proxy routing logic for the production environment.

app.get('*', async (req, reply) => {
  const template = require('../../.generated/index.template').default 
  const { document } = parseHTML(template)
  await injectMetaToTemplate(document, req, reply)

  reply.type('text/html')
  reply.send(document.toString())
})

Then we modify the build process:

cross-env NODE_ENV=production vite build && tsx scripts/prepare-vercel-build.ts && tsup

Regarding the use of tsup for server-side compilation, it is introduced in Deploying an OpenGraph Image Service on Vercel.

Now that we have two different environment handling logics, we can make a judgment:

export const globalRoute = isDev ? devHandler : prodHandler

Deploying to Vercel#

Now we can deploy to Vercel. We need to mount two applications on the same domain. Our main application is the original app, and we need to create a new application on Vercel, which is used to proxy HTML and is a Node.js service.

Now we refer to the original app as app1 and the new application as app2.

The URL routing rules we need to implement are:

/share/* -> app2
/* -> app1
https://cdn.jsdelivr.net/npm/@innei/react-cdn-components@latest/excalidraw/follow/vercel-rewrite-domain.json

In app2, create or modify vercel.json:

{
  "rewrites": [
    {
      "source": "/((?!external-dist|dist-external).*)", // Rewrite all requests to Vercel's API route. About what API is, you can find in ![Deploying an OpenGraph Image Service on Vercel](https://innei.in/posts/tech/deploy-an-opengraph-image-service-on-vercel)
      "destination": "/api"
    }
  ]
}

In app1, create or modify vercel.json:

{
  "rewrites": [
    {
      "source": "/share/:path*",
      "destination": "https://<domain>/share/:path*" // Modify address
    },
    {
      "source": "/og/:path*",
      "destination": "https://<domain>/og/:path*"
    },
    {
      "source": "/((?!assets|vendor/).*)",
      "destination": "/index.html"
    }
  ],

  "headers": []
}

After these modifications, accessing the /share path can correctly rewrite to app2, but all resource files for app2 return 404. Since the compiled products of app1 are also under the assets path, to avoid conflicts, we place app2 under a different path, such as dist-external.

Modify app2's vite.config.ts:

import { resolve } from 'node:path'

import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'

export default () => {
  return defineConfig({
    build: {
      rollupOptions: {
        output: {
          assetFileNames: 'dist-external/[name].[hash].[ext]', 
          chunkFileNames: 'dist-external/[name].[hash].js', 
          entryFileNames: 'dist-external/[name].[hash].js', 
        },
      },
    },
    plugins: [react()],
  })
}

In app1, modify vercel.json:

{
  "rewrites": [
    {
      "source": "/dist-external/:path*", 
      "destination": "https://<domain>/dist-external/:path*"
    },
    {
      "source": "/((?!assets|vendor|locales|dist-external/).*)", 
      "destination": "/index.html"
    }
  ]
}

Due to the length of the content, please refer to future breakdowns for other details.

This article is synchronized by Mix Space to xLog. The original link is https://innei.in/posts/tech/dynamic-meta-tags-for-spa-on-vercel

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.