Come creare un blog Jamstack multilingua con Nuxt.js e Strapi CMS

Parole
Andrea Stagi
Tempo di lettura
9

Jamstack (Javascript, API e Markup Stack) è una terminologia che ruota attorno a un nuovo modo di fare progetti web

I

In questo tutorial imparerai a costruire un blog multilingua con l'approccio Jamstack utilizzando Nuxt.js, un potente framework basato su Vue.js che supporta le modalità di rendering SPA, SSR e la generazione statica e un CMS Headless, Strapi, per memorizzare gli articoli e servirli tramite GraphQL. Per il setup di locale di Strapi puoi seguire questa guida altrimenti puoi tranquillamente utilizzare la nostra istanza in sola lettura https://strapi.lotrek.net/.

Il codice di questo tutorial è disponibile in questo repository.

La struttura del backend Strapi

Con Strapi abbiamo predisposto una struttura piuttosto banale per memorizzare i nostri Post che contengono una referenza 1 a molti con gli elementi TransPost dove avviene l'effettiva traduzione.

       ____________                        ____________
      |    POST    |                      | TRANS_POST |
       ============                        ============
      | published  |                      | language   |
      | created_at | <--(1)-------(N)-->> | title      |
      |            |                      | content    |
      |            |                      | slug       |
       ============                        ============

Se vuoi già giocare un po' con GraphQL all'interno di questo schema ed esplorare la composizione dei dati puoi andare sul GraphQL playground dedicato. Ricorda però che il focus principale di questo tutorial è Nuxt.js, quindi ometteremo eventuali approfondimenti del backend. Inoltre grazie alla versatilità di questo framework puoi utilizzare qualsiasi backend, anche Wordpress, come abbiamo fatto noi per il nostro progetto A Good Magazine

Setup del progetto con Nuxt.js

Per cominciare assicurati di avere un ambiente con Node.js installato almeno alla versione 12. Installa Nuxt.js globalmente e crea una nuova app chiamata multilangblog

npx create-nuxt-app multilangblog

Ricorda di selezionare l'opzione axios (sarà necessario in seguito) e aggiungi un framework UI come Buefy.

Crea un client per leggere gli articoli

Installa apollo-fetch per interrogare il nostro server Strapi con GraphQL

yarn add apollo-fetch

dopodiché crea un file index.js nella cartella services per wrappare tutte le richieste. Questo client implementerà 3 metodi:

  • getAllPostsHead: prende tutti i post in uno specifico linguaggio, mostrando gli attributi slug e title.
  • getAllPosts: prende tutti i post in uno specifico linguaggio, mostrando gli attributi slugtitlecontent e gli articoli nelle altre lingue per riprendere lo slug tradotto.
  • getSinglePost: prende un singlo post specificando lingua e slug con tutti gli attributi, e gli articoli nelle altre lingue per riprendere lo slug tradotto.
import { createApolloFetch } from 'apollo-fetch'
export default class BlogClient {
  constructor () {
    this.apolloFetch = createApolloFetch({ uri: `${process.env.NUXT_ENV_BACKEND_URL}/graphql` })
  }
  getAllPostsHead (lang) {
    const allPostsQuery = `
      query AllPosts($lang: String!) {
        transPosts(where: {lang: $lang}) {
          slug
          title
        }
      }
    `
    return this.apolloFetch({
      query: allPostsQuery,
      variables: {
        lang
      }
    })
  }
  getAllPosts (lang) {
    const allPostsQuery = `
      query AllPosts($lang: String!) {
        transPosts(where: {lang: $lang}) {
          slug
          title
          content
          post {
            published
            transPosts(where: {lang_ne: $lang}) {
              slug
              lang
            }
          }
        }
      }
    `
    return this.apolloFetch({
      query: allPostsQuery,
      variables: {
        lang
      }
    })
  }
  getSinglePost (slug, lang) {
    const simplePostQuery = `
      query Post($slug: String!, $lang: String!) {
        transPosts(where: {slug : $slug, lang: $lang}) {
          slug
          title
          content
          post {
            published
            transPosts(where: {lang_ne: $lang}) {
              slug
              lang
            }
          }
        }
      }
    `
    return this.apolloFetch({
      query: simplePostQuery,
      variables: {
        slug,
        lang
      }
    })
  }
}

Per far sì che BlogClient sia accessibile ovunque noi utilizziamo il context (ad esempio nella funzione asyncData). Crea il file plugins/ctx-inject.js

import BlogClient from '~/services'
export default ({ app }, inject) => {
  app.$blogClient = new BlogClient()
}

e aggiungilo alla sezione plugins nel file nuxt.config.js

export default {
  // ...
  plugins: ['~/plugins/ctx-inject.js']
}

La view principale

