A Complete Guide to Creating a Multilingual Blog with Nuxt.js and ElmapiCMS Translations
Creating a multilingual website with Nuxt.js and ElmapiCMS is straightforward thanks to the translations feature. This tutorial will guide you through building a Vue-based blog that supports multiple languages with seamless navigation between translated content.
Table of Contents
- What You'll Build
- Prerequisites
- Step 1: Set Up the Example Project in ElmapiCMS
- Step 2: Create the Nuxt.js Project
- Step 3: Configure Environment Variables
- Step 4: Create the Elmapi Composable
- Step 5: Create the Language Switcher Component
- Step 6: Create the Home Page
- Step 7: Create the Post Detail Page
- Step 8: Create the Translation Server Route
- How It Works
- Advantages of Nuxt.js for Multilingual Sites
- Next Steps
- Complete Example
What You'll Build
By the end of this tutorial, you'll have:
- A Nuxt.js blog that displays posts in multiple languages
- A Vue language switcher component with smooth transitions
- Server-side API routes for handling translations
- A production-ready example you can customize for your needs
Prerequisites
Before starting, make sure you have:
- Node.js 18+ installed
- An ElmapiCMS instance running (local or remote)
- Basic knowledge of Nuxt.js and Vue.js
- The ElmapiCMS JavaScript SDK available
Step 1: Set Up the Example Project in ElmapiCMS
First, create the example project in your ElmapiCMS instance. This includes sample blog posts with translations in English, French, and Spanish.
Option 1: Import JSON Template (Recommended)
- Download the
translations-example.jsonfile from the elmapi3-examples repository - In your ElmapiCMS admin panel:
- Go to Dashboard → Create Project
- Select "Import from file"
- Upload the
translations-example.jsonfile - Click Create Project
This creates:
- A project with three locales:
en,fr,es - A blog posts collection with fields for title, slug, content, excerpt, and published date
- Sample blog posts with translations linked across all three languages
Tip: You can generate more multilingual content using AI - see our tutorial on generating content with AI.
Option 2: Use Seeder (Legacy)
If you prefer using seeders, you can still run:
php artisan db:seed --class=TranslationsExampleSeeder
Step 2: Create the Nuxt.js Project
Create a new Nuxt.js project and install the required dependencies:
npx nuxi@latest init translations-blog
cd translations-blog
npm install @elmapicms/js-sdk@^0.4.0 @nuxtjs/tailwindcss
Note: This tutorial uses SDK v0.4.0 or later, which includes support for query parameters in the
getEntry()method. This allows us to use the SDK's native methods instead of manualfetchcalls.
Add Tailwind CSS to your nuxt.config.ts:
export default defineNuxtConfig({
modules: ['@nuxtjs/tailwindcss'],
})
Step 3: Configure Environment Variables
Create a .env file in your project root:
ELMAPI_PROJECT_ID=your-project-uuid
ELMAPI_API_URL=https://your-instance.elmapi.com/api
ELMAPI_IMAGE_HOST=your-instance.elmapi.com
Update your nuxt.config.ts to expose these variables:
export default defineNuxtConfig({
modules: ['@nuxtjs/tailwindcss'],
runtimeConfig: {
public: {
elmapiProjectId: process.env.ELMAPI_PROJECT_ID || '',
elmapiApiUrl: process.env.ELMAPI_API_URL || 'http://localhost:8000/api',
elmapiImageHost: process.env.ELMAPI_IMAGE_HOST || 'localhost:8000',
},
elmapiApiKey: process.env.ELMAPI_API_KEY || '',
},
})
Step 4: Create the Elmapi Composable
Create a composable to interact with the ElmapiCMS API. Create composables/useElmapi.ts:
import { createClient } from '@elmapicms/js-sdk';
export interface BlogPost {
uuid: string;
locale: string;
published_at: string | null;
fields: {
title: string;
slug: string;
content: string;
excerpt: string;
published_date: string | null;
};
}
export const useElmapi = () => {
const config = useRuntimeConfig();
function getClient() {
const apiUrl = config.public.elmapiApiUrl;
const apiToken = config.elmapiApiKey || '';
const projectId = config.public.elmapiProjectId;
if (!projectId) {
throw new Error('ELMAPI_PROJECT_ID environment variable is required');
}
return createClient(apiUrl, apiToken, projectId);
}
const getPosts = async (locale: string = 'en'): Promise<BlogPost[]> => {
try {
const client = getClient();
const entries = await client.getEntries('blog-posts', { locale });
return Array.isArray(entries) ? entries as BlogPost[] : [];
} catch (error) {
console.error('Error fetching posts:', error);
return [];
}
};
const getPost = async (uuid: string, locale?: string): Promise<BlogPost | null> => {
try {
const client = getClient();
const params = locale ? { locale } : undefined;
// SDK returns response.body which contains { data: {...} }
const entry = await client.getEntry('blog-posts', uuid, params) as any;
const blogPost = (entry?.data || entry) as BlogPost;
return blogPost || null;
} catch (error) {
console.error('Error fetching post:', error);
return null;
}
};
const getPostTranslation = async (uuid: string, targetLocale: string): Promise<BlogPost | null> => {
try {
const client = getClient();
// SDK returns response.body which contains { data: {...} }
const entry = await client.getEntry('blog-posts', uuid, {
translation_locale: targetLocale,
}) as any;
const blogPost = (entry?.data || entry) as BlogPost;
return blogPost || null;
} catch (error) {
console.error('Error fetching translation:', error);
return null;
}
};
return {
getPosts,
getPost,
getPostTranslation,
};
};
Step 5: Create the Language Switcher Component
Create a Vue component for the language switcher. Create components/LanguageSwitcher.vue:
<template>
<div class="language-switcher">
<button
v-for="locale in locales"
:key="locale.code"
@click="switchLanguage(locale.code)"
:disabled="loading === locale.code || currentLocale === locale.code"
:class="[
'lang-btn',
{ active: currentLocale === locale.code, loading: loading === locale.code }
]"
>
{{ locale.label }}
</button>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
currentLocale: string;
currentPostUuid?: string;
}>();
const router = useRouter();
const route = useRoute();
const locales = [
{ code: 'en', label: 'English' },
{ code: 'fr', label: 'Français' },
{ code: 'es', label: 'Español' },
];
const loading = ref<string | null>(null);
const switchLanguage = async (targetLocale: string) => {
if (targetLocale === props.currentLocale) return;
if (props.currentPostUuid && route.path.startsWith('/post/')) {
loading.value = targetLocale;
try {
const response = await $fetch(`/api/translations/${props.currentPostUuid}`, {
params: { locale: targetLocale },
});
if (response && response.uuid) {
await router.push(`/post/${response.uuid}?locale=${targetLocale}`);
} else {
await router.push(`/?locale=${targetLocale}`);
}
} catch (error) {
console.error('Error fetching translation:', error);
await router.push(`/?locale=${targetLocale}`);
} finally {
loading.value = null;
}
} else {
await router.push(`/?locale=${targetLocale}`);
}
};
</script>
<style scoped>
.language-switcher {
display: flex;
gap: 0.5rem;
margin: 1rem 0;
}
.lang-btn {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
background: white;
cursor: pointer;
border-radius: 4px;
font-size: 0.875rem;
transition: all 0.2s;
}
.lang-btn:hover:not(:disabled) {
background: #f5f5f5;
border-color: #999;
}
.lang-btn.active {
background: #0070f3;
color: white;
border-color: #0070f3;
}
.lang-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
Step 6: Create the Home Page
Create the home page that lists blog posts. Create pages/index.vue:
<template>
<div class="container">
<header>
<h1>Blog Posts</h1>
<LanguageSwitcher :current-locale="locale" />
</header>
<div class="post-list">
<div v-if="pending">Loading...</div>
<div v-else-if="posts.length === 0">
<p>No posts found.</p>
</div>
<article
v-else
v-for="post in posts"
:key="post.uuid"
class="post-card"
>
<NuxtLink :to="`/post/${post.uuid}?locale=${locale}`">
<h2>{{ post.fields.title }}</h2>
</NuxtLink>
<p v-if="post.fields.excerpt" class="excerpt">
{{ post.fields.excerpt }}
</p>
<p v-if="post.fields.published_date" class="date">
{{ formatDate(post.fields.published_date, locale) }}
</p>
</article>
</div>
</div>
</template>
<script setup lang="ts">
const route = useRoute();
const locale = computed(() => (route.query.locale as string) || 'en');
const { getPosts } = useElmapi();
const { data: posts, pending } = await useAsyncData(
`posts-${locale.value}`,
() => getPosts(locale.value)
);
const formatDate = (dateString: string, locale: string) => {
return new Date(dateString).toLocaleDateString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
</script>
Step 7: Create the Post Detail Page
Create the post detail page. Create pages/post/[uuid].vue:
<template>
<div class="container">
<header>
<NuxtLink :to="`/?locale=${locale}`" class="back-link">
← Back to Posts
</NuxtLink>
<LanguageSwitcher
:current-locale="locale"
:current-post-uuid="post?.uuid"
/>
</header>
<article v-if="post" class="post-detail">
<h1>{{ post.fields.title }}</h1>
<div class="meta">
<span>
{{
post.fields.published_date
? formatDate(post.fields.published_date, locale)
: 'No date'
}}
</span>
<span style="margin-left: 1rem; color: #999">
({{ post.locale.toUpperCase() }})
</span>
</div>
<div class="content" v-html="post.fields.content" />
</article>
<div v-else-if="pending">Loading...</div>
<div v-else>Post not found</div>
</div>
</template>
<script setup lang="ts">
const route = useRoute();
const uuid = route.params.uuid as string;
const locale = computed(() => (route.query.locale as string) || 'en');
const { getPost } = useElmapi();
const { data: post, pending } = await useAsyncData(
`post-${uuid}-${locale.value}`,
() => getPost(uuid, locale.value)
);
const formatDate = (dateString: string, locale: string) => {
return new Date(dateString).toLocaleDateString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
</script>
Step 8: Create the Translation Server Route
Create a server API route to handle translation requests. Create server/api/translations/[uuid].get.ts:
import { createClient } from '@elmapicms/js-sdk';
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();
const uuid = getRouterParam(event, 'uuid');
const query = getQuery(event);
const locale = query.locale as string;
if (!uuid || !locale) {
throw createError({
statusCode: 400,
message: 'UUID and locale are required',
});
}
try {
const client = createClient(
config.public.elmapiApiUrl,
config.elmapiApiKey || '',
config.public.elmapiProjectId
);
const entry = await client.getEntry('blog-posts', uuid, {
translation_locale: locale,
}) as any;
if (!entry) {
throw createError({
statusCode: 404,
message: 'Translation not found',
});
}
// Return translation directly (not wrapped in { data: ... })
// to match what LanguageSwitcher expects
return entry?.data || entry;
} catch (error: any) {
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
message: 'Failed to fetch translation',
});
}
});
How It Works
Nuxt.js provides several advantages for building multilingual websites:
- Server-Side Rendering: Posts are fetched on the server, improving SEO and initial load times
- Composables: The
useElmapicomposable provides a clean API for fetching content - Server Routes: Translation requests are handled server-side, keeping API keys secure
- Reactive Data: Vue's reactivity system makes it easy to update the UI when switching languages
The translations feature uses a translation_group_id to link entries across locales. When you switch languages:
- The language switcher calls the server route with the current post UUID and target locale
- The server route uses the SDK's
getEntry()method with thetranslation_localeparameter to fetch the linked translation - Nuxt navigates to the translated post's UUID
The SDK's getEntry() method (v0.4.0+) supports query parameters like locale and translation_locale, making it easy to fetch entries in specific locales or their translations without needing to use fetch directly.
Advantages of Nuxt.js for Multilingual Sites
- Built-in SSR: Better SEO for each language version
- Auto-imports: Composables and components are automatically available
- TypeScript Support: Full type safety out of the box
- Server Routes: Secure API calls without exposing keys to the client
- Vue Ecosystem: Access to the entire Vue.js ecosystem
Next Steps
Now that you have a working multilingual blog, you can:
- Add i18n routing for language-specific URLs
- Implement language detection based on browser settings
- Add SEO metadata for each language
- Customize the styling with Tailwind CSS
- Add features like categories, tags, or search
Complete Example
A complete working example is available in the elmapi3-examples repository. The Nuxt.js example includes:
- Full Vue 3 implementation with Composition API
- Server-side rendering for better SEO
- TypeScript support throughout
- Tailwind CSS for styling
- Error handling and loading states
Building multilingual websites with Nuxt.js and ElmapiCMS gives you the power of Vue.js with the performance of server-side rendering. The translations API handles the complexity of linking content, so you can focus on creating great user experiences.