Mostrando la imagen y el título correctos para aplicaciones de página única (SPAs)

Jun 12, 2023   HIRANO Satoshi

Cuando se comparte un enlace a una página web en plataformas de redes sociales como Twitter o Facebook, se muestra el título e imagen en miniatura de la página. Desafortunadamente, si la página es parte de una aplicación de página única (SPA), el servidor proporciona un index.html fijo en lugar de la página real, lo que resulta en que se muestre un título e imagen fijos. Algunos motores de búsqueda tampoco pueden indexar el título y contenido correctos. Este artículo explica cómo superar estos problemas y mostrar el título e imagen correctos para la página.

dev webpack SPA SEO
Follow us
Share it

El problema


Mientras desarrollábamos Requestland, nos encontramos con un problema. Cuando un usuario compartía un enlace a una página de Requestland en plataformas de redes sociales como Twitter o Facebook, se mostraba el mismo título e imagen en miniatura predeterminados en lugar del título y la imagen en miniatura específicos de la página.


Si los lectores de Twitter no podían ver la imagen en miniatura de la página, era menos probable que hicieran clic en el enlace.


La causa raíz de este problema fue que Requestland era una aplicación de página única (SPA).  Aunque el problema es común y molesto, no pude encontrar una explicación en la red ni en ChatGPT. 


En una SPA, cuando se accede o se vuelve a cargar cualquier enlace, el servidor web sirve el archivo index.html predeterminado que contiene etiquetas <script> que apuntan a archivos de paquete con código JavaScript y CSS. Estos archivos son necesarios para ejecutar la SPA. Dado que el archivo index.html tiene un título e imagen en miniatura, el usuario siempre veía los mismos.


Por ejemplo, cargar una página en una aplicación de notas en https://my-service.com/app/note/995 devuelve el siguiente index.html. <title> es el título de la aplicación, no el título de la página. 


<html>
  <head>
    <base href="/app">
    <title>My SPA notes app</title>
    <description>This is a notes app.</description>
    <meta property="og:image" content="https://file-server.image1.png">
    <!-- injected script tags for bundle files -->
    <script src="https://file-server.bundle.a94.js"></script>
    <link href="https://file-server.bundle.vc0.css" rel="stylesheet"></link>
  </head>
  <body>
    ...
  </body>
</html>
Nuestro objetivo es mostrar el título y la imagen en miniatura de la página real, incluso si es parte de una aplicación de página única (SPA).

Algunos motores de búsqueda pueden recuperar el título, el contenido y la imagen en miniatura correctos para cada página enumerada en el archivo sitemap.xml sin representación del lado del servidor (SSR).

El problema que debemos resolver es cómo proporcionar el título y la imagen en miniatura correctos para cada URL dentro del archivo index.html fijo.

Si un servidor web sirve tanto los archivos de paquete como genera index.html para cada URL, puede inyectar fácilmente las etiquetas <script> para los archivos de paquete. Sin embargo, esto puede causar problemas de rendimiento porque el servidor web tiene que procesar solicitudes y servir archivos estáticos.



Fig.1 Componentes típicos en producción.


La Fig.1 muestra una relación típica de componentes en producción para un mejor rendimiento, pero aún así tiene el problema.

