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.
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 workflowgulp_rename
: To rename templatesgulp-replace
: To create the localized files from our templatesgulp-zip
: To zip our themedel
: To clean the projectyargs
: 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 config
folder.
{
"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 URLsgulp
: it compiled the theme without any scripts or useless files, inside abuild
foldergulp 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 /public/cards /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.