• 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 CSV bulk exporter block for Gutenberg using React components

Step-by-Step Guide to building a custom CSV bulk exporter block for Gutenberg using React components

Setting Up the Development Environment

Before we dive into code, ensure your WordPress development environment is properly configured. This typically involves a local server stack (like LocalWP, Docker with a WordPress image, or a LAMP/LEMP stack) and Node.js with npm or yarn for managing JavaScript dependencies. We’ll be using `wp-scripts` for building our React components, which simplifies the process significantly.

Navigate to your WordPress plugin directory (e.g., wp-content/plugins/) and create a new directory for your plugin. For this example, let’s call it custom-csv-exporter.

Plugin Structure and Initialization

Inside your plugin directory, create the main plugin file. This file will register our Gutenberg block and enqueue necessary scripts.

custom-csv-exporter.php

This PHP file serves as the entry point for our plugin. It registers the block type and ensures the necessary JavaScript and CSS files are loaded in the WordPress admin.

<?php
/**
 * Plugin Name: Custom CSV Exporter
 * Description: A custom Gutenberg block to export post data as CSV.
 * Version: 1.0.0
 * Author: Your Name
 * License: GPL-2.0-or-later
 * Text Domain: custom-csv-exporter
 */

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

Next, create a block.json file in the root of your plugin directory. This file describes your block to WordPress, including its name, category, attributes, and the build path for its assets.

block.json

{
    "$schema": "https://schemas.wp.org/trunk/block.json",
    "apiVersion": 3,
    "name": "custom-csv-exporter/block",
    "version": "1.0.0",
    "title": "Custom CSV Exporter",
    "category": "widgets",
    "icon": "download",
    "description": "Export post data as a CSV file.",
    "keywords": ["csv", "export", "data"],
    "attributes": {
        "postType": {
            "type": "string",
            "default": "post"
        },
        "numberOfPosts": {
            "type": "number",
            "default": 10
        }
    },
    "textdomain": "custom-csv-exporter",
    "editorScript": "file:./build/index.js",
    "editorStyle": "file:./build/index.css",
    "style": "file:./build/style-index.css"
}

The attributes section defines the configurable properties of our block. Here, we’ve added postType and numberOfPosts to allow users to select the post type and the quantity of posts to export.

Frontend and Editor JavaScript with React

Now, let’s set up the JavaScript part. Create a src directory in your plugin’s root. Inside src, create index.js, which will be the entry point for our block’s JavaScript, and a file for our React component, e.g., exporter-block.js.

src/index.js

This file imports the necessary WordPress components and registers the block using the information from block.json. It also imports our React component.

/**
 * Registers a new block provided a unique name and an object defining its behavior.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
 */
import { registerBlockType } from '@wordpress/blocks';

/**
 * Lets webpack process CSS, Skip the import() if not using CSS modules.
 *
 * @see https://developer.wordpress.org/block-editor/tutorials/block-tutorial/applying-styles-to-blocks/
 */
import './style.scss';
import './editor.scss';

/**
 * Internal dependencies
 */
import Edit from './exporter-block';
import save from './save'; // We'll define this later, or use saveContent.

/**
 * Every block starts by registering a unique name with the registerBlockType() method.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/
 */
registerBlockType( 'custom-csv-exporter/block', {
    /**
     * @see ./exporter-block.js
     */
    edit: Edit,
    /**
     * @see ./save.js
     */
    save,
} );

src/exporter-block.js

This is where the core React component for our block resides. It will handle the UI for selecting post type and number of posts, and trigger the export action.

/**
 * React component for the custom CSV exporter block.
 */
import { __ } from '@wordpress/i18n';
import {
    useBlockProps,
    InspectorControls,
} from '@wordpress/block-editor';
import {
    PanelBody,
    SelectControl,
    RangeControl,
    Button,
} from '@wordpress/components';
import { useState, useEffect } from '@wordpress/element';
import apiFetch from '@wordpress/api-fetch';

