Crisp is an all in one solution to communicate with customers.
Learn more about what we do.

How we made Ghost blog multilingual to leverage our international SEO strategy

Leveraging international SEO is a powerful asset to target multiple markets in different languages. That's why we decided to make our blog international, applying the same strategy as the one we did with our brand platform.

How we made Ghost blog multilingual to leverage our  international SEO strategy

Leveraging international SEO is a powerful asset to target multiple markets in different languages. That's why we decided to make our blog international, applying the same strategy as the one we did with our brand platform.

By default, a Ghost blog can only support one language. You can switch between different languages but not have them work simultaneously. That's why we had to modify our blog to take advantage of an international SEO strategy. Here is a tutorial explaining how we did it.

A bit of context

Ghost had a tutorial in the past explaining how to make your blog multilingual, but many things were missing, so they removed the tutorial from their website. That's why we decided to adapt this tutorial and add additional necessary steps to make it work.

To do so, we adapted the Casper theme we are using at Crisp.

Since we already have a Gulp workflow in our projects at Crisp and Gulp is also used to build Ghost themes, we used Gulp to build our multilingual blog.

This article will tell you:

  • How to create a multilingual blog using Ghost
  • How to automate the compilation of the theme

Prerequisites

Configuring content collections for your locales

To manage different locales on Ghost, we use content collections. We create a content collection for each language we want to support. Everything is stored in the routes.yaml in the settings folder.


routes:

collections:
  /en/blog/:
    filter: "tag:[hash-en]"
    permalink: /en/blog/{slug}/
    template: index-en

  /de/blog/:
    filter: "tag:[hash-de, hash-en]"
    permalink: /de/blog/{slug}/
    template: index-de

  /es/blog/:
    filter: "tag:[hash-es, hash-en]"
    permalink: /es/blog/{slug}/
    template: index-es

  /fi/blog/:
    filter: "tag:[hash-fi, hash-en]"
    permalink: /fi/blog/{slug}/
    template: index-fi

  /fr/blog/:
    filter: "tag:[hash-fr, hash-en]"
    permalink: /fr/blog/{slug}/
    template: index-fr

  /nl/blog/:
    filter: "tag:[hash-nl, hash-en]"
    permalink: /nl/blog/{slug}/
    template: index-nl

  /pl/blog/:
    filter: "tag:[hash-pl, hash-en]"
    permalink: /pl/blog/{slug}/
    template: index-pl

  /pt/blog/:
    filter: "tag:[hash-pt, hash-en]"
    permalink: /pt/blog/{slug}/
    template: index-pt

  /ru/blog/:
    filter: "tag:[hash-ru, hash-en]"
    permalink: /ru/blog/{slug}/
    template: index-ru

  /tr/blog/:
    filter: "tag:[hash-tr, hash-en]"
    permalink: /tr/blog/{slug}/
    template: index-tr

  /zh/blog/:
    filter: "tag:[hash-zh, hash-en]"
    permalink: /zh/blog/{slug}/
    template: index-zh

taxonomies:
  tag: /en/blog/tag/{slug}/
  author: /en/blog/author/{slug}/

Let's go through each section of the routes.yaml.

Routes

This section allows you to add custom routes. There are various use cases for this, but we are not using this section here for this tutorial, so we won't explain this section further.

Collections

Collections list every content collection we have created. Each content collection has a different template, but we will detail it later in this tutorial.

We use internal tags, which are tags not visible to readers on the blog but which still allow organizing your posts. To create an internal tag, you start with hash- and add the slug you want. For example, hash-en. It will be displayed as #en.

Thanks to these internal tags, we can filter to only showcase the right blog posts in the correct locale. We choose to filter with the right locale (tr on /tr/) and English, so English posts are available everywhere on the blog.

Taxonomies

The last one, taxonomies, manages the tag and author permalinks. Since we did not need to have these pages in various languages, we keep these in the taxonomies section and use English as the default locale.

Installing required npm packages

As we said before, we use Gulp to manage our project, so we need various npm packages in our gulpfile.js . Here are the ones you should install via npm or yarn before starting the rest of the tutorial:

  • gulp: To manage our entire workflow
  • gulp_rename: To rename templates
  • gulp-replace: To create the localized files from our templates
  • gulp-zip: To zip our theme
  • del: To clean the project
  • yargs: Only needed if you want to manage development and production configurations

