Building a Multilingual Blog with Next.js and ElmapiCMS Headless CMS

Learn how to build a multilingual blog using Next.js and ElmapiCMS, a headless CMS. This tutorial walks you through creating a language switcher and linking translated content.

R
Raşit Apalak
·
·
9 min read

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

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)

  1. Download the translations-example.json file from the elmapi3-examples repository
  2. In your ElmapiCMS admin panel:
    • Go to DashboardCreate Project
    • Select "Import from file"
    • Upload the translations-example.json file
    • 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 manual fetch calls.


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:

  1. The component checks if we're on a post detail page
  2. If yes, it calls the translation API route with the current post UUID and target locale
  3. The API route uses the SDK's getEntry() method with the translation_locale query parameter to fetch the linked translation
  4. 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.

Share this post:

Ready to build your content API?

Try ElmapiCMS free with our live demo, or get the full source code for $149.

Comparing options first? Read our best headless CMS comparison.

Related Posts