Consuming localized/translated data from GraphCMS with 11ty

So, I've been really diving deep into the Static Site Generator pool as of late. For me it's almost a nostalgic return to "the good ol' days". I know, I sound older than your grandparents. I am an Elder Millennial, after all. Back in my day (I'm gonna continue sounding like this - be forewarned) - static sites were it. No fancy server-side rendering, no drag-and-drop upload, no responsive image fetching. None of the things that we modern web developers take for granted.

Consuming GraphCMS data isn't the easiest thing in the world. Well, it's not difficult per se, but it gets a bit tricky to do in 11ty when you add lozalization support. The reason for this, is that GraphCMS will give you an array of objects, where each object is the localization, and within that object, you get your data. I.e. each localization needs to be it's own item. This is further complicated when you take into account all the relationships. I'm gonna supply an example to give a better idea of what I mean further down.

To follow along, you're gonna need:

You should also create the file .env in your root folder. It should look something like this:

GCMS_ENDPOINT=
GCMS_TOKEN=

Fill those in with whatever Endpoint and Token you generate in your GraphCMS settings.

My content models looks roughly like this. In reality it has a lot more of course, but I'm only including what's relevant to this write-up.

Model: Services

  • Fields:
    • Name
    • Slug
    • Eyecatcher (relationship)
    • Description
    • Content Body

Model: Eyecatcher

  • Fields:
    • Title
    • Title Support
    • etc.

Now, in order to get this data from GraphCMS, we're gonna need to create at least three files:

  • /services.11tydata.js
  • /services.njk
  • /_includes/layouts/services.njk (or wherever you place your template files)

/services.11tydata.js

require("dotenv").config();
const { gql } = require("graphql-request");
const { GraphQLClient } = require("graphql-request");

const client = new GraphQLClient(process.env.GCMS_ENDPOINT);

const requestHeaders = {
	authorization: "Bearer " + process.env.GCMS_TOKEN,
};

const variables = {};

const Query = gql`
	query {
		services(
			locales: [en, sv]
		) {
			localizations(includeCurrent: true) {
				locale
				id
				name
				slug
				description
				contentBody {
					html
				}
				eyecatcher {
					localizations(includeCurrent: true) {
						locale
						id
						title
						titleSupport
					}
				}
			}
		}
	}
`;

const getServices = async () => {
	try {
		const { services } = await client.request(
			Query,
			variables,
			requestHeaders
		);
		const result = services.map((page) => ({
			// process things if necessary.
			...page,
		}));

		return result;
	} catch (e) {
		throw new Error("There was a problem getting Services", e);
	}
};

module.exports = async () => {
	const services_raw = await getServices();

	let localizations;
	let services_full = new Array();

	services_raw.forEach((item, index) => {
		localizations = item.localizations;
		localizations.forEach((subitem, index2) => {
			let preSlug = '';
			if (subitem.locale === 'sv') {
				preSlug = 'tjanster';
			} else {
				preSlug = 'services';
			}
			localizations[index2]['pre_slug'] = preSlug;
		});
		
		services_full.push(localizations);
	});

	const services = services_full.reduce((acc, items) => ([...acc, ...items]), []);
	return { services };
};

So what's going on here?
Well, first of all, we're fetching our data in the way we want it pulled down. Now, any data that I want localized, I need to wrap in localizations(includeCurrent: true) { ... }. If I were to console.log() services_raw (within module.exports), I'd get a response that looks something like this:

[
  { localizations: [ [Object], [Object] ] },
  { localizations: [ [Object], [Object] ] },
  { localizations: [ [Object], [Object] ] },
  { localizations: [ [Object], [Object] ] },
  { localizations: [ [Object], [Object] ] },
  { localizations: [ [Object], [Object] ] }
]

Pretty difficult to see what all that is. But, it's an array, so I create a forEach loop. If I console.log item.localizations, I get this:

[
	{
		locale: 'sv',
		id: 'cknisezawsny90b59',
		name: 'Service name SV',
		slug: 'service-name-sv',
		description: 'Lorem ipsum dolor sit amet',
		contentBody: {
			html: '<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec vel nisl.</p>'
		},
		eyecatcher: { localizations: [Array] }
	},
	{
		locale: 'en',
		id: 'cknisezawsny90b59',
		name: 'Service name EN',
		slug: 'service-name-en',
		description: 'Lorem ipsum dolor sit amet',
		contentBody: {
			html: '<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec vel nisl.</p>'
		},
		eyecatcher: { localizations: [Array] }
	}
]

Now, the above is only one service out of six.

If we left it as is, 11ty wouldn't know what to do. It can't traverse such an array/object stack and know what is a post and what isn't. In fact, services_raw would be seen as a single post by 11ty. So, we have to manipulate it. Essentially, we have to take each node within the localization object and put it in a new array so they're all at the top level of the array. 11ty will then be able to step through it in order to create each page. That's the services_full.push(localizations);.

Sidenote: Because I want the services to be in a sub-folder which is dependent on what locale it is, I've also added some code in there for that. Since this is a common thing, I've left it in to show one way of achieving this. This only works with one level of sub-folders, though.

The last step is to create the final { services } object from the services_full array. I use reduce for this in order to remove excess levels that were added when looping through the original. This is what is then returned.

Two gotchas:

Naming: 11ty is quite.. odd, with this. If you're requesting "services", it's important that both the filename (services.11tydata.js) and the returned object has the same name.
Const name: It's also really important that the const we're creating with our await function (within getServices), does not contain any capital letters. Tbh, I don't know why - but I tried renaming it servicePosts instead of services and it failed (And no, I did not forget to change the name before .map!).

/services.njk

---
pagination:
  data: services
  size: 1
  alias: service
  addAllPagesToCollections: true
permalink: "/{{ service.locale }}/{{ service.pre_slug }}/{{ service.slug }}/"
tags: "services"
layout: layouts/services.njk

eleventyComputed:
  title: "{{ service.name | safe }}"
  lang: "{{ service.locale | safe }}"
---

This is the file that will actually trigger the pagination of the services global data. How this is built, is well-documented (and not too complicated) in the 11ty docs. The code is pure front matter with eleventyComputed added to it, in order to send a few useful variables into the page object upon generation. How this works is also well-documented in the 11ty docs.

Note: The "tags" directive is also important here. It needs to match the global data name for whatever you're fetching.

And lastly, you need the template file to display the paginated items. I'm just including the relevant items here because everything else will be up to how you've designed/structured your site.

<section class="primary-hero">
	{% for loc in service.eyecatcher.localizations %}
		{% if (loc.locale === lang) %}
			<h1>{{ loc.title | safe }}</h1>
			<p class="title-support">
				{{ loc.titleSupport }}
			</p>
		{% endif %}
	{% endfor %}
</section>

This is relatively self-explanatory, I think - even though it looks a little messy.

When we're loading a service (service), we're also loading its relationships. In this case, eyecatcher. Since eyecatcher is also localized, it's going to contain both localizations of this object. In order to display the correct one, we need to check against the currently loaded pages locale. That's why we added lang as a variable in eleventyComputed within the service.njk file.

So, for each localization within the eyecatcher object, we're checking if its locale (loc.locale) is the same as the lang variable. If it is, we show its fields. It's simple and works perfectly.

This can get a bit messy if we need to "dig a hole", i.e. for-loop our way down into the data tree.

Luckily, since we're generating static pages, it won't make a difference performance-wise.


Note:

I got off the ground on this using this write-up by dev.to. Very little code from that is still present, but I think it's a worthy mention.