Taking a Symfony & Vue project to the Nuxt level

If you did not read the first part yet, please have a look here before going further.

Introduction

In our last post, we explained:

  • why our Twig / Vue mix was bad, in terms of development experience, maintainability and performance
  • why we decided to migrate to a full Nuxt.js application.

In this article, we will explain how we moved to a Nuxt.js website, looking at: 

  • the iterations we had on the architecture
  • the way we shared configuration between Symfony & Nuxt.js

Before starting, here is a small reminder about the project:

  • Tarkett is a French multinational corporation specialized in the production of floor and wall coverings.
  • The website is live on more than 30 countries.
  • The web team includes around 15 developers.
  • The project has begun 3 years ago.

Tarkett flooring

Now let's dive into this migration.

Architecture

Before we started

Here is a simplified version of the architecture. Basically we have:

  • a Symfony app served by Nginx.
  • 2 Varnish servers to cache our most viewed pages server-side.

v0 Initial architecture (simplified version)

Welcome Nuxt!

The routing had a huge impact on the architecture.

Firstly because the routes using Nuxt and those using Twig/Vue are coexisting during the migration.

Then, all the routes are internationalized. So we decided to keep this responsibility in Symfony rather than having some hardcoded rules in Nginx config to route the request properly.

When calling a "migrated" route, the request is handled by a Gateway which is here only to call Nuxt.js server.

Then, Nuxt.js calls an internal API to fetch business data or data coming from other APIs, like a CMS for example.

v1 Symfony & Nuxt coexisting together ❤️

Nuxt generates some assets (compiled JS, images, fonts) so you need to tell it to Nginx:

    location ~* /(_nuxt|__webpack_hmr|_loading)/ {
        proxy_pass http://nuxt:3000;
    }

where nuxt is Nuxt Docker container's address.

Wow, that's too complex! You could have added /v2 at the beginning of the new routes and create an easy Nginx rule to proxy_pass those URLs to the Nuxt server.

That is true. But it implies to create 301 (or 302) redirects from /routeA to /v2/routeA which is bad for Performance & SEO.

Current situation

At this point, the website was showing great performance and we all loved coding with Vue/Nuxt so everyone was happy, until we got a bunch a 504 errors (Gateway Timeout), many times a day...

Let's dive in a little deeper on how Symfony works. Symfony is a PHP framework where PHP-FPM (FastCGI Process Manager) allows the communication between a web server and PHP.

PHP-FPM has a maximum number of workers (if you want to learn more about, check here), let's say 3.

Now let's consider 3 parallel requests landing on the Gateway. At this point, all the workers are busy, so the 3 calls to the internal API are queued , then end up in a 504 as all the workers are waiting for Nuxt to answer...

deadlocks Deadlocks

How to fix this mess?

Option 1: Start a second instance of Symfony dedicated to the internal API. It would work but it would be costly.

Option 2: The gateway stops calling Nuxt and returns a 307 error, which is handled by Nginx to callback Nuxt. Here is the config:

    location ~ ^/(app)\.php {
        // Other fastcgi params
        fastcgi_intercept_errors on;
        error_page 307 = @redirect_to_nuxt;
    }

    location @redirect_to_nuxt {
        rewrite /app.php/(.*) /$1 break;
        proxy_pass http://nuxt:3000;
    }

The previous loop does not exist anymore as the first Gateway PHP-FPM worker has been freed when the internal API is called.

The target

The target is clear now. Once the old pages are fully migrated, Nuxt will handle all the requests and call the Symfony API to fetch the business data.

v3 The target.

How to keep the code clean ?

Share the locales

As stated before, the website is internationalized. Therefore the locales are set in Symfony and Nuxt-i18n needs to know them. Let's create a nuxt-i18n-config-provider.js where

export const getLocales() {
    const translationFilenames = fs.readdirSync('path/to/translation/files');

    return translationFilenames
      .map(filename => {
        return filename.match(/messages\.([\w-]+)\.yml/i)
      })
      .filter(capturedLocale => !!capturedLocale)
      .map(capturedLocale => {
        const filename = capturedLocale[0]
        const locale = capturedLocale[1]

        return { code: locale, iso: locale, file: filename }
      });
}

Then use this function in your nuxt.config.js (cf. doc)

Share the internationalized routing

Nuxt needs to know about your translated routes in order to match the URL with its routes. Let's see how we can share the initial routing.

We use BeSimpleI18nRoutingBundle and its configuration is done in a i18n.yml file.

