A Step-by-Step Guide to Creating a Multilingual Blog with Next.js and ElmapiCMS Translations
Building a multilingual website can be complex, but with ElmapiCMS translations feature and Next.js, you can create a seamless multilingual blog experience. This tutorial will show you how to build a blog that supports multiple languages with a language switcher that automatically navigates to translated content.
Table of Contents
- What You'll Build
- Prerequisites
- Step 1: Set Up the Example Project in ElmapiCMS
- Step 2: Create the Next.js Project
- Step 3: Configure Environment Variables
- Step 4: Create the API Client
- 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 API Route
- How It Works
- Next Steps
- Complete Example
What You'll Build
By the end of this tutorial, you'll have:
- A Next.js blog that displays posts in multiple languages
- A language switcher component that navigates between translated versions
- Integration with ElmapiCMS translations API
- A working example you can use as a foundation for your own projects
Prerequisites
Before starting, make sure you have:
- Node.js 18+ installed
- An ElmapiCMS instance running (local or remote)
- Basic knowledge of Next.js and React
- The ElmapiCMS JavaScript SDK installed
Step 1: Set Up the Example Project in ElmapiCMS
First, you need to create the example project in your ElmapiCMS instance. This project 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 Next.js Project
Create a new Next.js project and install the required dependencies:
npx create-next-app@latest translations-blog
cd translations-blog
npm install @elmapicms/js-sdk@^0.4.0
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.
Step 3: Configure Environment Variables
Create a .env.local 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
Replace the values with your actual ElmapiCMS configuration. You'll get the Project ID from the API Access page in your ElmapiCMS admin panel after creating the project.
Step 4: Create the API Client
Create a composable or utility file to interact with the ElmapiCMS API. Create lib/api.ts:
import { createClient } from '@elmapicms/js-sdk';
// Lazy initialization of SDK client
function getClient() {
const apiUrl = process.env.ELMAPI_API_URL || 'http://localhost:8000/api';
const apiToken = process.env.ELMAPI_API_KEY || '';
const projectId = process.env.ELMAPI_PROJECT_ID || '';
if (!projectId) {
throw new Error('ELMAPI_PROJECT_ID environment variable is required');
}
return createClient(apiUrl, apiToken, projectId);
}
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 async function getPosts(locale: string = 'en'): Promise<BlogPost[]> {
try {
const client = getClient();
const entries = await client.getEntries('blog-posts', {
locale,
});
// SDK returns array directly
return Array.isArray(entries) ? entries as BlogPost[] : [];
} catch (error) {
console.error('Error fetching posts:', error);
return [];
}
}
export async function getPost(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;
}
}
export async function getPostTranslation(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;
}
}
Step 5: Create the Language Switcher Component
Create a language switcher component that handles navigation between translated posts. Create components/LanguageSwitcher.tsx:
'use client';
import { useRouter, usePathname } from 'next/navigation';
import { useState } from 'react';
const LOCALES = [
{ code: 'en', label: 'English' },
{ code: 'fr', label: 'Français' },
{ code: 'es', label: 'Español' },
];
interface LanguageSwitcherProps {
currentLocale: string;
currentPostUuid?: string;
}
export default function LanguageSwitcher({
currentLocale,
currentPostUuid
}: LanguageSwitcherProps) {
const router = useRouter();
const pathname = usePathname();
const [loading, setLoading] = useState<string | null>(null);
const switchLanguage = async (targetLocale: string) => {
if (targetLocale === currentLocale) return;
if (currentPostUuid && pathname.startsWith('/post/')) {
setLoading(targetLocale);
try {
const response = await fetch(
`/api/translations?uuid=${currentPostUuid}&locale=${targetLocale}`
);
if (response.ok) {
const translation = await response.json();
if (translation && translation.uuid) {
router.push(`/post/${translation.uuid}?locale=${targetLocale}`);
} else {
router.push(`/?locale=${targetLocale}`);
}
} else {
router.push(`/?locale=${targetLocale}`);
}
} catch (error) {
console.error('Error fetching translation:', error);
router.push(`/?locale=${targetLocale}`);
} finally {
setLoading(null);
}
} else {
router.push(`/?locale=${targetLocale}`);
}
};
return (
<div className="language-switcher">
{LOCALES.map((locale) => (
<button
key={locale.code}
onClick={() => switchLanguage(locale.code)}
disabled={loading === locale.code || currentLocale === locale.code}
className={`lang-btn ${currentLocale === locale.code ? 'active' : ''}`}
>
{locale.label}
</button>
))}
</div>
);
}
Step 6: Create the Home Page
Create the home page that lists blog posts filtered by locale. Update app/page.tsx:
import { getPosts } from '@/lib/api';
import Link from 'next/link';
import LanguageSwitcher from '@/components/LanguageSwitcher';
interface HomeProps {
searchParams: {
locale?: string;
};
}
export default async function Home({ searchParams }: HomeProps) {
const locale = searchParams.locale || 'en';
const posts = await getPosts(locale);
return (
<div className="container">
<header>
<h1>Blog Posts</h1>
<LanguageSwitcher currentLocale={locale} />
</header>
<div className="post-list">
{posts.length === 0 ? (
<p>No posts found.</p>
) : (
posts.map((post) => (
<article key={post.uuid} className="post-card">
<Link href={`/post/${post.uuid}?locale=${locale}`}>
<h2>{post.fields.title}</h2>
</Link>
{post.fields.excerpt && (
<p className="excerpt">{post.fields.excerpt}</p>
)}
{post.fields.published_date && (
<p className="date">
{new Date(post.fields.published_date).toLocaleDateString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
)}
</article>
))
)}
</div>
</div>
);
}
Step 7: Create the Post Detail Page
Create the post detail page that displays individual posts. Create app/post/[uuid]/page.tsx:
import { getPost } from '@/lib/api';
import Link from 'next/link';
import LanguageSwitcher from '@/components/LanguageSwitcher';
import { notFound } from 'next/navigation';
interface PostPageProps {
params: {
uuid: string;
};
searchParams: {
locale?: string;
};
}
export default async function PostPage({ params, searchParams }: PostPageProps) {
const locale = searchParams.locale || 'en';
const post = await getPost(params.uuid, locale);
if (!post) {
notFound();
}
return (
<div className="container">
<header>
<Link href={`/?locale=${locale}`} className="back-link">
← Back to Posts
</Link>
<LanguageSwitcher currentLocale={locale} currentPostUuid={post.uuid} />
</header>
<article className="post-detail">
<h1>{post.fields.title}</h1>
<div className="meta">
<span>
{post.fields.published_date
? new Date(post.fields.published_date).toLocaleDateString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
})
: 'No date'}
</span>
<span style={{ marginLeft: '1rem', color: '#999' }}>
({post.locale.toUpperCase()})
</span>
</div>
<div
className="content"
dangerouslySetInnerHTML={{ __html: post.fields.content }}
/>
</article>
</div>
);
}
Step 8: Create the Translation API Route
Create a server API route to handle translation requests. Create app/api/translations/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { getPostTranslation } from '@/lib/api';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const uuid = searchParams.get('uuid');
const locale = searchParams.get('locale');
if (!uuid || !locale) {
return NextResponse.json(
{ error: 'UUID and locale are required' },
{ status: 400 }
);
}
try {
const translation = await getPostTranslation(uuid, locale);
if (!translation) {
return NextResponse.json(
{ error: 'Translation not found' },
{ status: 404 }
);
}
// Return translation directly (not wrapped in { data: ... })
// to match what LanguageSwitcher expects (translation.uuid)
return NextResponse.json(translation);
} catch (error) {
console.error('Error fetching translation:', error);
return NextResponse.json(
{ error: 'Failed to fetch translation' },
{ status: 500 }
);
}
}
How It Works
The translations feature in ElmapiCMS uses a translation_group_id to link entries across different locales. When you link two entries as translations, they share the same group ID.
When a user clicks the language switcher:
- The component checks if we're on a post detail page
- If yes, it calls the translation API route with the current post UUID and target locale
- The API route uses the SDK's
getEntry()method with thetranslation_localequery parameter to fetch the linked translation - The app navigates to the translated post's UUID
This creates a seamless experience where users can switch languages and automatically see the translated version of the same content.
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.
Next Steps
Now that you have a working multilingual blog, you can:
- Customize the styling to match your brand
- Add more languages by updating the locales array
- Implement SEO optimizations for each language
- Add language-specific metadata and URLs
- Extend the functionality with comments, categories, or tags
Complete Example
A complete working example is available in the elmapi3-examples repository. You can clone it, follow the setup instructions, and start building your own multilingual website.
The example includes:
- Full Next.js implementation
- Language switcher component
- API integration
- Error handling
- Loading states
Building multilingual websites doesn't have to be complicated. With ElmapiCMS translations feature and Next.js, you can create a professional multilingual blog in just a few steps. The translations API handles the complexity of linking content across languages, so you can focus on creating great content.