Hay un servidor de archivos que sirve los archivos de paquete e index.html, y un servidor API que sirve los enlaces /api/* para la SPA. El load balancer de carga divide las solicitudes HTTP a cualquiera del servidor API o del servidor de archivos según las rutas (URL). Puede ser un proxy Nginx simple. El servidor de archivos devuelve el archivo index.html para todas las URL excepto /api/*.

Por ejemplo, cargar o volver a cargar https://my-service.com/app/note/995 debería devolver el título y la imagen en miniatura de la nota 995, así como las etiquetas <script> en la respuesta HTTP como sigue:

<html>
  <head>
    <base href="/app">
    <title>Note 995 title</title>
    <description>Note 995 description</description>
    <meta property="og:image" content="https://file-server.note995-image.png">
    <!-- injected script tags for bundle files -->
    <script src="https://file-server.bundle.a94.js"></script>
    <link href="https://file-server.bundle.vc0.css" rel="stylesheet"></link>
  </head>
  <body>
    (content here)
  </body>
</html>
Una estructura de componentes deseable en el entorno de producción se muestra en la Fig.2. El servidor API es responsable de generar la respuesta HTTP inyectando el título y la URL de la miniatura para la página dirigida con la ruta dada y las etiquetas <script>. La respuesta HTTP se muestra como "index response" en la figura.



Fig.2 Componentes mejorados en producción.

Entorno de desarrollo local


Primero, creemos un entorno de desarrollo local típico con Webpack.

Al desarrollar una SPA, usamos el servidor de desarrollo de Webpack para servir nuestra aplicación localmente, como se muestra en la Fig.3. Todos los archivos estáticos, incluidos los archivos de paquete, son servidos por él. Las etiquetas <script> son inyectadas por el empaquetador de Webpack según los nombres de archivo de los archivos de paquete.


Fig.3 Componentes típicos en un entorno de desarrollo local.


El archivo webpack.config.js es un archivo de configuración para Webpack que contiene varias configuraciones y opciones para nuestra aplicación. La configuración proxy en el archivo webpack.config.js se utiliza para reenviar las solicitudes desde la SPA al servidor API ubicado en localhost:8080.

  devServer: {
    historyApiFallback: true,
    proxy: [
        { context: [ '/api' ],
          target: 'http://localhost:8080,
          secure: false,
          changeOrigin: true,
        }]
Hay "historyApiFallback: true" para devolver index.html para todas las rutas excepto /api para SPAs.

Nuestra solución para el entorno de desarrollo local



Fig.4 Muestra una estructura mejorada para el entorno de desarrollo local.


La Fig.4 muestra una estructura mejorada para el problema.

Ahora, el archivo webpack.config.js tiene otra configuración de proxy que transfiere las solicitudes HTTP para /app al servidor API.

Por ejemplo, https://localhost/app/note/995 irá a https://localhost:8080/app/note/995 y devolverá el título y la imagen en miniatura de la nota 995.

    proxy: [
        { context: [ '/api', '/app' ],
          target: 'http://localhost:8080,
          changeOrigin: true,
        }]

Dado que el servidor API no conoce los archivos del paquete, debemos informarle. Agregamos WebpackManifestPlugin que genera un archivo de manifiesto de archivos de paquete al archivo webpack.config.js.

  % yarn add WebpackManifestPlugin


  const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
  ...
  plugins: [
      new WebpackManifestPlugin({
          fileName: 'app-bundle-manifest.json',
          writeToFileEmit: true,
          filter: (file) => {
              return file.isChunk;
          },
          sort: (fileA, fileB) => {
              if (fileA.path.endsWith('.js') && fileB.path.endsWith('.css')) return -1;
              if (fileA.path.endsWith('.css') && fileB.path.endsWith('.js')) return 1;
              if (fileA.path > fileB.path) return -1;
              return 1;
          },
          //seed: {
          //    version: 'local-dev-server-version'
          //}
      }),

El complemento selecciona los archivos de paquete y genera un archivo app-bundle-manifest.json como este:
      {
        "app-a1d170d4.css": "/app-a1d170d4.d1dd426331d40f5930fe.bundle.css",
        "app-a1d170d4.js": "/app-a1d170d4.d1dd426331d40f5930fe.bundle.js",
        "runtime~app.js": "/runtime~app.d1dd426331d40f5930fe.bundle.js",
        "vendors-91c40cd8.css": "/vendors-91c40cd8.d1dd426331d40f5930fe.bundle.css",
        "vendors-91c40cd8.js": "/vendors-91c40cd8.d1dd426331d40f5930fe.bundle.js",
      ...
El archivo JSON se puede acceder a través de una solicitud HTTP y no existe en el sistema de archivos durante el desarrollo local. Cuando construyes la aplicación SPA para producción, se emite en el directorio /dist.

En cualquier caso, el servidor API puede recuperarlo a través de una solicitud HTTP.

Servidor API


Aquí hay una versión de plantilla del archivo index.html que se ha convertido desde el index.ejs de SPA y se utiliza para generar la respuesta HTTP. Tiene algunas {{variables}} que serán reemplazadas por el motor de plantillas Jinja2. Puede generar index.ejs a partir de este index.html.

<html>
  <head>
    <base href="/app">
    <title>{{title}}</title>
    <description>{{desc}}</description>
    <meta property="og:image" content="{{image_url}}">
    {{bundles}}
  </head>
  <body>
    ...
  </body>
</html>

Aquí hay un servidor API en Python que utiliza el marco web Falcon y Jinja2. Tenga en cuenta que puede haber errores en el código. Deberá instalar las bibliotecas importadas y manejar los errores para requests.get().

import falcon
import requests
from jinja2
from markupsafe import Markup
is_local = True
file_server = 'https://localhost' if is_local else 'https://file-server.com'
jinja_env = jinja2.Environment(loader=jinja2.FileSystemLoader('.'))
class APIServer():
    ''' Generates the HTTP response for reloading. '''
    def on_get_api(self, req, resp):
        resp.body = 'API result'
        resp.content_type = 'text/html; charset=UTF-8'
    def on_get_note(self, req, resp, note_id):
        note = get_note_from_db(note_id)
        resp.body = jinja_env.get_template("index.html").render({
            'title':       note.title,
            'desc':        note.content,
            'image_url':   note.image_url,
            'bundles':     Markup(self.get_script_tags())
        })
        resp.content_type = 'text/html; charset=UTF-8'
    def get_script_tags(self) -> str:  # need caching
        response = requests.get(file_server + '/app-bundle-manifest.json', timeout=5, verify=is_local)
        js = css = ''
        for filename in json.loads(response.text).values():
            if filename.endswith('.js'):
                js += '    <script defer src="' + filename + '"></script>\n'
            if filename.endswith('.css'):
                css += '    <link href="' + filename + '" rel="stylesheet"></link>\n'
        return js + css
api_server = APIServer()
app = falcon.App()
app.add_route('/api, api_server, suffix='api')
app.add_route('/app/note/{note_id}', api_server, suffix='note')
def sink(req, resp):
    resp.body = jinja_env.get_template("index.html").render({
        'title':       'My SPA notes app',
        'desc':        'This is a notes app.,
        'image_url':   'https://file-server.image1.png',
        'bundles':     Markup(api_server.get_script_tags())
    })
    resp.content_type = 'text/html; charset=UTF-8'
app.add_sink(sink, prefix='/app')
if __name__ == '__main__':
    from wsgiref.simple_server import make_server
    with make_server('', 8080, app) as httpd:
        httpd.serve_forever()
APIServer.on_get_api() maneja solicitudes de API en la ruta /api. Solo devuelve un texto.

APIServer.on_get_note() maneja la carga y recarga de la ruta /app/note/995. Carga un registro de nota desde la base de datos y llena la respuesta HTTP con su título e imagen en miniatura. Las etiquetas <script> para los archivos de paquete se inyectan usando app-bundle-manifest.json obtenido del servidor de archivos. Puede agregar controladores on_get para más rutas.

APIServer.get_script_tags() devuelve etiquetas <script> para los archivos de paquete usando app-bundle-manifest.json obtenido del servidor de archivos.

sink() maneja todas las demás rutas y llena la respuesta HTTP con el título predeterminado y la imagen en miniatura.

Para probar esto, vuelva a cargar la página /app/note/995 y verifique su respuesta en la pestaña Network de las herramientas de desarrollo de Chrome.

El entorno de producción


Una vez que hayamos completado la versión local, la versión de producción es fácil. El servidor API anterior funciona si se ajustan algunas constantes para el entorno. Aquí hay un ejemplo de la configuración para el balanceador de carga que se muestra en la Fig.2 que transfiere el tráfico para /app/note/* al servidor API por Google Cloud Platform. No se ha verificado.

 pathMatchers:
    - name: Note API server
      routeRules:
        - description: Note API server
          matchRules:
            - pathTemplateMatch: '/app/note/{rest=**}'
          service: note-server
          priority: 1
          routeAction:
            urlRewrite:
              pathTemplateRewrite: '/app/note/{rest}'

Mejora adicional


La respuesta de índice generada puede incluir el contenido de la nota. Si bien el contenido no es visible para los usuarios en SPA, puede ser visible para los motores de búsqueda. Aunque no se ha probado, se espera que algunos motores de búsqueda indexen el título y el contenido. Esto podría mejorar la optimización de motores de búsqueda (SEO) de su página y facilitar que los usuarios encuentren su contenido a través de los motores de búsqueda.

Aquí hay un index.html y un servidor API mejorados:

    <body>
      <div>
        {{content}}
      </div>
    </body>

    def on_get_note(self, req, resp, note_id):
        note = get_note_from_db(note_id)
        resp.body = jinja_env.get_template("index.html").render({
            'title':       note.title,
            'desc':        note.content,
            'image_url':   note.image_url,
            'bundles':     Markup(self.get_script_tags()),
            'content':     note.content
        })
        resp.content_type = 'text/html; charset=UTF-8'
Es posible que necesite almacenar en caché app-bundle-manifest.json para su uso en producción.

La implementación de los archivos de paquete en el servidor de archivos y la implementación del servidor API pueden ocurrir por separado. Cuando se implementa una nueva versión de la aplicación SPA, los archivos de paquete cambian. El servidor no conoce los nuevos archivos de paquete y sigue utilizando un app-bundle-manifest.json obsoleto que tiene nombres de archivo de paquete que ya no son válidos.

Por lo tanto, al diseñar el almacenamiento en caché de app-bundle-manifest.json, es necesario manejar la brecha de tiempo entre las implementaciones. Esto puede ser un desafío pero es posible.

Conclusión


Al utilizar una combinación de configuraciones de proxy, un servidor API y un motor de plantillas, los desarrolladores pueden inyectar el título y la imagen en miniatura correctos para cada página dentro de una SPA. Eso proporcionará a los usuarios una mejor experiencia.

Si bien puede haber algunos desafíos en la implementación de esta solución, como el almacenamiento en caché de app-bundle-manifest.json y el manejo del intervalo de tiempo entre las implementaciones, el resultado final vale la pena el esfuerzo.