• 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 XML sitemap generator block for Gutenberg using PHP block-render callbacks

Step-by-Step Guide to building a custom XML sitemap generator block for Gutenberg using PHP block-render callbacks

Leveraging PHP Block-Render Callbacks for Dynamic XML Sitemaps in Gutenberg

While WordPress offers built-in sitemap functionality, the need for highly customized sitemaps—perhaps excluding specific post types, including custom metadata, or adhering to strict XML schema variations—often necessitates a custom solution. This guide details the construction of a Gutenberg block that dynamically generates an XML sitemap using PHP’s block-render callbacks, providing granular control over sitemap content and structure.

Registering the Custom Block Type

The foundation of any Gutenberg block is its registration. We’ll define a server-side rendered block, meaning its output will be generated on the fly by PHP. This is ideal for dynamic content like sitemaps.

Create a PHP file within your theme’s `inc` directory or a custom plugin. For this example, let’s assume a plugin structure.

`custom-sitemap-block.php` (Plugin Main File)

<?php
/**
 * Plugin Name: Custom Sitemap Block
 * Description: A Gutenberg block to generate a custom XML sitemap.
 * Version: 1.0.0
 * Author: Your Name
 */

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_sitemap_block_init() {
    register_block_type( __DIR__ . '/build' );
}
add_action( 'init', 'custom_sitemap_block_init' );
?>

Defining Block Metadata (`block.json`)

The `block.json` file describes the block to WordPress, including its name, attributes, and importantly, its `render` callback for server-side rendering.

`src/block.json`

{
    "apiVersion": 2,
    "name": "custom-sitemap/block",
    "title": "Custom XML Sitemap",
    "category": "widgets",
    "icon": "admin-site-alt3",
    "description": "Generates a custom XML sitemap.",
    "keywords": [ "sitemap", "xml", "seo" ],
    "attributes": {
        "postTypes": {
            "type": "array",
            "default": [ "post", "page" ]
        },
        "excludeCategories": {
            "type": "array",
            "default": []
        },
        "includeCustomPostTypes": {
            "type": "array",
            "default": []
        }
    },
    "editorScript": "file:./index.js",
    "render": "file:./render.php"
}

Implementing the Render Callback (`render.php`)

This PHP file contains the logic to generate the XML sitemap. It receives the block’s attributes as an argument.

`src/render.php`

<?php
/**
 * Server-side rendering for the Custom XML Sitemap block.
 *
 * @package CustomSitemapBlock
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit; // Exit if accessed directly.
}

/**
 * Generates the XML sitemap content.
 *
 * @param array $attributes Block attributes.
 * @return string The XML sitemap.
 */
function custom_sitemap_block_render_callback( $attributes ) {
    // Ensure we are outputting XML.
    header( 'Content-Type: application/xml; charset=utf-8' );

    // Default attributes.
    $post_types        = isset( $attributes['postTypes'] ) ? (array) $attributes['postTypes'] : array( 'post', 'page' );
    $exclude_categories = isset( $attributes['excludeCategories'] ) ? (array) $attributes['excludeCategories'] : array();
    $include_custom_post_types = isset( $attributes['includeCustomPostTypes'] ) ? (array) $attributes['includeCustomPostTypes'] : array();

    // Combine default and custom post types.
    $all_post_types = array_unique( array_merge( $post_types, $include_custom_post_types ) );

    // Get all published posts of the specified types.
    $args = array(
        'post_type'      => $all_post_types,
        'post_status'    => 'publish',
        'posts_per_page' => -1, // Get all posts.
        'tax_query'      => array(),
    );

    // Add category exclusion if specified.
    if ( ! empty( $exclude_categories ) ) {
        $args['tax_query'][] = array(
            'taxonomy' => 'category',
            'field'    => 'term_id',
            'terms'    => $exclude_categories,
            'operator' => 'NOT IN',
        );
    }

    $posts = new WP_Query( $args );

    // Start building the XML.
    $xml_output = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
    $xml_output .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' . "\n";

    // Add homepage URL.
    $xml_output .= sprintf(
        '<url><loc>%s</loc><lastmod>%s</lastmod></url>' . "\n",
        esc_url( home_url( '/' ) ),
        date( 'Y-m-d\TH:i:sP', strtotime( get_lastpostmodified( 'gmt' ) ) ) // Use the last modified date of any post.
    );

    // Loop through posts and add them to the sitemap.
    if ( $posts->have_posts() ) {
        while ( $posts->have_posts() ) {
            $posts->the_post();
            global $post;

            $post_url = get_permalink();
            $last_mod = get_post_modified_time( 'Y-m-d\TH:i:sP', true ); // GMT time with timezone offset.

            // Basic validation to ensure URL is valid.
            if ( ! empty( $post_url ) ) {
                $xml_output .= sprintf(
                    '<url><loc>%s</loc><lastmod>%s</lastmod></url>' . "\n",
                    esc_url( $post_url ),
                    $last_mod
                );
            }
        }
        wp_reset_postdata();
    }

    $xml_output .= '</urlset>';

    return $xml_output;
}