export default function Edit( { attributes, setAttributes } ) {
    const blockProps = useBlockProps();
    const { postType, numberOfPosts } = attributes;

    const [ availablePostTypes, setAvailablePostTypes ] = useState( [] );
    const [ isLoading, setIsLoading ] = useState( false );

    // Fetch available post types on mount
    useEffect( () => {
        apiFetch( { path: '/wp/v2/types' } ).then( ( types ) => {
            const options = Object.keys( types ).map( ( key ) => ( {
                label: types[ key ].name,
                value: key,
            } ) );
            setAvailablePostTypes( options );
        } );
    }, [] );

    const handleExport = () => {
        setIsLoading( true );
        apiFetch( {
            path: '/custom-csv-exporter/v1/export',
            method: 'POST',
            data: {
                post_type: postType,
                number_of_posts: numberOfPosts,
            },
        } )
            .then( ( response ) => {
                if ( response.url ) {
                    window.location.href = response.url; // Trigger download
                } else {
                    alert( __( 'Export failed. Please try again.', 'custom-csv-exporter' ) );
                }
            } )
            .catch( ( error ) => {
                console.error( 'Export error:', error );
                alert( __( 'An error occurred during export. Please check console.', 'custom-csv-exporter' ) );
            } )
            .finally( () => {
                setIsLoading( false );
            } );
    };

    return (
        <>
            <InspectorControls>
                <PanelBody title={ __( 'Export Settings', 'custom-csv-exporter' ) }>
                    <SelectControl
                        label={ __( 'Post Type', 'custom-csv-exporter' ) }
                        value={ postType }
                        options={ availablePostTypes }
                        onChange={ ( newPostType ) => setAttributes( { postType: newPostType } ) }
                    />
                    <RangeControl
                        label={ __( 'Number of Posts', 'custom-csv-exporter' ) }
                        value={ numberOfPosts }
                        onChange={ ( newNumberOfPosts ) => setAttributes( { numberOfPosts: newNumberOfPosts } ) }
                        min={ 1 }
                        max={ 100 } // Adjust max as needed
                    />
                </PanelBody>
            </InspectorControls>
            <div { ...blockProps }>
                <p>{ __( 'Configure export settings in the sidebar.', 'custom-csv-exporter' ) }</p>
                <Button
                    variant="primary"
                    onClick={ handleExport }
                    isBusy={ isLoading }
                    disabled={ isLoading }
                >
                    { __( 'Export to CSV', 'custom-csv-exporter' ) }
                </Button>
            </div>
        </>
    );
}

In this component:

  • We use useBlockProps to get the necessary props for the block’s wrapper element.
  • InspectorControls provides a sidebar interface for settings.
  • PanelBody, SelectControl, and RangeControl are used to create UI elements for selecting the post type and number of posts.
  • useState and useEffect are used for managing component state and fetching data (available post types).
  • apiFetch is WordPress’s built-in AJAX utility for interacting with the REST API.
  • The handleExport function makes a POST request to our custom REST API endpoint.

src/save.js

The save function determines how the block’s content is rendered on the frontend. For this block, we don’t need to render anything specific on the frontend, as the export functionality is triggered from the editor. We can return null or a simple placeholder.

/**
 * The save function defines the way in which the different attributes should
 * be combined into the final markup, which is then serialized by the block editor.
 *
 * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-edit-save/
 */
export default function save() {
    return null; // No frontend rendering needed for this block.
}

Building the Assets

To compile our React components and SCSS files into the formats WordPress expects, we need to set up a build process. The easiest way is to use @wordpress/scripts. First, install it as a development dependency.

Install Dependencies

cd wp-content/plugins/custom-csv-exporter
npm init -y
npm install @wordpress/scripts --save-dev

Next, add build scripts to your package.json file.

package.json Scripts

{
  "name": "custom-csv-exporter",
  "version": "1.0.0",
  "description": "A custom Gutenberg block to export post data as CSV.",
  "main": "index.js",
  "scripts": {
    "build": "wp-scripts build",
    "start": "wp-scripts start"
  },
  "keywords": [],
  "author": "",
  "license": "GPL-2.0-or-later",
  "devDependencies": {
    "@wordpress/scripts": "^27.0.0"
  }
}

Now you can build your assets by running:

npm run build

This command will create a build directory containing index.js, index.css, and style-index.css. These are the files referenced in your block.json.

Implementing the REST API Endpoint

To handle the CSV export request from the frontend, we need a custom REST API endpoint. This endpoint will query the database, format the data, and return a CSV file.

Add REST API Endpoint Registration

