Step-by-Step Guide to building a custom automatic translation switcher block for Gutenberg using Next.js headless configurations
Project Setup: Headless WordPress and Next.js
This guide assumes a functional headless WordPress setup. We’ll be leveraging Next.js for our frontend application, which will consume content from WordPress via its REST API. Ensure your WordPress installation is configured to expose the REST API and that you have a mechanism for managing translations (e.g., WPML, Polylang, or a custom solution). For this example, we’ll assume a basic REST API endpoint for fetching posts.
First, initialize a new Next.js project:
npx create-next-app@latest my-translation-switcher cd my-translation-switcher
Next, install necessary dependencies. We’ll use axios for API requests and potentially a state management library like zustand or react-query for managing the active language. For simplicity, we’ll use React Context for this example.
npm install axios
Gutenberg Block Development Environment
To build a custom Gutenberg block, we need a dedicated development environment. The recommended approach is to use the WordPress `@wordpress/scripts` package, which provides build tools for JavaScript, CSS, and more. This is typically done within a custom WordPress plugin.
Create a new directory for your plugin within wp-content/plugins/. Let’s call it custom-translation-block.
Inside custom-translation-block, create the following file structure:
custom-translation-block/ ├── custom-translation-block.php ├── build/ ├── src/ │ ├── index.js │ ├── block.json │ └── edit.js │ └── save.js └── package.json
Initialize the Node.js project for your plugin:
cd wp-content/plugins/custom-translation-block npm init -y
Install the WordPress scripts package:
npm install @wordpress/scripts --save-dev
Add build scripts to your package.json:
{
"name": "custom-translation-block",
"version": "1.0.0",
"description": "A custom translation switcher block for Gutenberg.",
"main": "build/index.js",
"scripts": {
"build": "wp-scripts build",
"start": "wp-scripts start"
},
"keywords": ["wordpress", "gutenberg", "block"],
"author": "Your Name",
"license": "GPL-2.0-or-later",
"devDependencies": {
"@wordpress/scripts": "^26.10.0"
}
}
Create the main plugin file, custom-translation-block.php:
<?php
/**
* Plugin Name: Custom Translation Switcher Block
* Description: A custom Gutenberg block for managing translation switching.
* Version: 1.0.0
* Author: Your Name
* License: GPL-2.0-or-later
* Text Domain: custom-translation-block
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Registers the block using the metadata loaded from the `block.json` file.
* Behind the scenes, it registers also all assets so they can be enqueued
* through the block editor in the corresponding context.
*
* @see https://developer.wordpress.org/reference/functions/register_block_type/
*/
function custom_translation_block_init() {
register_block_type( __DIR__ . '/build' );
}
add_action( 'init', 'custom_translation_block_init' );
?>
Gutenberg Block Configuration (`block.json`)
The block.json file is crucial for defining your block’s metadata, including its name, title, icon, and supported features. Create src/block.json:
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "custom-translation-block/switcher",
"version": "0.1.0",
"title": "Translation Switcher",
"category": "widgets",
"icon": "translation",
"description": "A block to switch between different language versions of content.",
"keywords": ["translation", "language", "switcher", "i18n"],
"attributes": {
"languages": {
"type": "array",
"default": [
{"code": "en", "name": "English"},
{"code": "es", "name": "Español"}
]
},
"defaultLanguage": {
"type": "string",
"default": "en"
}
},
"supports": {
"html": false
},
"textdomain": "custom-translation-block",
"editorScript": "file:./build/index.js",
"editorStyle": "file:./build/index.css",
"style": "file:./build/style-index.css"
}
In this configuration:
name: Unique identifier for the block.title: User-friendly name displayed in the block inserter.category: Where the block appears in the inserter.icon: Visual representation of the block.attributes: Defines configurable properties of the block. Here, we define an array oflanguagesand adefaultLanguage.editorScript: Path to the JavaScript file for the block editor.editorStyle: Path to the CSS file for the block editor.style: Path to the CSS file for the frontend.
Gutenberg Block Editor Implementation (`edit.js`)
The edit.js file contains the React component that renders your block in the Gutenberg editor. This is where users will configure the languages and default language.
Create src/edit.js:
// src/edit.js
import { __ } from '@wordpress/i18n';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, TextControl, Button, Icon } from '@wordpress/components';
import { useState } from '@wordpress/element';
import './editor.scss'; // For editor-specific styles
export default function Edit({ attributes, setAttributes }) {
const blockProps = useBlockProps();
const { languages, defaultLanguage } = attributes;
// Local state for managing language input
const [newLanguageCode, setNewLanguageCode] = useState('');
const [newLanguageName, setNewLanguageName] = useState('');
const addLanguage = () => {
if (newLanguageCode && newLanguageName) {
const updatedLanguages = [...languages, { code: newLanguageCode, name: newLanguageName }];
setAttributes({ languages: updatedLanguages });
setNewLanguageCode('');
setNewLanguageName('');
}
};
const removeLanguage = (indexToRemove) => {
const updatedLanguages = languages.filter((_, index) => index !== indexToRemove);
setAttributes({ languages: updatedLanguages });
// If the removed language was the default, reset defaultLanguage
if (defaultLanguage === languages[indexToRemove].code) {
setAttributes({ defaultLanguage: updatedLanguages.length > 0 ? updatedLanguages[0].code : '' });
}
};
const updateDefaultLanguage = (event) => {
setAttributes({ defaultLanguage: event.target.value });
};
return (
<>
<InspectorControls>
<PanelBody title={__('Languages', 'custom-translation-block')} initialOpen={true}>
{languages.map((lang, index) => (
<div key={index} style={{ display: 'flex', alignItems: 'center', marginBottom: '8px' }}>
<span style={{ marginRight: '10px' }}>{lang.name} ({lang.code})</span>
<Button
isSmall
isDestructive
onClick={() => removeLanguage(index)}
aria-label={__('Remove language', 'custom-translation-block')}
>
<Icon icon="trash" />
</Button>
</div>
))}
<div style={{ marginTop: '15px', borderTop: '1px solid #eee', paddingTop: '15px' }}>
<TextControl
label={__('New Language Code (e.g., fr)', 'custom-translation-block')}
value={newLanguageCode}
onChange={setNewLanguageCode}
help={__('ISO 639-1 code.', 'custom-translation-block')}
/>
<TextControl
label={__('New Language Name (e.g., Français)', 'custom-translation-block')}
value={newLanguageName}
onChange={setNewLanguageName}
/>
<Button isPrimary onClick={addLanguage}>
{__('Add Language', 'custom-translation-block')}
</Button>
</div>
</PanelBody>
<PanelBody title={__('Default Language', 'custom-translation-block')} initialOpen={false}>
<select value={defaultLanguage} onChange={updateDefaultLanguage}>
{languages.map(lang => (
<option key={lang.code} value={lang.code}>
{lang.name} ({lang.code})
</option>
))}
</select>
</PanelBody>
</InspectorControls>
<div {...blockProps}>
<p>{__('Translation Switcher Block', 'custom-translation-block')}</p>
<p>{__('Configured Languages:', 'custom-translation-block')}</p>
<ul>
{languages.map(lang => (
<li key={lang.code}>{lang.name} ({lang.code})</li>
))}
</ul>
<p>{__('Default:', 'custom-translation-block')} {defaultLanguage}</p>
</div>
</>
);
}
And the corresponding entry point src/index.js:
// src/index.js
import { registerBlockType } from '@wordpress/blocks';
import './style.scss'; // Frontend styles
import Edit from './edit';
import save from './save';
import metadata from './block.json';
registerBlockType(metadata.name, {
edit: Edit,
save,
});
Create a placeholder src/save.js. For this block, the frontend rendering will be handled by Next.js, so the save function can be minimal, perhaps just outputting a placeholder or nothing if all logic is client-side.
// src/save.js
export default function save() {
return null; // Or return a placeholder div if needed
}
Add some basic styles in src/editor.scss and src/style.scss if desired. For example, in src/editor.scss:
/* src/editor.scss */
.wp-block-custom-translation-block-switcher {
border: 1px dashed #ccc;
padding: 15px;
background-color: #f9f9f9;
}
Now, run the build command in your plugin directory:
cd wp-content/plugins/custom-translation-block npm run build
This will compile your JavaScript and CSS into the build/ directory. Activate the “Custom Translation Switcher Block” plugin in your WordPress admin. You should now be able to add the “Translation Switcher” block to your posts and pages and configure its languages in the sidebar.
Frontend Integration with Next.js
The core challenge is to make the selected language from the Gutenberg block available to your Next.js application. Since Gutenberg blocks are rendered server-side by default (or statically), we need a way to pass this configuration. A common approach is to store the block’s attributes in post meta or directly within the post content in a structured format (like JSON) that Next.js can parse.
For this example, let’s assume the block’s attributes (languages and default language) are stored in a custom field (post meta) associated with the post. You would need a WordPress plugin or theme function to extract this meta data.
In your WordPress theme’s functions.php or a custom plugin, you might add code to expose this meta data via the REST API. This is a simplified example; a robust solution would involve custom REST API endpoints or modifying existing ones.
<?php
// Example: Add custom meta to post response
add_action( 'rest_api_init', function () {
register_rest_field( 'post', 'translation_settings', array(
'get_callback' => function( $post_data ) {
// Assuming your block's attributes are saved as post meta
// The key might be 'translation_settings' or similar, depending on how you save it.
// For simplicity, let's assume a meta key 'translation_block_config'
$config = get_post_meta( $post_data['id'], 'translation_block_config', true );
if ( ! empty( $config ) ) {
return json_decode( $config, true );
}
return null;
},
'update_callback' => null, // Not needed for GET
'schema' => null,
) );
} );
// You'll need a mechanism to save the block's attributes to post meta.
// This often involves a server-side save function for the block, or a separate
// mechanism that listens for post updates and serializes block attributes.
// For instance, if you have a custom field 'translation_block_config' where you
// save the JSON string of the block's attributes.
?>
Now, in your Next.js application, fetch this data when retrieving post content.
Create a context provider for managing the current language state in your Next.js app.
Create context/LanguageContext.js:
// context/LanguageContext.js
import React, { createContext, useState, useContext, useEffect } from 'react';
const LanguageContext = createContext();
export const LanguageProvider = ({ children, initialLanguage, availableLanguages, defaultLanguage }) => {
const [currentLanguage, setCurrentLanguage] = useState(initialLanguage || defaultLanguage || 'en');
// Function to set the language, potentially persisting it (e.g., in localStorage)
const setLanguage = (langCode) => {
setCurrentLanguage(langCode);
// Optional: Persist to localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('preferredLanguage', langCode);
}
};
// Effect to load language from localStorage on mount
useEffect(() => {
if (typeof window !== 'undefined') {
const savedLang = localStorage.getItem('preferredLanguage');
if (savedLang && availableLanguages.some(lang => lang.code === savedLang)) {
setCurrentLanguage(savedLang);
} else if (defaultLanguage) {
setCurrentLanguage(defaultLanguage);
}
}
}, [availableLanguages, defaultLanguage]); // Re-run if languages change
const contextValue = {
currentLanguage,
setLanguage,
availableLanguages: availableLanguages || [],
defaultLanguage: defaultLanguage || 'en',
};
return (
<LanguageContext.Provider value={contextValue}>
{children}
</LanguageContext.Provider>
);
};
export const useLanguage = () => useContext(LanguageContext);
Wrap your application with the LanguageProvider in pages/_app.js.
// pages/_app.js
import '../styles/globals.css';
import { LanguageProvider } from '../context/LanguageContext';
import axios from 'axios'; // Assuming you'll fetch initial data
function MyApp({ Component, pageProps }) {
// Fetch initial language settings from WordPress post meta
// This assumes pageProps.post contains the fetched post data including translation_settings
const initialLanguage = pageProps.post?.translation_settings?.defaultLanguage || 'en';
const availableLanguages = pageProps.post?.translation_settings?.languages || [];
const defaultLanguage = pageProps.post?.translation_settings?.defaultLanguage || 'en';
return (
<LanguageProvider
initialLanguage={initialLanguage}
availableLanguages={availableLanguages}
defaultLanguage={defaultLanguage}
>
<Component {...pageProps} />
</LanguageProvider>
);
}
// Example of fetching post data in getInitialProps or getServerSideProps
// MyApp.getInitialProps = async ({ ctx }) => {
// // Fetch post data here, e.g., from WordPress REST API
// // const res = await axios.get(`YOUR_WORDPRESS_API_URL/wp/v2/posts/${ctx.query.id}?_embed`);
// // const post = res.data;
// // return { post };
// return {}; // Placeholder
// };
export default MyApp;
Modify your page or post component (e.g., pages/posts/[id].js) to fetch post data including the translation settings.
// pages/posts/[id].js
import axios from 'axios';
import { useLanguage } from '../context/LanguageContext';
export default function PostPage({ post }) {
const { currentLanguage, setLanguage, availableLanguages, defaultLanguage } = useLanguage();
// Placeholder for translated content - in a real app, you'd fetch this
// based on the currentLanguage.
const getTranslatedContent = (content, langCode) => {
// This is a simplified example. You'd likely have a more sophisticated
// way to fetch or map translations, perhaps using a separate API endpoint
// or a translation management system's API.
if (post.translations && post.translations[langCode]) {
return post.translations[langCode].content;
}
return content; // Fallback to original content
};
const translatedTitle = getTranslatedContent(post.title.rendered, currentLanguage);
const translatedContent = getTranslatedContent(post.content.rendered, currentLanguage);
return (
<div>
<h1>{translatedTitle}</h1>
{/* Language Switcher UI */}
<div>
{availableLanguages.map(lang => (
<button
key={lang.code}
onClick={() => setLanguage(lang.code)}
style={{ fontWeight: currentLanguage === lang.code ? 'bold' : 'normal', margin: '0 5px' }}
>
{lang.name}
</button>
))}
</div>
<div dangerouslySetInnerHTML={{ __html: translatedContent }} />
{/* Display original content if different */}
{currentLanguage !== defaultLanguage && (
<div style={{ marginTop: '20px', borderTop: '1px solid #eee', paddingTop: '10px' }}>
<h3>Original ({defaultLanguage})</h3>
<div dangerouslySetInnerHTML={{ __html: post.content.rendered }} />
</div>
)}
</div>
);
}
export async function getServerSideProps(context) {
const { id } = context.params;
const WORDPRESS_API_URL = process.env.WORDPRESS_API_URL || 'http://your-wordpress-site.com'; // Replace with your WP URL
try {
// Fetch post data including the custom field for translation settings
const res = await axios.get(`${WORDPRESS_API_URL}/wp-json/wp/v2/posts/${id}?_embed`);
const post = res.data;
// Fetch translations if available (this is a placeholder)
// In a real scenario, you'd query for translations based on post ID and language codes.
// For WPML, you might use its API or specific REST API endpoints.
let translations = {};
if (post.translation_settings && post.translation_settings.languages) {
for (const lang of post.translation_settings.languages) {
if (lang.code !== post.locale.locale) { // Avoid fetching current language again
// Example: Fetching translated content for a specific language
// This endpoint structure depends heavily on your translation plugin
try {
const langRes = await axios.get(`${WORDPRESS_API_URL}/wp-json/wp/v2/posts/${id}?lang=${lang.code}`);
if (langRes.data && langRes.data.content) {
translations[lang.code] = {
title: langRes.data.title.rendered,
content: langRes.data.content.rendered,
};
}
} catch (langError) {
console.warn(`Could not fetch translation for language ${lang.code}:`, langError.message);
// Handle cases where translation might not exist or API fails
}
}
}
}
post.translations = translations; // Attach fetched translations to the post object
return {
props: {
post: post,
},
};
} catch (error) {
console.error("Error fetching post:", error);
return {
notFound: true, // Return 404 if post not found or error occurs
};
}
}
Important Considerations for Production:
- Saving Block Attributes: The example assumes block attributes are saved to post meta. You’ll need a robust mechanism (e.g., a server-side save callback for the block or a separate plugin) to serialize the
languagesanddefaultLanguageattributes fromblock.jsonand save them as JSON in a post meta field. - Translation Fetching: The
getServerSidePropsexample for fetching translations is highly simplified. Real-world implementations will depend on your specific translation plugin (WPML, Polylang, TranslatePress, etc.) and its API capabilities. You might need custom endpoints in WordPress to efficiently retrieve translations for a given post and language. - Routing: For a true multi-language site, you’ll need a routing strategy. This could involve subdirectories (
/en/post-slug,/es/post-slug), subdomains, or query parameters. Your Next.js app and WordPress setup must align on this. - SEO: Implement
hreflangtags correctly to inform search engines about the different language versions of your pages. - Performance: Consider client-side vs. server-side rendering for translations. Fetching translations server-side (as shown in
getServerSideProps) is generally better for SEO. - Caching: Implement appropriate caching strategies for both your WordPress API responses and your Next.js application.
Conclusion
Building a custom translation switcher block for Gutenberg and integrating it with a headless Next.js application involves several layers: WordPress plugin development for the block, API integration for data fetching, and frontend state management in Next.js. By carefully managing block attributes and leveraging React Context, you can create a dynamic and user-friendly translation experience for your headless WordPress site.