// This is crucial for the block registration to find the render callback.
// The key 'custom-sitemap/block' must match the 'name' in block.json.
$block_data = parse_blocks( $xml_output ); // This is a placeholder, the actual registration happens via register_block_type.
// The render callback is directly specified in block.json.
// The function `custom_sitemap_block_render_callback` will be invoked by WordPress
// when the block is rendered on the server.
?>

Frontend Asset Compilation

For the block to be usable in the editor, we need to compile its JavaScript and CSS. This typically involves a build process using tools like `@wordpress/scripts`.

`package.json`

{
    "name": "custom-sitemap-block",
    "version": "1.0.0",
    "description": "A Gutenberg block to generate a custom XML sitemap.",
    "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.0.0"
    }
}

Run `npm install` to install dependencies, then `npm run build` to compile the assets. This will create a `build` directory containing `index.js` and `style-index.css`.

Editor-Side Configuration (Optional but Recommended)

While the `render.php` handles the sitemap generation, providing controls in the Gutenberg editor for users to select post types and categories enhances usability. This involves creating an `edit.js` file.

`src/edit.js`

/**
 * Retrieves the translation of text.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-i18n/
 */
import { __ } from '@wordpress/i18n';

/**
 * React component for the block's editor view.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-block-editor/
 */
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, SelectControl, CheckboxControl, TextControl } from '@wordpress/components';
import { Fragment } from '@wordpress/element';

/**
 * Lets webpack process CSS, included with the block.
 *
 * @see https://developer.wordpress.org/block-editor/tutorials/block-tutorial/applying-styles-to-blocks/
 */
import './style.scss';

/**
 * The edit function describes the structure of your block in the context of the
 * editor. This represents what the editor will render when the block is used.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-edit-save/#edit
 *
 * @param {Object}   props               Properties passed to the function.
 * @param {Object}   props.attributes    Available block attributes.
 * @param {Function} props.setAttributes Function that updates block attributes.
 * @return {WPElement} Element to render.
 */