La struttura finale di questo blog sarà piuttosto semplice, nella homepage (/) ci sarà una lista di articoli con il relativo link (/blog/<postslug>). Adesso che il BlogClient è accessibile dal context, riscrivi il componente HomePage (pages/index.vue) per prendere tutti gli articoli nel metodo asyncData e renderizza titolo e link per ogni articolo. Il metodo asyncData riceve il context come primo argomento e grazie al nostro plugin scritto nel paragrafo precedente, BlogClient sarà accessibile attraverso context.app.$blogClient

<template>
  <section class="section">
    <div class="is-mobile">
      <div v-for="post in posts" :key="post.slug">
        <h2>{{ post.title }}</h2>
        <nuxt-link :to="{name: 'blog-slug', params:{slug: post.slug}}">Read more...</nuxt-link>
      </div>
    </div>
  </section>
</template>
<script>
export default {
  name: 'HomePage',
  async asyncData ({ app }) {
    const postsData = await app.$blogClient.getAllPostsHead('en')
    return { posts: postsData.data.transPosts }
  },
  data () {
    return {
      posts: []
    }
  }
}
</script>

Aggiungi la route /blog/<postslug> creando il componento BlogPost (pages/blog/_slug.vue). Installa il componente Vue Markdown per renderizzare gli articoli correttamente, visto che il contenuto dei post da Strapi arriva in formato Markdown (yarn add vue-markdown)

<template>
  <section class="section">
    <div class="is-mobile">
      <h2>{{ post.title }}</h2>
      <vue-markdown>{{ post.content }}</vue-markdown>
    </div>
  </section>
</template>
<script>
export default {
  name: 'BlogPost',
  components: {
    'vue-markdown': VueMarkdown
  },
  async asyncData ({ app, route }) {
    const postsData = await app.$blogClient.getSinglePost(route.params.slug, 'en')
    return { post: postsData.data.transPosts[0] }
  },
  data () {
    return {
      post: null
    }
  }
}
</script>

Aggiungiamo le lingue

Per aggiungere il supporto all'internazionalizzazione (i18n) installa il modulo Nuxt i18n

yarn add nuxt-i18n

Abilitalo nella sezione module del file nuxt.config.js

{
  modules: ['nuxt-i18n']
}

e configura la sezione i18n

const LOCALES = [
  {
    code: 'en',
    iso: 'en-US'
  },
  {
    code: 'es',
    iso: 'es-ES'
  },
  {
    code: 'it',
    iso: 'it-IT'
  }
]
const DEFAULT_LOCALE = 'en'
export default {
  // ...
  i18n: {
    locales: LOCALES,
    defaultLocale: DEFAULT_LOCALE,
    encodePaths: false,
    vueI18n: {
      fallbackLocale: DEFAULT_LOCALE,
      messages: {
        en: {
          readmore: 'Read more'
        },
        es: {
          readmore: 'Lee mas'
        },
        it: {
          readmore: 'Leggi di più'
        }
      }
    }
  }
  // ...
}

Adesso puoi modificare il componente HomePage: negli elementi nuxt-link devi usare il metodo localePath e renderizzare la label readmore usando la funzione $t

<nuxt-link :to="localePath({name: 'blog-slug', params:{slug: post.slug}})">{{ $t('readmore') }}</nuxt-link>

Nella funzione asyncData puoi richiamare il metodo getAllPostsHead del nostro client, prendendo il linguaggio corrente dalla variabile store.$i18n del context

// ....
async asyncData ({ app, store }) {
  const postsData = await app.$blogClient.getAllPostsHead(
    store.$i18n.locale
  )
  return { posts: postsData.data.transPosts }
},
// ....

Fai la stessa cosa nel componente BlogPost usando route.params.slug per riprendere lo slug

// ....
async asyncData ({ app, route, store }) {
  const postsData = await app.$blogClient.getSinglePost(
    route.params.slug, store.$i18n.locale
  )
  return { post: postsData.data.transPosts[0] }
},
// ....

Adesso possiamo cominciare a creare un elemento fondamentale per un blog multilingua come si deve, il LanguageSwitcher (components/LanguageSwitcher.vue) che, come si evince dal nome, servirà per cambiare la lingua in ogni parte del sito.

<template>
  <b-navbar-dropdown :label="$i18n.locale">
    <nuxt-link v-for="locale in availableLocales" :key="locale.code" class="navbar-item" :to="switchLocalePath(locale.code)">
      {{ locale.code }}
    </nuxt-link>
  </b-navbar-dropdown>
</template>
<script>
export default {
  computed: {
    availableLocales () {
      return this.$i18n.locales.filter(locale => locale.code !== this.$i18n.locale)
    }
  }
}
</script>

