Chapter 22
Build Your First AI-Powered Block
Registering a static block whose editor UI calls a new REST endpoint (same pattern as Ep. 20) via apiFetch
This chapter builds a real block called “AI Quote.” You add it to a post, click a button, and a quote appears right there in the editor, generated on the spot.
Chapter 20 built a REST endpoint and called it from a shortcode’s JavaScript. This chapter calls that exact same kind of endpoint from somewhere different: inside the block editor itself, while someone’s actually writing a post.
The endpoint, same pattern as before
Nothing new here, this is Chapter 20’s shape again, just a different prompt:
// Registers the REST endpoint the block's JavaScript will call.
function ai_course_ch22_register_rest_route() {
register_rest_route(
'ai-course/v1',
'/quote',
array(
'methods' => 'POST',
'callback' => 'ai_course_ch22_generate_quote',
'permission_callback' => function () {
// Only logged-in users who can edit posts may generate a quote.
return current_user_can( 'edit_posts' );
},
)
);
}
add_action( 'rest_api_init', 'ai_course_ch22_register_rest_route' );
// Handles the actual REST request: runs the prompt, returns the result.
function ai_course_ch22_generate_quote( WP_REST_Request $request ) {
$result = wp_ai_client_prompt( 'Write a short, original, inspiring quote about creativity or perseverance. Do not attribute it to any real person.' )
->using_temperature( 0.9 )
->generate_text_result();
// rest_ensure_response() handles both success and WP_Error automatically.
return rest_ensure_response( $result );
}
What this block does
While you’re writing, this block shows a button. Click it, and a quote appears underneath. That’s edit(), everything visible and interactive while you’re editing.
Once you publish, the button disappears. Only the quote itself gets written into the post. That’s save(), it only cares about the final result, not how you got there.
Both functions read from the same place: one stored attribute called quote. Generating a new quote just replaces what’s in that attribute, edit() and save() each display whatever’s currently there.
Registering the block, no build step
blocks.registerBlockType( 'ai-course/quote', {
title: 'AI Quote',
icon: 'format-quote',
category: 'text',
// The block only needs to remember one thing: the generated quote text.
attributes: {
quote: {
type: 'string',
default: ''
}
},
// edit() controls what you see and interact with while writing.
edit( props ) {
// covered below
},
// save() controls what gets written into the post content on publish.
save( props ) {
return el( 'blockquote', {}, props.attributes.quote );
}
} );
save() is the simple half: take whatever’s in quote and wrap it in a <blockquote>. That’s the entire finished output, a plain HTML tag with text in it, nothing about the AI call survives into the published post.
The edit function: calling the AI
edit( props ) {
const { attributes, setAttributes } = props;
// Local state, just for this block instance, tracks whether a
// request is in flight so the button can disable itself.
const [ generating, setGenerating ] = useState( false );
const generateQuote = () => {
setGenerating( true );
apiFetch( {
path: '/ai-course/v1/quote',
method: 'POST'
} )
.then( ( data ) => {
// Pull the generated text out of the confirmed response shape.
let text = '';
if ( data.candidates && data.candidates[ 0 ] && data.candidates[ 0 ].message ) {
data.candidates[ 0 ].message.parts.forEach( ( part ) => {
if ( part.text ) {
text += part.text;
}
} );
}
// Updating the attribute is what actually updates the block.
setAttributes( { quote: text || 'Could not generate a quote.' } );
setGenerating( false );
} )
.catch( () => {
setAttributes( { quote: 'Something went wrong generating a quote.' } );
setGenerating( false );
} );
};
return el(
'div',
{ className: 'ai-course-ch22-quote-block' },
el(
components.Button,
{ variant: 'secondary', onClick: generateQuote, disabled: generating },
generating ? 'Generating...' : 'Generate Quote'
),
el(
'blockquote',
{ style: { marginTop: '10px' } },
attributes.quote || 'Click the button to generate a quote.'
)
);
}
useState tracks one small piece of information for this block: whether a request is currently running. While it’s true, the button disables itself and changes its label to “Generating…”, so clicking twice can’t fire two calls at once.
The apiFetch() call itself should look familiar, same method, same shape as Chapter 20. Once a response comes back, setAttributes( { quote: text } ) is what actually updates the block, that one call is the entire bridge between “AI responded” and “the editor shows the new quote.”
el() is shorthand for element.createElement(), and it’s how this file builds what you see without JSX. Each call takes a tag name (or a component, like components.Button), an object of properties, and then whatever goes inside it, another el() call for a nested element, or a plain string for text. el( 'blockquote', {}, attributes.quote ), for example, is the same thing as writing <blockquote>{ attributes.quote }</blockquote> in JSX, just as a function call instead of markup. A JSX version of this exact block is at the end of this chapter, if you’d rather see it written that way.
Putting it together
This chapter needs two files instead of one, the PHP file with the endpoint and the enqueue call, and a real JavaScript file for the block itself.
Create chapter-22-first-block.php inside includes:
<?php
/**
* Chapter 22: Build Your First AI-Powered Block
* Usage: search for "AI Quote" in the block inserter, inside the block
* editor. No shortcode for this chapter, it's a block, not a shortcode.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // No direct access.
}
// Registers the REST endpoint the block's JavaScript will call.
function ai_course_ch22_register_rest_route() {
register_rest_route(
'ai-course/v1',
'/quote',
array(
'methods' => 'POST',
'callback' => 'ai_course_ch22_generate_quote',
'permission_callback' => function () {
// Only logged-in users who can edit posts may generate a quote.
return current_user_can( 'edit_posts' );
},
)
);
}
add_action( 'rest_api_init', 'ai_course_ch22_register_rest_route' );
// Handles the actual REST request: runs the prompt, returns the result.
function ai_course_ch22_generate_quote( WP_REST_Request $request ) {
$result = wp_ai_client_prompt( 'Write a short, original, inspiring quote about creativity or perseverance. Do not attribute it to any real person.' )
->using_temperature( 0.9 )
->generate_text_result();
// rest_ensure_response() handles both success and WP_Error automatically.
return rest_ensure_response( $result );
}
// Loads the block's JavaScript in the block editor.
function ai_course_ch22_enqueue_block_editor_assets() {
wp_enqueue_script(
'ai-course-ch22-block',
plugins_url( 'js/chapter-22-block.js', __FILE__ ),
array( 'wp-blocks', 'wp-element', 'wp-i18n', 'wp-api-fetch', 'wp-components' ),
'1.0',
true // Load in the footer.
);
}
add_action( 'enqueue_block_editor_assets', 'ai_course_ch22_enqueue_block_editor_assets' );
Then create a js folder inside includes, and inside that, chapter-22-block.js:
( function ( blocks, element, components, apiFetch ) {
const el = element.createElement;
const { useState } = element;
blocks.registerBlockType( 'ai-course/quote', {
title: 'AI Quote',
icon: 'format-quote',
category: 'text',
// The block only needs to remember one thing: the generated quote text.
attributes: {
quote: {
type: 'string',
default: ''
}
},
// edit() controls what you see and interact with while writing.
edit( props ) {
const { attributes, setAttributes } = props;
// Local state, just for this block instance, tracks whether a
// request is in flight so the button can disable itself.
const [ generating, setGenerating ] = useState( false );
const generateQuote = () => {
setGenerating( true );
apiFetch( {
path: '/ai-course/v1/quote',
method: 'POST'
} )
.then( ( data ) => {
// Pull the generated text out of the confirmed response shape.
let text = '';
if ( data.candidates && data.candidates[ 0 ] && data.candidates[ 0 ].message ) {
data.candidates[ 0 ].message.parts.forEach( ( part ) => {
if ( part.text ) {
text += part.text;
}
} );
}
// Updating the attribute is what actually updates the block.
setAttributes( { quote: text || 'Could not generate a quote.' } );
setGenerating( false );
} )
.catch( () => {
setAttributes( { quote: 'Something went wrong generating a quote.' } );
setGenerating( false );
} );
};
return el(
'div',
{ className: 'ai-course-ch22-quote-block' },
el(
components.Button,
{ variant: 'secondary', onClick: generateQuote, disabled: generating },
generating ? 'Generating...' : 'Generate Quote'
),
el(
'blockquote',
{ style: { marginTop: '10px' } },
attributes.quote || 'Click the button to generate a quote.'
)
);
},
// save() controls what gets written into the post content on publish.
save( props ) {
return el( 'blockquote', {}, props.attributes.quote );
}
} );
} )( window.wp.blocks, window.wp.element, window.wp.components, window.wp.apiFetch );
plugins_url( 'js/chapter-22-block.js', __FILE__ ) is what connects the two, it builds the correct public URL to that JS file relative to wherever this PHP file itself lives, so the browser can actually load it.
Open a post in the block editor, add a new block, and search for “AI Quote.” Click “Generate Quote,” and after a moment, a real generated quote appears where the placeholder text was. Publish the post, and the quote is just there, permanently, no button, no editor chrome, exactly what save() produced.
Try it yourself
Change the prompt in ai_course_ch22_generate_quote() to something else entirely, a WordPress tip, a one-line joke, a random fact. The block itself doesn’t need to change at all, only the prompt does.
For readers who already know JSX
Everything above uses wp.element.createElement() instead of JSX, for the reasons explained in this module’s introduction. If you already know JSX, only the imports and the two return statements would actually look different, everything else, the REST call, the state, the attribute updates, stays exactly the same.
The imports:
import { registerBlockType } from '@wordpress/blocks';
import { useState } from '@wordpress/element';
import { Button } from '@wordpress/components';
import apiFetch from '@wordpress/api-fetch';
edit()‘s return statement:
return (
<div className="ai-course-ch22-quote-block">
<Button variant="secondary" onClick={ generateQuote } disabled={ generating }>
{ generating ? 'Generating...' : 'Generate Quote' }
</Button>
<blockquote style={ { marginTop: '10px' } }>
{ attributes.quote || 'Click the button to generate a quote.' }
</blockquote>
</div>
);
save()‘s return statement:
return <blockquote>{ attributes.quote }</blockquote>;
That’s the entire difference. None of this runs without @wordpress/scripts compiling it first, which is the tradeoff this module opted out of.