Add the following code to your main plugin file (custom-csv-exporter.php) to register the REST API route.

 'POST',
        'callback'            => 'handle_csv_export',
        'permission_callback' => function() {
            // Ensure only logged-in users with 'edit_posts' capability can export.
            return current_user_can( 'edit_posts' );
        },
        'args'                => array(
            'post_type'       => array(
                'required'          => true,
                'type'              => 'string',
                'sanitize_callback' => 'sanitize_text_field',
            ),
            'number_of_posts' => array(
                'required'          => true,
                'type'              => 'integer',
                'sanitize_callback' => 'absint',
            ),
        ),
    ) );
}
add_action( 'rest_api_init', 'register_csv_export_route' );

/**
 * Handles the CSV export request.
 *
 * @param WP_REST_Request $request Full data about the request.
 * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
 */
function handle_csv_export( WP_REST_Request $request ) {
    $post_type       = $request->get_param( 'post_type' );
    $number_of_posts = $request->get_param( 'number_of_posts' );

    // Basic validation
    if ( ! post_type_exists( $post_type ) ) {
        return new WP_Error( 'invalid_post_type', __( 'Invalid post type specified.', 'custom-csv-exporter' ), array( 'status' => 400 ) );
    }
    if ( $number_of_posts <= 0 ) {
        return new WP_Error( 'invalid_number_of_posts', __( 'Number of posts must be positive.', 'custom-csv-exporter' ), array( 'status' => 400 ) );
    }

    $args = array(
        'post_type'      => $post_type,
        'posts_per_page' => $number_of_posts,
        'post_status'    => 'publish',
        'orderby'        => 'date',
        'order'          => 'DESC',
    );

    $posts_query = new WP_Query( $args );
    $posts       = $posts_query->posts;

    if ( empty( $posts ) ) {
        return new WP_Error( 'no_posts_found', __( 'No posts found for the specified criteria.', 'custom-csv-exporter' ), array( 'status' => 404 ) );
    }

    // Prepare CSV data
    $csv_data = array();
    $header   = array(
        __( 'ID', 'custom-csv-exporter' ),
        __( 'Title', 'custom-csv-exporter' ),
        __( 'Date', 'custom-csv-exporter' ),
        __( 'Author ID', 'custom-csv-exporter' ),
        __( 'Status', 'custom-csv-exporter' ),
        // Add more fields as needed, e.g., custom fields
    );
    $csv_data[] = $header;

    foreach ( $posts as $post ) {
        $row = array(
            $post->ID,
            $post->post_title,
            $post->post_date,
            $post->post_author,
            $post->post_status,
            // Add corresponding data for custom fields
        );
        $csv_data[] = $row;
    }

    // Generate CSV content
    $csv_output = fopen( 'php://temp', 'w' );
    foreach ( $csv_data as $line ) {
        fputcsv( $csv_output, $line );
    }
    rewind( $csv_output );
    $csv_content = stream_get_contents( $csv_output );
    fclose( $csv_output );

    // Prepare response for download
    $filename = sanitize_title( sprintf( '%s-%s-%s.csv', $post_type, $number_of_posts, date( 'Y-m-d' ) ) );

    $response = new WP_REST_Response( $csv_content );
    $response->set_headers( array(
        'Content-Type'        => 'text/csv',
        'Content-Disposition' => 'attachment; filename="' . $filename . '"',
        'Content-Length'      => strlen( $csv_content ),
    ) );

    // For the frontend JS to trigger download, we return a URL to the file.
    // This requires saving the file temporarily or using a more complex approach.
    // A simpler approach for immediate download is to return the content directly
    // and let the JS handle the download. However, for larger files or more robust
    // handling, a dedicated download endpoint or temporary file storage is better.

    // For this example, we'll return a JSON response with a URL that the frontend
    // can use to initiate the download. This requires a separate endpoint to serve the file.
    // A more direct approach for immediate download is to return the CSV content
    // with appropriate headers, which is what the JS `window.location.href` expects.

    // Let's refine this to directly return the file content with headers.
    // The JS `apiFetch` will receive this response.
    // The `window.location.href = response.url;` in JS is not ideal here.
    // We need to return the CSV data directly.

    // Re-thinking the response: The JS `apiFetch` expects a JSON response.
    // To trigger a download, we need to send the CSV content with headers.
    // This means the REST API callback should directly output the file.
    // However, `register_rest_route` expects a `WP_REST_Response` object.

    // A common pattern is to return a temporary URL. Let's simulate that.
    // In a real-world scenario, you'd save this to a temporary file and return its URL.
    // For simplicity here, we'll return a JSON object with a placeholder URL,
    // and the JS will need to be adjusted to handle this.

    // Let's adjust the JS to expect a JSON response with a download URL.
    // This requires a separate endpoint to serve the file.
    // For immediate download, the REST API callback should output the file directly.

    // Let's go with the direct output approach for simplicity in this example.
    // The `apiFetch` call in JS will need to be handled differently.
    // The `window.location.href = response.url;` is a common pattern if the backend
    // generates a temporary file and returns its URL.

    // Let's modify the JS to handle the direct CSV response.
    // The `apiFetch` call in JS should be replaced with a direct link or a fetch
    // that handles blob responses.

    // For this example, let's stick to the `apiFetch` and return a JSON response
    // with a URL. This implies a need for a separate endpoint to serve the file.
    // This is more complex than a direct download.

    // Let's simplify: The JS `apiFetch` can't directly trigger a file download
    // with `window.location.href = response.url` if `response.url` is just a string.
    // It needs to be a valid URL.

    // Alternative: Use a standard `` tag with `download` attribute, or a JS
    // Blob approach.

    // Let's adjust the `handle_csv_export` to return a JSON response containing
    // the CSV content, and the JS will handle the download.

    $response_data = array(
        'csv_content' => $csv_content,
        'filename'    => $filename,
    );

    return new WP_REST_Response( $response_data, 200 );
}