Questo componente deve essere incluso in layouts/default.vue per renderlo disponibile sulla barra di navigazione. Il codice è molto semplice, grazie al modulo i18n di Nuxt basterà chiamare la funzione switchLocalePath per prendere il link della pagina nelle altre lingue. Per far sì che anche i parametri dinamici della url come ad esempio lo slug vengano passati al nostro LanguageSwitcher è sufficente utilizzare la funzione store.dispatch nel nostro componente BlogPost

//...
async asyncData ({ app, route, store }) {
  const postsData = await app.$blogClient.getSinglePost(
    route.params.slug, store.$i18n.locale
  )
  await store.dispatch(
    'i18n/setRouteParams',
    Object.fromEntries(postsData.data.transPosts[0].post.transPosts.map(
      el => [el.lang, { slug: el.slug }])
    )
  )
  return { post: postsData.data.transPosts[0] }
},
//...

Un po' di info in più sul language switcher

Ricorda di settare la variabile di ambiente NUXT_ENV_BACKEND_URL usata dal BlogClient con .env o direttamente (export NUXT_ENV_BACKEND_URL=https://strapi.lotrek.net) e lancia il tutto in modalità sviluppo con

yarn dev

La generazione statica

Per generare staticamente il nostro blog multilingua lancia

yarn generate

Il risultato finale sarà una lista di tutte le route generate all'interno della cartella dist nella root del progetto

ℹ Generating pages
✔ Generated /it/
✔ Generated /es/
✔ Generated /
✨  Done in 43.49s.

come si può vedere tutte le route dinamiche non vengono generate, questo perchè Nuxt.js non sa di per sé come generarle. Per aggiungere il supporto alla generazione delle route dinamiche è necessario implementare il metodo routes all'interno della sezione generate nel file nuxt.config.js e ritornare una lista di oggetti contenenti la route che si vuol generare e il payload contenente il post.

import BlogClient from './services'
// ...
export default {
  // ...
  generate: {
    routes: async () => {
      const client = new BlogClient()
      let routes = []
      let postsData = []
      for (const locale of LOCALES) {
        postsData = await client.getAllPosts(locale.code)
        routes = routes.concat(postsData.data.transPosts.map((post) => {
          return {
            route: `${locale.code === DEFAULT_LOCALE ? '' : '/' + locale.code}/blog/${post.slug}`,
            payload: post
          }
        }))
      }
      return routes
    }
  }
  //...
}

Dato che il payload è disponibile nel context, si può fare refactor di asyncData nel componente BlogPost per prendere l'articolo direttamente context.payload

const getSinglePostFromContext = async ({ app, route, store, payload }) => {
  if (payload) {
    return payload
  }
  const postsData = await app.$blogClient.getSinglePost(
    route.params.slug, store.$i18n.locale
  )
  return postsData.data.transPosts[0]
}
export default {
  name: 'BlogPost',
  async asyncData (context) {
    const singlePost = await getSinglePostFromContext(context)
    await context.store.dispatch(
      'i18n/setRouteParams',
      Object.fromEntries(singlePost.post.transPosts.map(
        el => [el.lang, { slug: el.slug }])
      )
    )
    return { post: singlePost }
  },
  // ...
}

Lancia yarn generate di nuovo

ℹ Generating pages
✔ Generated /it/
✔ Generated /es/
✔ Generated /
✔ Generated /blog/hello-world
✔ Generated /it/blog/ciao-mondo
✨  Done in 33.82s.

Adesso Nuxt.js riesce a generere anche le route dinamiche

Puoi testare il sito generato staticamente utilizzando http-server, lanciandolo con

http-server ./dist

Un problema che si nota dopo aver effettuato la generazione statica è che asyncData viene chiamata ogni volta durante la navigazione, il che comporta che un server che espone i contenuti attraverso la sua API come il nostro Strapi debba sempre essere online altrimenti apparirà l'errore "Failed to fetch". Questo è un problema noto al team di Nuxt.js che sta già lavorando per supportare l'opzione di full static generation nella prossima versione. Per ovviare a questo problema per adesso si può usare il plugin nuxt-payload-extractor come suggerito dallo stesso team di Nuxt.js. Una volta installato con

yarn add nuxt-payload-extractor

aggiungi il modulo in nuxt.config.js

{
  modules: [
    // ...
    'nuxt-payload-extractor'
  ]
}

il codice finale di getSinglePostFromContext sarà

const getSinglePostFromContext = async ({ $axios, $payloadURL, app, route, store, payload }) => {
  if (payload) {
    return payload
  }
  let postsData = null
  if (process.static && process.client && $payloadURL) {
    postsData = await $axios.$get($payloadURL(route))
    return postsData.post
  }
  postsData = await app.$blogClient.getSinglePost(
    route.params.slug, store.$i18n.locale
  )
  return postsData.data.transPosts[0]
}

Ovviamente è necessario aggiungere il nuxt-payload-extractor laddove si fetchano i dati, quindi anche in HomePage

async asyncData ({ $axios, $payloadURL, app, route, store }) {
  if (process.static && process.client && $payloadURL) {
    return await $axios.$get($payloadURL(route))
  }
  const postsData = await app.$blogClient.getAllPostsHead(
    store.$i18n.locale
  )
  return { posts: postsData.data.transPosts }
}

Rigenerando il sito, la funzione di asyncData non va più a chiedere i dati al server ma attraverso il payload estratto dal plugin.

Aggiungere un supporto alle custom path

A volte capita che occorre fornire delle custom path per le route dinamiche in un multilingua, ad esempio per la lingua inglese avere la url /blog/:slug, ma per lo spagnolo fornire invece /artículos/:slug e magari per l'italiano /articoli/:slug. Seguendo la documentazione di nuxt-i18n bisogna specificare queste route nella sezione i18n in nuxt.config.js

i18n {
  // ...
  parsePages: false,
  pages: {
    'blog/_slug': {
      it: '/articoli/:slug',
      es: '/artículos/:slug',
      en: '/blog/:slug'
    }
  },
  // ...
}

Consiglio di spostare tutte le custom path in un file separato i18n.config.js per renderle disponibili poi nella funzione generate e nella sezione i18n

export default {
  pages: {
    'blog/_slug': {
      it: '/articoli/:slug',
      es: '/artículos/:slug',
      en: '/blog/:slug'
    }
  }
}

e importare questo file quindi in nuxt.config.js

import i18nConfig from './i18n.config'
// ...
export default {
  // ...
  i18n: {
    locales: LOCALES,
    defaultLocale: DEFAULT_LOCALE,
    parsePages: false,
    pages: i18nConfig.pages,
    encodePaths: false,
    vueI18n: {
      fallbackLocale: DEFAULT_LOCALE,
      // ...
    }
  },
  // ...

Ovviamente a questo punto occorre riscrivere la funzione generate e prendere la giusta custom path a seconda della lingua (per semplicità ho fatto un'ingenua replace)

routes: async () => {
  const client = new BlogClient()
  let routes = []
  let postsData = []
  for (const locale of LOCALES) {
    postsData = await client.getAllPosts(locale.code)
    routes = routes.concat(postsData.data.transPosts.map((post) => {
      return {
        route: `${locale.code === DEFAULT_LOCALE ? '' : '/' + locale.code}${i18nConfig.pages['blog/_slug'][locale.code].replace(':slug', post.slug)}`,
        payload: post
      }
    }))
  }
  return routes
}

Prova a lanciare yarn generate di nuovo

ℹ Generating pages
✔ Generated /blog/hello-world
✔ Generated /it/articoli/ciao-mondo
✔ Generated /es/artículos/hola-mundo
✔ Generated /es/
✔ Generated /it/
✔ Generated /
✨  Done in 33.82s.

come vedi le path vengono generate nella maniera corretta per tutte e 3 le lingue.

Il codice finale

In questo repository trovi il codice completo di questo tutorial, il risultato è deployato su Netlify CDN all'indirizzo https://eager-shockley-a415b7.netlify.app/. Netlify è uno dei miei servizi preferiti che fornisce cloud hosting per i siti statici, offrendo la funzione di continuous deployment, certificati SSL free, funzioni serverless e tanto altro... Il codice finale aggiunge un po' di cose rispetto al tutorial, per esempio aggiunge gli autori dei post, fa uso di qualche componente esterno che per semplicità qua è stato omesso e abilita la SEO per aggiungere i metadata necessari alle pagine (vedi la sezione SEO nella documentazione di nuxt-18n).

Un'altra cosa molto utile aggiunta nel codice finale è la sitemap, fornita dal modulo Nuxt.js Sitemap. La sitemap è facile da setuppare perchè di default già usa la funzione di generate.routes, perciò tutte le url comprese quelle dinamiche sono automaticamente incluse nella sitemap. La configurazione è altrettanto semplice, basta aggiungere @nuxtjs/sitemap alla fine dell'array della sezione modules innuxt.config.js

  {
    modules: [
      // ...
      '@nuxtjs/sitemap'
    ],
  }

e configurare la sezione sitemap

export default {
  // ...
  sitemap: {
    hostname: BASE_URL,
    gzip: true,
    i18n: DEFAULT_LOCALE
  }
  // ...
}

Ci sono veramente tanti moduli e progetti nell'organizzazione Nuxt Community su Github consiglio di farci un giro!

Happy coding!

Analizza la tua presenza online.

Scrivici per una consulenza gratuita



Richiedi consulenza