news_page:
    locales:
       en_GB: '/news/{id}'
       sv_SE: '/nyheter/{id}'
       fr_FR: '/actualites/{id}'
    defaults: { _controller: AppBundle:News:index }

Let's load this file and map the data to match nuxt-i18n requirements:

news_page: { 
    locales: {
         en_GB: '/news/:id'
         sv_SE: '/nyheter/:id'
         fr_FR: '/actualites/:id'
    }
}

Do you remember our nuxt-i18n-config-provider.js? What about adding a new function:

export function getTranslatedRoutes(localesArray, defaultLocale) {
  
  const loadedFile = safeLoad(
    fs.readFileSync('path/to/i18n.yml', { encoding: 'utf8' })
      .replace(new RegExp('{', 'g'), ':')
      .replace(new RegExp('}', 'g'), '')
  )

  return mapValues(loadedFile,
    (i18nProperties) => {
      return localesArray
        .reduce((translatedRoutesIndexedByLocale, localeConfig) => {
          const currentLocale = localeConfig.code
          translatedRoutesIndexedByLocale[currentLocale] = i18nProperties.locales[currentLocale] || i18nProperties.locales[defaultLocale]

          return translatedRoutesIndexedByLocale
        }, {})
    }
  )
}

Share the translations

In our previous Vue/Twig configuration, translations were handled by Symfony so every translation was given to a Vue component as a prop. And that was painful...

To share translations (coming from messages.{locale}.yml files), a webpack loader can do the job. In the nuxt config:

build: {
    extend (config) {
      config.module.rules.push({
        test: /\.ya?ml$/,
        use: [
          'js-yaml-loader',
          require.resolve('./path/to/nuxtTranslationsLoader'),
        ]
      })
    }
  }

The goal of this loader is to convert translations from Symfony format to Nuxt-i18n format. Here is an extract of converted translations:

const loadedTranslations = `
        key1: 'I am %age% years old'
        key2: '[0,1[Zero cat|[1,Inf[Some cats'
    `
const expectedConvertedTranslations = `
        key1: 'I am {age} years old'
        key2: 'Zero cat|Some cats|Some cats'
    `

And now the loader itself:

function delimitersReplacer(match, variable) {
    return `{${variable}}`;
}

function splitIntervalReplacer(match, translation, endDelimiter) {
    return `${translation}|${translation}${endDelimiter}`
}

module.exports = function(translationsString) {
    return translationsString
        .replace(/%([^% ]+)%/g, delimitersReplacer)
        .replace(/\]\s*1\s*,\s*Inf\s*\[/g, '')
        .replace(/\{0\}/g, '')
        .replace(/\{1\}/g, '')
        .replace(/\[\s*0\s*,\s*1\s*\[/g, '')
        .replace(/\[\s*1\s*,\s*2\s*\[/g, '')
        .replace(/\[\s*2\s*,\s*Inf\s*\[/g, '')
        .replace(/\{0,1\}([^|'"]*)(|)/g, splitIntervalReplacer)
        .replace(/\[\s*1\s*,\s*Inf\s*\[([^'"]*)(['"])/g, splitIntervalReplacer)
        .replace(/\]\s*0\s*,\s*Inf\s*\[([^'"]*)(['"])/g, splitIntervalReplacer)
}

Where is my cache?

In our case, Symfony is adding a cache-control header. For example, cache-control: public, s-maxage=3600 is telling Varnish to keep this page in cache for 1 hour.

A Nuxt middleware can help a lot:

export default function ({ store, route, res }) {
  const sharedMaxAgeByType = {
    contribution: 21600,
    default: 86400,
  }

  route.meta.map(metaSetting => {
    if (!metaSetting.varnishCacheType) return

    const sharedMaxAge = sharedMaxAgeByType[metaSetting.varnishCacheType] || sharedMaxAgeByType.default

    res.setHeader('Cache-control', `public, s-maxage=${sharedMaxAge}`)
  })
}

where varnishCacheType is set in the page root component, inside the meta properties.

The next steps

First of all, we'll need to finish this migration. For now, 75% of the pages are done.

Also, we are planning to split the translations by page and bundle only the needed ones, instead of shipping all the translations like today. Why not by creating a new loader ?

We will soon write about how we handled the store between Vue & Nuxt.

We hoped you liked it!

Want to go from Vue to Nuxt on your project? Feel free to contact one of our Vue/Nuxt.js experts!


View Original