• Skip to secondary menu
  • Skip to main content
  • Skip to primary sidebar
  • Home
  • Projects
  • Products
  • Themes
  • Tools
  • Request for Quote

Vengala Vinay

Having 12+ Years of Experience in Software Development

  • Home
  • WordPress
  • PHP
    • Codeigniter
  • Django
  • Magento
  • Selenium
  • Server
Home » Step-by-Step Guide to building a custom automatic translation switcher block for Gutenberg using Next.js headless configurations

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 of languages and a defaultLanguage.
  • 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 languages and defaultLanguage attributes from block.json and save them as JSON in a post meta field.
  • Translation Fetching: The getServerSideProps example 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 hreflang tags 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.

Primary Sidebar

A little about the Author

Having 12+ Years of Experience in Software Development, Vinay is a principal software architect, senior systems engineer, and elite technical consultant. He specializes in bespoke PHP/WordPress development, high-performance Magento 2 & Shopify architectures, custom plugin/theme development from scratch, and legacy code modernization (including VB6, VB.NET, PyQt, and Crystal Reports). Known for solving complex database bottlenecks, speed optimization (Core Web Vitals), and advanced security code auditing, Vinay engineers production-ready systems designed to scale under heavy concurrent load conditions.



Chat on WhatsApp

Recent Posts

  • Step-by-Step Guide: Offloading high-frequency custom subscription logs metadata writes to a Redis KV store
  • How to design a modular Command Query Responsibility Segregation (CQRS) architecture for enterprise-level custom plugins
  • Troubleshooting guide: Resolving memory leak spikes caused by unclosed custom database loops in user transaction ledgers
  • Designing audit logs for enterprise WordPress setups tracking internal user modifications to affiliate click tracking logs
  • WordPress Development Recipe: Real-time custom event triggers using WebSockets and Transients API

Categories

  • apache (1)
  • Business & Monetization (390)
  • Centos (4)
  • Comparisons & Decision Making (55)
  • Debian (2)
  • Debugging & Troubleshooting (658)
  • Desktop Applications (14)
  • DevOps (7)
  • DevOps & Cloud Scaling (962)
  • Django (1)
  • Laravel (4)
  • Migration & Architecture (192)
  • Mobile Applications (24)
  • MySQL (1)
  • Performance & Optimization (872)
  • PHP (5)
  • PHP Development (41)
  • Plugins & Themes (244)
  • Programming Languages (9)
  • Python (20)
  • Ruby on Rails (1)
  • Security & Compliance (639)
  • SEO & Growth (492)
  • Server (23)
  • Ubuntu (9)
  • VB6 & VB.NET (8)
  • Web Applications & Frontend (19)
  • Web Assembly (Wasm) (2)
  • WordPress (22)
  • WordPress Plugin Development (64)
  • WordPress Plugin Development (70)
  • WordPress Plugin Development (330)
  • WordPress Theme Development (357)

Recent Posts

  • Step-by-Step Guide: Offloading high-frequency custom subscription logs metadata writes to a Redis KV store
  • How to design a modular Command Query Responsibility Segregation (CQRS) architecture for enterprise-level custom plugins
  • Troubleshooting guide: Resolving memory leak spikes caused by unclosed custom database loops in user transaction ledgers

Top Categories

  • DevOps & Cloud Scaling (962)
  • Performance & Optimization (872)
  • Debugging & Troubleshooting (658)
  • Security & Compliance (639)
  • SEO & Growth (492)
  • Business & Monetization (390)

Our Products

  • ERP & LMS Systems (4)
  • Directories & Marketplaces (4)
  • Healthcare Portals (3)
  • Point of Sale (POS) (2)
  • E-Commerce Engines (2)

Our Services

  • E-Commerce Development (10)
  • WordPress Development (8)
  • Python & Desktop GUI (7)
  • General Consulting (7)
  • Legacy Modernization (5)
  • Mobile App Development (4)

Copyright © 2026 · Vinay Vengala