export default function Edit( { attributes, setAttributes } ) {
    const blockProps = useBlockProps();
    const { postTypes, excludeCategories, includeCustomPostTypes } = attributes;

    // Fetch available post types dynamically.
    // In a real-world scenario, you'd likely use wp.apiFetch or a REST API endpoint.
    // For simplicity, we'll hardcode common ones and allow manual input for custom ones.
    const availablePostTypes = [
        { label: 'Posts', value: 'post' },
        { label: 'Pages', value: 'page' },
        // Add more built-in types if needed
    ];

    // Fetch available categories dynamically.
    // Again, ideally via API.
    const availableCategories = wp.apiFetch( { path: '/wp/v2/categories?per_page=100' } )
        .then( ( categories ) => {
            return categories.map( ( category ) => ( {
                label: category.name,
                value: category.id,
            } ) );
        } )
        .catch( ( error ) => {
            console.error( 'Error fetching categories:', error );
            return [];
        } );

    const handlePostTypeChange = ( value, index ) => {
        const newPostTypes = [ ...postTypes ];
        if ( value ) {
            newPostTypes[ index ] = value;
        } else {
            newPostTypes.splice( index, 1 );
        }
        setAttributes( { postTypes: newPostTypes } );
    };

    const handleExcludeCategoryChange = ( value, isSelected ) => {
        let newExcludeCategories = [ ...excludeCategories ];
        if ( isSelected ) {
            newExcludeCategories.push( parseInt( value, 10 ) );
        } else {
            newExcludeCategories = newExcludeCategories.filter( id => id !== parseInt( value, 10 ) );
        }
        setAttributes( { excludeCategories: newExcludeCategories } );
    };

    const handleIncludeCustomPostTypeChange = ( value, isSelected ) => {
        let newIncludeCustomPostTypes = [ ...includeCustomPostTypes ];
        if ( isSelected ) {
            newIncludeCustomPostTypes.push( value );
        } else {
            newIncludeCustomPostTypes = newIncludeCustomPostTypes.filter( type => type !== value );
        }
        setAttributes( { includeCustomPostTypes: newIncludeCustomPostTypes } );
    };

    return (
        <Fragment>
            <InspectorControls>
                <PanelBody title={ __( 'Sitemap Settings', 'custom-sitemap-block' ) }>
                    <p>{ __( 'Select which post types to include:', 'custom-sitemap-block' ) }</p>
                    { availablePostTypes.map( ( type, index ) => (
                        <CheckboxControl
                            label={ type.label }
                            checked={ postTypes.includes( type.value ) }
                            onChange={ ( isChecked ) => {
                                const newPostTypes = isChecked
                                    ? [ ...postTypes, type.value ]
                                    : postTypes.filter( pt => pt !== type.value );
                                setAttributes( { postTypes: newPostTypes } );
                            } }
                            key={ type.value }
                        />
                    ) ) }

                    <TextControl
                        label={ __( 'Include Custom Post Types (comma-separated)', 'custom-sitemap-block' ) }
                        value={ includeCustomPostTypes.join(', ') }
                        onChange={ ( value ) => {
                            setAttributes( { includeCustomPostTypes: value.split(',').map( t => t.trim() ).filter( t => t !== '' ) } );
                        } }
                        help={ __( 'e.g., product, event', 'custom-sitemap-block' ) }
                    />

                    <hr />

                    <p>{ __( 'Exclude posts from specific categories:', 'custom-sitemap-block' ) }</p>
                    { availableCategories.map( ( category ) => (
                        <CheckboxControl
                            label={ category.label }
                            checked={ excludeCategories.includes( category.value ) }
                            onChange={ ( isChecked ) => handleExcludeCategoryChange( category.value, isChecked ) }
                            key={ category.value }
                        />
                    ) ) }
                </PanelBody>
            </InspectorControls>
            <div { ...blockProps }>
                { __( 'Custom XML Sitemap Block', 'custom-sitemap-block' ) }
                <p>{ __( 'Post Types Included:', 'custom-sitemap-block' ) } { postTypes.join(', ') }{ includeCustomPostTypes.length > 0 ? ', ' + includeCustomPostTypes.join(', ') : '' }</p>
                { excludeCategories.length > 0 && (
                    <p>{ __( 'Excluding Categories (IDs):', 'custom-sitemap-block' ) } { excludeCategories.join(', ') }</p>
                ) }
                <p>{ __( 'This block will render an XML sitemap on the frontend.', 'custom-sitemap-block' ) }</p>
            </div>
        </Fragment>
    );
}

`src/index.js`

/**
 * Registers a 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/block-editor/reference-guides/block-api/block-registration/
 */
import { registerBlockType } from '@wordpress/blocks';

/**
 * WordPress dependencies
 */
import { __ } from '@wordpress/i18n';

/**
 * Internal dependencies
 */
import Edit from './edit';
import metadata from './block.json';

/**
 * Every block starts by registering a unique name and how the editor should
 * render the block.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
 */
registerBlockType( metadata.name, {
    /**
     * @see ./edit.js
     */
    edit: Edit,

    /**
     * Save function is not needed for server-side rendered blocks.
     * The render callback in `block.json` handles the frontend output.
     */
    save: () => null,
} );

Integrating the Block

Place the `custom-sitemap-block.php` file in your plugin’s root directory. Ensure the `src` directory contains `block.json`, `edit.js`, `index.js`, and `style.scss`. Run `npm install` and `npm run build` within the plugin’s root directory to compile the assets.

Activate the plugin. You can now add the “Custom XML Sitemap” block to any page or post. The block will render its configuration in the editor and generate the actual XML sitemap on the frontend when accessed directly via a specific URL. For a true sitemap, you would typically create a dedicated page template that uses this block, or hook into WordPress’s sitemap functionality to serve this XML.

Serving the Sitemap Directly

To make this block function as a true sitemap endpoint (e.g., `yourdomain.com/sitemap.xml`), you need to modify your theme’s `functions.php` or a custom plugin to conditionally load the block’s render callback when the sitemap URL is requested.

Example: `functions.php` modification

<?php
/**
 * Serve custom sitemap XML if the request is for sitemap.xml.
 */