In the handle_csv_export function:

  • We retrieve the post_type and number_of_posts from the request parameters.
  • Basic validation is performed to ensure the parameters are valid.
  • A WP_Query is used to fetch the posts based on the provided criteria.
  • The post data is formatted into a CSV structure, including a header row.
  • fputcsv is used to write the data to a temporary stream, which is then read into a string.
  • A WP_REST_Response is created, containing the CSV content and filename in a JSON object. This is because apiFetch in WordPress typically expects JSON.
  • The permission_callback ensures that only users with the `edit_posts` capability can access this endpoint.

Handling the Download in JavaScript

The JavaScript `Edit` component needs to be updated to handle the JSON response from our REST API and trigger the file download.

Update src/exporter-block.js

/**
 * React component for the custom CSV exporter block.
 */
import { __ } from '@wordpress/i18n';
import {
    useBlockProps,
    InspectorControls,
} from '@wordpress/block-editor';
import {
    PanelBody,
    SelectControl,
    RangeControl,
    Button,
} from '@wordpress/components';
import { useState, useEffect } from '@wordpress/element';
import apiFetch from '@wordpress/api-fetch';

export default function Edit( { attributes, setAttributes } ) {
    const blockProps = useBlockProps();
    const { postType, numberOfPosts } = attributes;

    const [ availablePostTypes, setAvailablePostTypes ] = useState( [] );
    const [ isLoading, setIsLoading ] = useState( false );

    // Fetch available post types on mount
    useEffect( () => {
        apiFetch( { path: '/wp/v2/types' } ).then( ( types ) => {
            const options = Object.keys( types ).map( ( key ) => ( {
                label: types[ key ].name,
                value: key,
            } ) );
            setAvailablePostTypes( options );
        } );
    }, [] );

    const handleExport = () => {
        setIsLoading( true );
        apiFetch( {
            path: '/custom-csv-exporter/v1/export',
            method: 'POST',
            data: {
                post_type: postType,
                number_of_posts: numberOfPosts,
            },
        } )
            .then( ( response ) => {
                if ( response.csv_content && response.filename ) {
                    // Create a Blob from the CSV content
                    const blob = new Blob( [ response.csv_content ], { type: 'text/csv;charset=utf-8;' } );
                    const link = document.createElement( 'a' );

                    if ( link.download !== undefined ) { // Feature detection
                        const url = URL.createObjectURL( blob );
                        link.setAttribute( 'href', url );
                        link.setAttribute( 'download', response.filename );
                        link.style.visibility = 'hidden';
                        document.body.appendChild( link );
                        link.click();
                        document.body.removeChild( link );
                        URL.revokeObjectURL( url ); // Clean up
                    } else {
                        alert( __( 'Your browser does not support direct download. Please copy the CSV content manually.', 'custom-csv-exporter' ) );
                    }
                } else {
                    alert( __( 'Export failed. Received invalid data.', 'custom-csv-exporter' ) );
                }
            } )
            .catch( ( error ) => {
                console.error( 'Export error:', error );
                alert( __( 'An error occurred during export. Please check console.', 'custom-csv-exporter' ) );
            } )
            .finally( () => {
                setIsLoading( false );
            } );
    };

    return (
        <>
            <InspectorControls>
                <PanelBody title={ __( 'Export Settings', 'custom-csv-exporter' ) }>
                    <SelectControl
                        label={ __( 'Post Type', 'custom-csv-exporter' ) }
                        value={ postType }
                        options={ availablePostTypes }
                        onChange={ ( newPostType ) => setAttributes( { postType: newPostType } ) }
                    />
                    <RangeControl
                        label={ __( 'Number of Posts', 'custom-csv-exporter' ) }
                        value={ numberOfPosts }
                        onChange={ ( newNumberOfPosts ) => setAttributes( { numberOfPosts: newNumberOfPosts } ) }
                        min={ 1 }
                        max={ 100 } // Adjust max as needed
                    />
                </PanelBody>
            </InspectorControls>
            <div { ...blockProps }>
                <p>{ __( 'Configure export settings in the sidebar.', 'custom-csv-exporter' ) }</p>
                <Button
                    variant="primary"
                    onClick={ handleExport }
                    isBusy={ isLoading }
                    disabled={ isLoading }
                >
                    { __( 'Export to CSV', 'custom-csv-exporter' ) }
                </Button>
            </div>
        </>
    );
}