Explaining each template

There are three types of files:

  • The ones we just modified one time and copy each time we compile the theme.
  • The ones where we put every translation of our locale
  • The ones we build for every locale at each compilation, and based on templates.

Here is the list of files we modified once and just copy each time we compile:

  • author.hbs
  • error-404.hbs
  • error.hbs
  • index.hbs
  • post.hbs
  • tag.hbs

Inside each one, we just changed {{!< default}} with {{!< default-en}} and changed partials imports to en each time since English is our default language. This import for example {{\> "en/post-card"}}. This allows us to have these pages available by default on https://crisp.chat/en/.

By the way, the index and post are not used but only kept so the theme is verified by gscan without any issue.

Partials

  • read-time.hbs
  • time.hbs

For example, the read-time.hbs looks like this

{{#match locale "de"}}
  {{reading_time secondes="<1 minute lesen" minute="1 minute lesen" minutes="% minuten lesezeit"}}
  
{{else match locale "es"}}
  {{reading_time secondes="<1 min de lectura" minute="1 min de lectura" minutes="% min de lectura"}}
  
{{else match locale "fi"}}
  {{reading_time secondes="<1 minuutti lukemista" minute="1 minuutti lukemista" minutes="% minuten lezen"}}
  
{{else match locale "fr"}}
  {{reading_time secondes="<1 min de lecture" minute="1 min de lecture" minutes="% min de lecture"}}
  
{{else match locale "nl"}}
  {{reading_time secondes="<1 één minuut lezen" minute="1 één minuut lezen" minutes="% minuten lezen"}}
  
{{else match locale "pl"}}
  {{reading_time secondes="<1 minuta czytania" minute="1 minuta czytania" minutes="% minuty czytania"}}
  
{{else match locale "pt"}}
  {{reading_time secondes="<1 ata de leitura" minute="1 ata de leitura" minutes="% atas de leitura"}}
  
{{else match locale "ru"}}
  {{reading_time secondes="<1 минутное чтение" minute="1 минутное чтение" minutes="% минуты чтения"}}
  
{{else match locale "tr"}}
  {{reading_time secondes="<1 bir dakikalık okuma" minute="1 bir dakikalık okuma" minutes="% okuma dakikaları"}}
  
{{else match locale "zh"}}
  {{reading_time secondes="<1 一分钟阅读" minute="1 一分钟阅读" minutes="% 阅读分钟"}}
  
{{else}}
  {{reading_time secondes="<1 min read" minute="1 min read" minutes="% min read"}}
{{/match}}

We had to translate the reading_time helper made by Ghost to support our language. We did the same with the time helper.

Then we have the files we modified and made templates of, from which we create each localized file.

  • partials/index.hbs
  • partials/post-card.hbs
  • custom-{language}.hbs
  • default-{locale}.hbs
  • index-{locale}.hbs

{locale} is for en, fr, tr, ... and {language} is when I want to use the language name instead of its locale.

Here is the index partial for example

{{!-- The main content area --}}

<main id="site-main" class="site-main outer">
  <div class="inner">
    <div class="post-feed">
      {{#get "posts" filter="tag:[hash-{locale},hash-en]" include="tags,authors" order="published_at desc" page=pagination.page}}
        {{#foreach posts}}
          {{!-- The tag below includes the markup for each post - partials/post-card.hbs --}}
          {{> "{locale}/post-card"}}
        {{/foreach}}
      {{/get}}
    </div>
  </div>
</main>

The custom-{language}.hbs is really interesting. It is often used to create a custom layout for a post, but we are using it here for our different languages.

Each language has its custom-{language}.hbs file, like the other files. It allows us to translate the content needed, import the right partials, and import the right default-{locale}.hbs.

For the custom-{language}.hbs we are using "language" instead of" locale". This way, when we select the template in Ghost editor, we see English in the dropdown, instead of En.

Disclaimer: We have a npm package managing our header, footer and other sections, so we didn't include code for it since it is custom to our needs. We removed the code related to converting the header, footer and two sections from .jade to .hbs and then translating content from JSON files.

Scripting the entire building process with Gulp

The issue with our system is that we create several pretty similar files for every section of the theme. That's when Gulp is coming in handy in helping us automate this aspect of the theme.

Getting Configuration

To get started, we needed a configuration file storing every locale we wanted to support. Everything is stored in common.json in the configfolder.

{
  "LANGS": [
    {
      "code": "en",
      "name": "English",
      "language": "English",
      "country": "United States"
    },

    {
      "code": "fr",
      "name": "Français",
      "language": "French",
      "country": "France"
    },

    {
      "code": "de",
      "name": "Deutsch",
      "language": "German",
      "country": "Deutschland"
    },

    {
      "code": "es",
      "name": "Español",
      "language": "Spanish",
      "country": "España"
    },
    
    {
      "code": "fi",
      "name": "Suomi",
      "language": "Finnish",
      "country": "Suomi"
    },
    
    {
      "code": "nl",
      "name": "Dutch",
      "language": "Dutch",
      "country": "Nederland"
    },

    {
      "code": "pt",
      "name": "Português",
      "language": "Portuguese",
      "country": "Brazil"
    },

    {
      "code": "tr",
      "name": "Türk",
      "language": "Turkish",
      "country": "Türkiye"
    },

    {
      "code": "pl",
      "name": "Polskie",
      "language": "Polish",
      "country": "Polska"
    },

    {
      "code": "ru",
      "name": "Pу́сский",
      "language": "Russian",
      "country": "Russia"
    },
    
    {
      "code": "zh",
      "name": "中国人",
      "language": "Chinese",
      "country": "中国"
    }
  ],
}

We then get this configuration and store it in a variable, CONFIG, that we are using in our gulpfile.js.

// Global config
var CONFIG = {};
var IS_DEVELOPMENT = (args.development !== undefined);

/*
  Acquires the configuration
*/
var get_configuration = function(next) {
  try {
    common_config = require("./src/config/common.json");
    
    CONFIG = lodash.merge(CONFIG, common_config);
 
    next();
    
    } catch (_error) {
      next(
        "Please check that your ghost folder contains common.json"
      );
    }
};

Copying Existing Assets

We are storing our compiled theme in a build folder, but some files are not modified, just copied to this build folder.
Assets are stored in an assets folder in src, so we have various scripts copying their content to the build folder.

/*
  Copy existing CSS files
*/
var copy_css = function() {
  return gulp.src(
    "./src/assets/css/*.css",
  )
  .pipe(
    gulp.dest(
      "./build/assets/css/"
    )
  );
}


/*
  Copy existing JS files
*/
var copy_js = function() {
  return gulp.src(
    "./src/assets/js/*.js"
  )
  .pipe(
    gulp.dest(
      "./build/assets/js/"
    )
  );
}


/*
Copy main content
*/
var copy_main_content = function() {
  return gulp.src(
    [
      "./src/*.hbs",
      "!./src/assets/templates",
      "!./src/assets/templates/**",
      "LICENSE",
      "package.json"
    ]
  )
  .pipe(
    gulp.dest(
      "./build/"
    )
  );
}


/*
Copy existing HBS files
*/
var copy_partials_hbs = function() {
  return gulp.src(
    "./src/partials/**/*.hbs",
  )
  .pipe(
    gulp.dest(
      "./build/partials/"
    )
  );
}

Creating localized partial files

The following script run through each locale in common.json and create a folder with every partial file needed for each locale. It then replaces in the filename and inside the file, every {locale} with the right locale.

/*
  Create partials hbs files for each locale
*/
var create_partials_hbs = function() {
  CONFIG.LANGS.map(function(_config_lang) {
    gulp.src(
      "./src/assets/templates/partials/*.hbs"
    )
    .pipe(
      gulp_replace(
        "{locale}",
        _config_lang.code
      )
    )
    .pipe(
      gulp.dest(
        `./build/partials/${_config_lang.code}`
      )
    );
  });

  return Promise.resolve();
}

Creating the main files

This script does the same thing as the previous one, but this time for the main files, like default.hbs and index.hbs

/*
Create hbs files for each locale
*/
var create_main_hbs = function() {
  let _minify = IS_DEVELOPMENT ? "" : ".min";

  CONFIG.LANGS.map(function(_config_lang) {
    gulp.src(
      [
        "./src/assets/templates/default-{locale}.hbs",
        "./src/assets/templates/index-{locale}.hbs",
        "./src/assets/templates/custom-{language}.hbs"
      ]
    )
    .pipe(
      gulp_rename(
        function(path) {
          path.basename = path.basename.replace("{locale}", _config_lang.code);
          path.basename = path.basename.replace("{language}", _config_lang.language.toLowerCase());
        }
      )
    )
    .pipe(
      gulp_replace(
        "{locale}",
        _config_lang.code
      )
    )
    .pipe(
      gulp_replace(
        "{minify}",
        _minify
      )
    )
    .pipe(
      gulp.dest(
        "./build/"
      )
    );
});

  return Promise.resolve();
}

Cleaning the project

These two scripts delete the build and dist folders.

/*
  Delete build
*/
var delete_build = function() {
  return del(
    [
      "./build",
      "./**.hbs",
      "./assets/**",
      "./partials/**",
      "!./src/**"
    ]
  );
}


/*
  Delete zipped theme
*/
var delete_zip = function() {
  return del(
    "./dist"
  );
}

Zipping the theme

These two functions zip our theme and save it in a dist folder.

/*
  Zip theme in dist folder
*/
var zipper = function() {
const filename = require('./package.json').name + '.zip';
  return gulp.src(
    "./build/**"
  )
  .pipe(
    gulp_zip(filename)
  )
  .pipe(
    gulp.dest(
      "./dist"
    )
  )
}

In the end, we manage everything with three command lines:

  • gulp --development: it allows us to try our theme locally with the local URLs
  • gulp: it compiled the theme without any scripts or useless files, inside a build folder
  • gulp zip: it zips our build folder to create a zipped theme folder, ready to be uploaded on the Ghost dashboard
/*
  Builds project
*/
var build = function() {
  let _series = [
    get_configuration,
    copy_partials_hbs,
    create_main_hbs,
    create_partials_hbs,
    build_sections,
    copy_css,
    copy_js,
    copy_main_content
  ];

  return gulp.series(_series);
}();


/*
  Zip theme
*/
var zip = function() {
  let _series = [
    build,
    zipper
  ];
  
  return gulp.series(_series);
}();


/*
  Cleans project
*/
var clean = function() {
  return gulp.series(
    get_configuration,
    delete_build,
    delete_zip
  );
}();


exports.build   = build;
exports.zip     = zip;
exports.clean   = clean;
exports.default = build;

Managing redirections via reverse proxy in Nginx

We use reverse proxies in Nginx to manage our new URLs and redirect the old URLs to the new ones, to prevent any big impact on SEO. Here is a preview of it below.

root ../crisp-web;

error_page 404 =404 /build/templates/not_found.en.html;

# Enforce URLs to end with a trailing slash
# Notice: the blog administration panel does not support trailing slashes for some URLs

rewrite "^/(?!([a-z]{2})(/blog/ghost/))([^\.]+[^/])$" http://$server_name/$3/ permanent;

location = / {
    rewrite ^/$ http://$server_name/$crisp_web_lang/ permanent;
}

location ~ "^/([a-z]{2})/blog/ghost/.*" {
    rewrite "^/([a-z]{2})/blog/ghost/(.*)$" /ghost/$2 break;

    proxy_pass http://127.0.0.1:2368;

    client_max_body_size 12m;

    proxy_cookie_path ~*^/.* /;

    # Do not buffer responses, otherwise large files might be written 		to a disk cache while being streamed to client
    proxy_buffering off;

    proxy_connect_timeout 5s;
    proxy_read_timeout 5s;
    proxy_send_timeout 5s;

    proxy_http_version 1.1;

    proxy_set_header Accept-Encoding "";

    sub_filter_types application/json;
    sub_filter_once off;
    sub_filter http://$server_name/content/  http://$server_name/static/blog/content/;

    include proxy_http_headers.conf;
    include error_pages_text_rules.conf;
}

location ~ "^/([a-z]{2})/blog/(.*).(xml|xsl)" {
    rewrite "^/([a-z]{2})/blog/(.*).(xml|xsl)" /$2.$3 break;

    proxy_pass http://127.0.0.1:2368;

    proxy_connect_timeout 5s;
    proxy_read_timeout 5s;
    proxy_send_timeout 5s;

    proxy_http_version 1.1;

    proxy_set_header Accept-Encoding "";

    sub_filter_types text/xml text/xsl;
    sub_filter_once off;
    sub_filter http://$server_name/sitemap-pages.xml 	     		http://$server_name/en/blog/sitemap-pages.xml;
    sub_filter http://$server_name/sitemap-posts.xml  http://$server_name/en/blog/sitemap-posts.xml;
    sub_filter http://$server_name/sitemap-authors.xml http://$server_name/en/blog/sitemap-authors.xml;
    sub_filter http://$server_name/sitemap-tags.xml http://$server_name/en/blog/sitemap-tags.xml;
    sub_filter http://$server_name/sitemap.xml http://$server_name/en/blog/sitemap.xml;
    sub_filter //$server_name/sitemap.xsl //$server_name/en/blog/sitemap.xsl;
    sub_filter http://$server_name/content/ http://$server_name/static/blog/content/;

    include proxy_http_headers.conf;
    include error_pages_text_rules.conf;
}

location ~ "^/([a-z]{2})/blog/.*" {
    proxy_pass http://127.0.0.1:2368;

    proxy_connect_timeout 5s;
    proxy_read_timeout 5s;
    proxy_send_timeout 5s;

    proxy_http_version 1.1;

    proxy_set_header Accept-Encoding "";

    sub_filter_types text/xml;
    sub_filter_once off;
    sub_filter http://$server_name/content/ 				    http://$server_name/static/blog/content/;
    sub_filter /static/blog/public/cards /static/blog/static/blog/public/cards;

    include proxy_http_headers.conf;
    include error_pages_text_rules.conf;
}

location ~ /not_found/$ {
    return 404;
}

location ~ ^/(static/blog/.*) {
    rewrite ^/static/blog/(.*)$ /$1 break;

    # Use blog assets
    proxy_pass http://127.0.0.1:2368;

    # Do not buffer responses, otherwise large files might be written 		to a disk cache while being streamed to client
    proxy_buffering off;

    proxy_connect_timeout 5s;
    proxy_read_timeout 10s;
    proxy_send_timeout 10s;

    proxy_http_version 1.1;

    expires 1d;

    include proxy_http_headers.conf;
    include error_pages_text_rules.conf;
}

location ~ ^/((robots\.txt)|(sitemap\.xml))$ {
    rewrite  ^/(.*)$  /build/web/$1;
}

location ~ ^/((humans\.txt)|(favicon\.ico)|(opengraph\.png)|((tile|favicon(-\d+x\d+)?|apple-touch-icon(-\d+x\d+)?)\.png)|(\.well-known/.*)) {
    rewrite  ^/(.*)  /web/$1;
}

location ~ "^/(?!((static/.*)|(sitemap\.xml)|(robots\.txt)|(humans\.txt)|(favicon\.ico)|(opengraph\.png)|(([a-z]{2})/.*)|((tile|favicon(-\d+x\d+)?|apple-touch-icon(-\d+x\d+)?)\.png)|(\.well-known/.*)))" {
    # English is default for the pages accessed via root URLs (eg: /features/ => /en/features/)
    rewrite  ^/(.+)/$  http://$server_name/en/$1/ permanent;
}

include error_pages_text_locations.conf;
include cors_http.conf;

What's new when you publish a blog post

We wanted to make the transition as seamless as possible to the new blog system. That's why you only need to add two things:

  • First, add the corresponding internal tag to your blog post. For example, add the internal tag #pt for a Portuguese blog post.
  • Secondly, select the correct custom template from the drop-down menu. This allows Ghost to know that the entire user interface should be in this language (header, footer, buttons, links, ...)

And that's it! Your blog post can now be published and will be on the right localized part of your blog.

Conclusion

As you are reading this article, the new blog is already in production. We now have a blog that is easier to maintain and that we can scale to improve our international SEO as well as the ability to publish more languages in the future, like our main website.

Thanks to this project, we can now start targeting new markets with content in a specific language.

Ready to improve your customer engagement?