function custom_sitemap_serve_xml() {
    // Check if the request is for sitemap.xml and if the block is registered.
    if ( isset( $_SERVER['REQUEST_URI'] ) && '/sitemap.xml' === $_SERVER['REQUEST_URI'] && block_has_block( 'custom-sitemap/block' ) ) {

        // Find the block instance. This assumes only one instance or the first one.
        // A more robust solution might involve querying posts for the block.
        $posts = get_posts( array(
            'post_type' => 'any',
            'meta_key'  => '_blocks', // This is a simplification; blocks are stored differently.
            'meta_value' => 'custom-sitemap/block',
            'posts_per_page' => 1,
        ) );

        $block_attributes = array();
        if ( ! empty( $posts ) ) {
            // This is a highly simplified way to get attributes.
            // A proper implementation would parse the post content to find the block's attributes.
            // For a dedicated sitemap page, you'd directly render the block.
            // Example: If you have a page with slug 'sitemap', you'd fetch its content.
            $sitemap_page = get_page_by_path( 'sitemap' ); // Assuming a page with slug 'sitemap' exists.
            if ( $sitemap_page ) {
                $parsed_blocks = parse_blocks( $sitemap_page->post_content );
                foreach ( $parsed_blocks as $block ) {
                    if ( $block['blockName'] === 'custom-sitemap/block' ) {
                        $block_attributes = $block['attrs'];
                        break;
                    }
                }
            }
        }

        // Directly call the render callback.
        // Ensure the render callback function is accessible.
        // If render.php is not included globally, you might need to include it.
        // For simplicity, let's assume custom_sitemap_block_render_callback is available.
        // If not, you'd need: require_once( 'path/to/your/plugin/src/render.php' );
        echo custom_sitemap_block_render_callback( $block_attributes );
        exit; // Stop further WordPress execution.
    }
}
add_action( 'template_redirect', 'custom_sitemap_serve_xml' );

// Ensure the render callback function is defined or included.
// If this is in a plugin, it's usually handled by register_block_type.
// If in functions.php, you might need to include it:
// require_once get_template_directory() . '/path/to/your/render.php';
?>

This `template_redirect` hook checks if the request URI matches `/sitemap.xml`. If it does, and the block is registered, it attempts to find a page containing the sitemap block and then directly calls the `custom_sitemap_block_render_callback` function, outputting the XML and exiting. This bypasses normal WordPress page rendering for the sitemap request.

Advanced Considerations and Enhancements

  • Performance: For very large sites, caching the generated sitemap is crucial. Consider using WordPress transients or a dedicated caching plugin.
  • URL Prioritization and Frequency: The sitemap protocol allows for `` and `` tags. These can be added as block attributes and configured in the editor.
  • Custom Post Types and Taxonomies: Extend the `block.json` attributes and the `render.php` logic to support arbitrary custom post types and taxonomies, fetching their registered names dynamically.
  • Image Sitemaps: Implement support for image sitemaps by fetching featured images or custom image fields.
  • Internationalization: Ensure all user-facing strings in `edit.js` are translatable using `__( ‘String’, ‘text-domain’ )`.
  • Security: Sanitize all user inputs and escape all output rigorously, especially when dealing with URLs and XML content.

By utilizing PHP block-render callbacks, developers gain a powerful and flexible mechanism to integrate dynamic, custom-generated content directly into the Gutenberg editor, with the XML sitemap being a prime example of such an application.

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

  • Reducing database query bloat in Sage Roots modern environments layouts using custom lazy loaders
  • Performance Optimization: Tuning PHP-FPM and opcache pools for high-concurrency Firebase Realtime DB handlers
  • Reducing Largest Contentful Paint (LCP) by optimizing custom script enqueuing structures in legacy plugins
  • How to implement native Redis caching layers for high-volume custom taxonomy queries in Carbon Fields custom wrappers
  • Building secure B2B pricing grids with custom REST API Controllers endpoints and role overrides

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 (48)
  • 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 (182)
  • WordPress Plugin Development (197)
  • WordPress Plugin Development (330)
  • WordPress Theme Development (357)

Recent Posts

  • Reducing database query bloat in Sage Roots modern environments layouts using custom lazy loaders
  • Performance Optimization: Tuning PHP-FPM and opcache pools for high-concurrency Firebase Realtime DB handlers
  • Reducing Largest Contentful Paint (LCP) by optimizing custom script enqueuing structures in legacy plugins

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