DEV Community

Cover image for I built a WordPress plugin that turns any post title into a featured image (one click, no Canva)
Aldin Kozica
Aldin Kozica

Posted on • Originally published at thumbapi.dev

I built a WordPress plugin that turns any post title into a featured image (one click, no Canva)

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.

ThumbAPI panel in the WordPress block editor sidebar

The settings page is the same minimal pattern — paste an API key, pick a default style. Nothing else.

ThumbAPI plugin settings page in WordPress admin

The architecture

The plugin is three pieces:

  1. A REST endpoint registered under thumbapi/v1/generate-featured-image that takes a post_id and orchestrates the whole flow server-side.
  2. A Gutenberg sidebar panel built with wp.element.createElement (no JSX, no build step) that calls that endpoint.
  3. 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' ),
        // ...
    ),
) );
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 );
Enter fullscreen mode Exit fullscreen mode

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 );
Enter fullscreen mode Exit fullscreen mode

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' );
    },
} )
Enter fullscreen mode Exit fullscreen mode

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 );
}
Enter fullscreen mode Exit fullscreen mode

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 URI and Author URI cannot be the same. Set them to different values or omit one. I put Plugin URI: https://thumbapi.dev and Author URI: https://www.linkedin.com/in/... to separate them.
  • Text Domain must match the plugin slug exactly. If your plugin is thumbapi-auto-featured-images, your text domain has to be the same string. Mismatch is a warning at submit time.
  • Domain Path: /languages requires 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_callback that calls current_user_can(). Returning true from permission_callback is 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:

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)