Every WordPress site I've worked on has the same friction: you write the post, you stare at the empty Featured Image slot, you go to Canva, design something, export it, upload it, set it. Then publish.
I got tired of that loop, so I built a plugin that does the whole "design + upload + set as featured" step with a single button.
This post is how it works under the hood — the small Gutenberg sidebar, the REST endpoint that sideloads to the Media Library, the brand-styled button. If you just want to install it, the link is at the bottom.
What it looks like
The plugin adds a panel to the block editor sidebar with Style, Category, and a Generate button. One click sends the post title to the ThumbAPI /v1/generate endpoint, downloads the returned image, and sets it as the post's featured image — all without leaving the editor.
The settings page is the same minimal pattern — paste an API key, pick a default style. Nothing else.
The architecture
The plugin is three pieces:
- A REST endpoint registered under
thumbapi/v1/generate-featured-imagethat takes apost_idand orchestrates the whole flow server-side. - A Gutenberg sidebar panel built with
wp.element.createElement(no JSX, no build step) that calls that endpoint. - A classic editor fallback because plenty of WP installs still use it. Same flow, jQuery DOM injection below the Featured Image meta box.
The interesting part is the server side — translating an AI-generated image from a third-party API into a proper WordPress Media Library attachment.
Registering the REST endpoint
register_rest_route( 'thumbapi/v1', '/generate-featured-image', array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'handle_generate' ),
'permission_callback' => array( $this, 'permission_check' ),
'args' => array(
'post_id' => array( 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint' ),
'image_style' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ),
'category' => array( 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ),
// ...
),
) );
permission_callback is non-negotiable. The WP REST API will reject any unsanitized route at submission time to wordpress.org:
public function permission_check( WP_REST_Request $request ) {
$post_id = (int) $request->get_param( 'post_id' );
if ( $post_id <= 0 || ! current_user_can( 'edit_post', $post_id ) ) {
return new WP_Error( 'thumbapi_forbidden', __( 'You cannot edit this post.', 'thumbapi' ), array( 'status' => 403 ) );
}
if ( ! current_user_can( 'upload_files' ) ) {
return new WP_Error( 'thumbapi_no_upload', __( 'You cannot upload files.', 'thumbapi' ), array( 'status' => 403 ) );
}
return true;
}
Two capabilities, both needed: edit_post for the specific post ID, and upload_files because we're about to write to the Media Library.
Sideloading the generated image
ThumbAPI returns a data:image/webp;base64,... URI. The job is to decode it, write it to wp-content/uploads/, register it as a proper attachment so WordPress can generate thumbnail/medium/large sizes from it, and return the attachment ID:
public static function sideload_data_uri( $data_uri, $post_id, $filename ) {
if ( ! preg_match( '#^data:image/([a-z0-9+.-]+);base64,(.+)$#i', $data_uri, $matches ) ) {
return new WP_Error( 'thumbapi_invalid_data_uri', __( 'Invalid image.', 'thumbapi' ) );
}
$mime_subtype = strtolower( $matches[1] );
$decoded = base64_decode( $matches[2], true );
if ( false === $decoded ) {
return new WP_Error( 'thumbapi_decode_failed', __( 'Could not decode image.', 'thumbapi' ) );
}
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
$uploads = wp_upload_dir();
$ext = 'webp'; // simplified
$unique = wp_unique_filename( $uploads['path'], sanitize_file_name( $filename ) . '.' . $ext );
$filepath = trailingslashit( $uploads['path'] ) . $unique;
file_put_contents( $filepath, $decoded );
$attachment_id = wp_insert_attachment( array(
'guid' => trailingslashit( $uploads['url'] ) . $unique,
'post_mime_type' => 'image/' . $mime_subtype,
'post_title' => sanitize_file_name( $filename ),
'post_status' => 'inherit',
), $filepath, $post_id, true );
$metadata = wp_generate_attachment_metadata( $attachment_id, $filepath );
wp_update_attachment_metadata( $attachment_id, $metadata );
return (int) $attachment_id;
}
That last wp_generate_attachment_metadata call is what makes the image a proper WordPress citizen — WP scans dimensions, generates intermediate sizes, populates srcset data. Skip it and your <img srcset> is empty.
After that, one line to wire it up:
set_post_thumbnail( $post_id, $attachment_id );
The Gutenberg sidebar (no JSX, no build step)
I deliberately wrote the editor JS without JSX. WordPress plugins that ship a Webpack bundle are an immediate review red flag — wordpress.org reviewers want code they can read in 30 seconds without running a build. wp.element.createElement is verbose but cheap to ship:
( function ( wp ) {
var el = wp.element.createElement;
var registerPlugin = wp.plugins.registerPlugin;
var PluginDocumentSettingPanel = wp.editPost.PluginDocumentSettingPanel;
var Button = wp.components.Button;
var Dropdown = wp.components.Dropdown;
var apiFetch = wp.apiFetch;
var __ = wp.i18n.__;
function ThumbAPIPanel() {
var style = wp.element.useState( 'faceless' );
var loading = wp.element.useState( false );
var post = wp.data.useSelect( function ( select ) {
var core = select( 'core/editor' );
return { id: core.getCurrentPostId(), title: core.getEditedPostAttribute( 'title' ) };
}, [] );
var editorDispatch = wp.data.useDispatch( 'core/editor' );
function handleGenerate() {
loading[ 1 ]( true );
apiFetch( {
path: '/thumbapi/v1/generate-featured-image',
method: 'POST',
data: { post_id: post.id, title: post.title, image_style: style[ 0 ] },
} ).then( function ( res ) {
if ( res && res.attachment_id ) {
editorDispatch.editPost( { featured_media: res.attachment_id } );
}
loading[ 1 ]( false );
} );
}
return el( PluginDocumentSettingPanel, { name: 'thumbapi-panel', title: 'ThumbAPI' },
// …style + category dropdowns here
el( Button, { variant: 'primary', disabled: loading[ 0 ], onClick: handleGenerate },
loading[ 0 ] ? 'Generating…' : 'Generate'
)
);
}
registerPlugin( 'thumbapi-featured-image', { render: ThumbAPIPanel } );
} )( window.wp );
apiFetch automatically attaches the X-WP-Nonce header, so REST auth Just Works as long as the user is logged in. editorDispatch.editPost({ featured_media: attachmentId }) is the canonical way to update the featured image from JS — the editor reacts immediately, no full reload.
Media Library picker for the "with photo" style
ThumbAPI supports a personImage parameter (base64) for compositing a face or logo into the thumbnail. For WordPress users, the natural source for that is the Media Library — they already have their face/logo there. So instead of forcing them back to a dashboard upload, the plugin uses wp.blockEditor.MediaUpload:
el( wp.blockEditor.MediaUpload, {
allowedTypes: [ 'image' ],
onSelect: function ( m ) {
setMedia( {
id: m.id,
url: m.url,
filename: m.filename,
filesizeInBytes: m.filesizeInBytes,
} );
},
render: function ( ctx ) {
return el( Button, { variant: 'secondary', onClick: ctx.open }, 'Choose image' );
},
} )
When the user generates, the server reads that attachment back, base64-encodes it, and ships it inline:
public static function encode_attachment_base64( $attachment_id ) {
$file = get_attached_file( $attachment_id );
if ( ! $file || ! file_exists( $file ) ) {
return new WP_Error( 'thumbapi_attachment_missing_file', __( 'File missing.', 'thumbapi' ) );
}
if ( filesize( $file ) > 2 * 1024 * 1024 ) {
return new WP_Error( 'thumbapi_attachment_too_large', __( 'Over 2 MB.', 'thumbapi' ) );
}
$mime = get_post_mime_type( $attachment_id );
$bytes = file_get_contents( $file );
return 'data:' . $mime . ';base64,' . base64_encode( $bytes );
}
Two megabyte cap is enforced both client- and server-side. Client-side gives the user a clear "Over 2 MB" notice before they click Generate; server-side stops anyone who skips the UI.
Things I had to fight with
A few WordPress.org review gotchas I hit along the way:
-
Plugin URIandAuthor URIcannot be the same. Set them to different values or omit one. I putPlugin URI: https://thumbapi.devandAuthor URI: https://www.linkedin.com/in/...to separate them. -
Text Domainmust match the plugin slug exactly. If your plugin isthumbapi-auto-featured-images, your text domain has to be the same string. Mismatch is a warning at submit time. -
Domain Path: /languagesrequires the folder to exist — even if you don't ship translations yet. Easier to omit the header until you do. -
No bundled libraries. jQuery, React,
wp.element, etc. — all from WordPress core. No Webpack output that re-bundles them. -
REST endpoints need real
permission_callbackthat callscurrent_user_can(). Returningtruefrompermission_callbackis an instant rejection.
Install it
The plugin is currently in review on the WordPress.org Plugin Directory (slug: thumbapi). Until it lists, you can install it manually:
- Download: https://thumbapi.dev/downloads/thumbapi-wordpress.zip
- Full integration page: https://thumbapi.dev/integrations/wordpress
You need a free ThumbAPI key (5 generations/month on the free tier). After install: Settings → ThumbAPI → paste key → open any post → click Generate.
Source
The official plugin ships through WordPress.org and the link above. The single-file auto-on-publish reference example (different flow — fires on transition_post_status instead of a button) lives in the examples repo: github.com/dinalllll/thumbapi-examples/tree/main/wordpress.
If you build something on top of this — or hit a bug — I'd love to know.
Originally published on thumbapi.dev/integrations/wordpress.


Top comments (0)