The key change here is within the handleExport function’s .then() block:

  • We check if response.csv_content and response.filename exist.
  • A Blob object is created from the CSV content.
  • A temporary `` element is created, its `href` is set to a URL generated from the Blob using URL.createObjectURL, and the `download` attribute is set to the desired filename.
  • The link is programmatically clicked to trigger the download, and then removed.
  • URL.revokeObjectURL is called to release the memory associated with the Blob URL.

Styling the Block

You can add custom styles for both the editor and the frontend. Create src/editor.scss and src/style.scss files.

src/editor.scss

/**
 * The following styles get applied inside the editor only.
 *
 * Replace them with your own styles or remove the file completely.
 */

.wp-block-custom-csv-exporter-block {
    border: 1px dashed #ccc;
    padding: 15px;
    background-color: #f9f9f9;
    text-align: center;

    p {
        margin-bottom: 15px;
    }
}

src/style.scss

/**
 * The following styles get applied both on the front of your site
 * and in the editor.
 *
 * Replace them with your own styles or remove the file completely.
 */

.wp-block-custom-csv-exporter-block {
    // Styles for the frontend if needed.
    // For this block, no specific frontend styling is required.
}

After adding these SCSS files, run npm run build again to compile them into CSS files in the build directory.

Testing and Deployment

Activate the “Custom CSV Exporter” plugin in your WordPress admin area. You should now be able to add the “Custom CSV Exporter” block to any post or page. Configure the post type and number of posts in the block’s sidebar, and click the “Export to CSV” button to download the file.

For deployment, ensure you include the build directory and all necessary plugin files in your plugin’s zip archive. The node_modules directory should NOT be included in the production build.

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

  • Debugging and Resolving deep-seated hook priority conflicts in third-party Zapier dynamic webhooks connectors
  • Step-by-Step Guide: Offloading high-frequency shipping tracking histories metadata writes to a Redis KV store
  • How to implement custom REST API Controllers endpoints with token authentication in Gutenberg blocks
  • Step-by-Step Guide to building a custom real-time activity logs block for Gutenberg using PHP block-render callbacks
  • How to implement custom WordPress Database Class ($wpdb) endpoints with token authentication in Gutenberg blocks

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

Recent Posts

  • Debugging and Resolving deep-seated hook priority conflicts in third-party Zapier dynamic webhooks connectors
  • Step-by-Step Guide: Offloading high-frequency shipping tracking histories metadata writes to a Redis KV store
  • How to implement custom REST API Controllers endpoints with token authentication in Gutenberg blocks

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