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
useBlockPropsto get the necessary props for the block’s wrapper element. InspectorControlsprovides a sidebar interface for settings.PanelBody,SelectControl, andRangeControlare used to create UI elements for selecting the post type and number of posts.useStateanduseEffectare used for managing component state and fetching data (available post types).apiFetchis WordPress’s built-in AJAX utility for interacting with the REST API.- The
handleExportfunction 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_typeandnumber_of_postsfrom the request parameters. - Basic validation is performed to ensure the parameters are valid.
- A
WP_Queryis used to fetch the posts based on the provided criteria. - The post data is formatted into a CSV structure, including a header row.
fputcsvis used to write the data to a temporary stream, which is then read into a string.- A
WP_REST_Responseis created, containing the CSV content and filename in a JSON object. This is becauseapiFetchin WordPress typically expects JSON. - The
permission_callbackensures 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_contentandresponse.filenameexist. - A
Blobobject 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.revokeObjectURLis 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.