Skip to main content
Being Idea Innovations
How to Create a Custom WordPress Widget (Classic and Block Editor)
Back to Blog

How to Create a Custom WordPress Widget (Classic and Block Editor)

8 September 20186 min readWordPress
Share:

A widget is a self-contained block of content a site owner can drag into a sidebar or footer without touching code. Building one means extending WP_Widget and implementing four methods.

Before the code, one thing that changed and confuses people: WordPress 5.8 replaced the widget screen with the block editor. Classic widgets still work, but where they appear and how they behave shifted. That is covered at the end.

Do not use register_sidebar_widget()

You will find this in older tutorials, including the earlier version of this article:

// Deprecated since WordPress 2.8 — released in 2009
register_sidebar_widget( __('Search'), 'custom_search_widget' );

It has been deprecated for over fifteen years. It offers no settings form, no instance data, and no way for a user to configure anything. Use the WP_Widget class.

Registering a sidebar

A widget needs somewhere to live. Register the area on widgets_init:

<?php
add_action( 'widgets_init', 'bi_register_sidebars' );
function bi_register_sidebars(): void {
    register_sidebar( [
        'name'          => __( 'Main Sidebar', 'being-idea' ),
        'id'            => 'main-sidebar',
        'description'   => __( 'Appears on posts and archive pages.', 'being-idea' ),
        // %1$s is the widget id, %2$s its class names — keep both.
        'before_widget' => '<section id="%1$s" class="widget %2$s">',
        'after_widget'  => '</section>',
        'before_title'  => '<h2 class="widget__title">',
        'after_title'   => '</h2>',
    ] );
}

Keep the %1$s and %2$s placeholders. Dropping them removes the per-widget IDs and classes that themes and plugins rely on for styling.

Then output it in your template:

<?php if ( is_active_sidebar( 'main-sidebar' ) ) : ?>
  <aside class="sidebar" role="complementary">
    <?php dynamic_sidebar( 'main-sidebar' ); ?>
  </aside>
<?php endif; ?>

The is_active_sidebar() check matters — without it you render an empty <aside> that still takes up grid space when no widgets are assigned.

The widget class

Four methods: __construct(), widget() to render the front end, form() for the admin settings, and update() to sanitise what gets saved.

class BI_Recent_Posts_Widget extends WP_Widget {

    public function __construct() {
        parent::__construct(
            'bi_recent_posts',                        // base id
            __( 'Being Idea: Recent Posts', 'being-idea' ),
            [
                'description' => __( 'Recent posts, optionally filtered by category.', 'being-idea' ),
                'classname'   => 'bi-recent-posts',
                'show_instance_in_rest' => true,      // needed for the block editor
            ]
        );
    }

    /** Front-end output. */
    public function widget( $args, $instance ): void {
        $title  = ! empty( $instance['title'] ) ? $instance['title'] : __( 'Recent Posts', 'being-idea' );
        $number = ! empty( $instance['number'] ) ? absint( $instance['number'] ) : 5;
        $cat    = ! empty( $instance['category'] ) ? absint( $instance['category'] ) : 0;

        $query = new WP_Query( [
            'post_type'           => 'post',
            'posts_per_page'      => $number,
            'ignore_sticky_posts' => true,
            'no_found_rows'       => true,   // skip the pagination COUNT — faster
            'cat'                 => $cat ?: null,
        ] );

        if ( ! $query->have_posts() ) {
            return;   // render nothing rather than an empty box
        }

        echo $args['before_widget'];

        // apply_filters keeps compatibility with translation and title plugins.
        echo $args['before_title']
           . esc_html( apply_filters( 'widget_title', $title, $instance, $this->id_base ) )
           . $args['after_title'];

        echo '<ul class="bi-recent-posts__list">';

        while ( $query->have_posts() ) {
            $query->the_post();
            printf(
                '<li><a href="%s">%s</a> <time datetime="%s">%s</time></li>',
                esc_url( get_permalink() ),
                esc_html( get_the_title() ),
                esc_attr( get_the_date( DATE_W3C ) ),
                esc_html( get_the_date() )
            );
        }

        echo '</ul>';
        echo $args['after_widget'];

        // Essential — restores the main query so the rest of the page is correct.
        wp_reset_postdata();
    }

    /** Admin settings form. */
    public function form( $instance ): string {
        $title  = $instance['title'] ?? '';
        $number = $instance['number'] ?? 5;
        $cat    = $instance['category'] ?? 0;
        ?>
        <p>
            <label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>">
                <?php esc_html_e( 'Title:', 'being-idea' ); ?>
            </label>
            <input class="widefat"
                   id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"
                   name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>"
                   type="text" value="<?php echo esc_attr( $title ); ?>" />
        </p>

        <p>
            <label for="<?php echo esc_attr( $this->get_field_id( 'number' ) ); ?>">
                <?php esc_html_e( 'Number of posts:', 'being-idea' ); ?>
            </label>
            <input class="tiny-text"
                   id="<?php echo esc_attr( $this->get_field_id( 'number' ) ); ?>"
                   name="<?php echo esc_attr( $this->get_field_name( 'number' ) ); ?>"
                   type="number" step="1" min="1" max="20"
                   value="<?php echo esc_attr( $number ); ?>" />
        </p>

        <p>
            <label for="<?php echo esc_attr( $this->get_field_id( 'category' ) ); ?>">
                <?php esc_html_e( 'Category:', 'being-idea' ); ?>
            </label>
            <?php
            wp_dropdown_categories( [
                'show_option_all' => __( 'All categories', 'being-idea' ),
                'selected'        => $cat,
                'id'              => $this->get_field_id( 'category' ),
                'name'            => $this->get_field_name( 'category' ),
                'class'           => 'widefat',
                'hide_empty'      => false,
            ] );
            ?>
        </p>
        <?php
        return '';
    }

    /** Sanitise before saving. */
    public function update( $new_instance, $old_instance ): array {
        return [
            'title'    => sanitize_text_field( $new_instance['title'] ?? '' ),
            'number'   => min( 20, max( 1, absint( $new_instance['number'] ?? 5 ) ) ),
            'category' => absint( $new_instance['category'] ?? 0 ),
        ];
    }
}

add_action( 'widgets_init', function (): void {
    register_widget( 'BI_Recent_Posts_Widget' );
} );

The details that matter

get_field_id() and get_field_name() generate unique identifiers per widget instance. Hardcoding name="title" means two copies of your widget overwrite each other's settings.

Escape on output, sanitise on input. esc_attr() in form fields, esc_html() and esc_url() in the front end, sanitize_text_field() and absint() in update(). Widget settings are entered by administrators, but a compromised or lower-privileged account should not be able to inject script into every page.

wp_reset_postdata() after any custom WP_Query. Omit it and the global post object stays pointing at your last widget item, so template tags below the sidebar output the wrong post. This is a genuinely common and confusing bug.

'no_found_rows' => true skips the SQL_CALC_FOUND_ROWS query WordPress runs for pagination. A widget never paginates, so that is a free saving on every page load.

Caching

A widget appears on every page, so its query runs on every page. Cache it:

public function widget( $args, $instance ): void {
    $key   = 'bi_recent_' . md5( wp_json_encode( $instance ) );
    $cached = get_transient( $key );

    if ( false !== $cached ) {
        echo $cached;   // already escaped when it was built
        return;
    }

    ob_start();
    // … build output …
    $output = ob_get_clean();

    set_transient( $key, $output, HOUR_IN_SECONDS );
    echo $output;
}

// Clear when content changes, or the widget shows stale posts for an hour.
add_action( 'save_post', function (): void {
    global $wpdb;
    $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_bi_recent_%'" );
} );

The block editor change

WordPress 5.8 replaced Appearance → Widgets with a block-based editor. Practical consequences:

  • Classic WP_Widget classes still work — they appear inside a "Legacy Widget" block.
  • Set 'show_instance_in_rest' => true in your constructor options, or the widget cannot render a preview in the block editor and shows a placeholder instead.
  • Widgets containing custom JavaScript in their form() often misbehave, because the block editor renders the form in an iframe and your inline scripts may not run.

If a client's site depends on the old screen, restore it:

// Bring back the classic widgets screen.
add_filter( 'use_widgets_block_editor', '__return_false' );

Alternatively install the official Classic Widgets plugin, which does the same and is easier to explain to a non-technical site owner.

For new work, consider building a block instead. Blocks can be placed in post content, templates and widget areas alike, whereas a widget only works in registered sidebars. The WP_Widget API is not deprecated, but it is no longer where WordPress is heading.

Summary

  • Extend WP_Widget; register_sidebar_widget() has been deprecated since 2009.
  • Register sidebars and widgets on widgets_init.
  • Use get_field_id() / get_field_name() so multiple instances do not collide.
  • Sanitise in update(), escape in widget() and form().
  • Always call wp_reset_postdata() after a custom query.
  • Return early rather than rendering an empty widget.
  • Cache the output, and invalidate it on save_post.
  • Set show_instance_in_rest for block editor previews.
Share:

Written by

Amit Verma

Founder / Senior Software Engineer

Amit leads engineering at Being Idea. With 15+ years building scalable software products across global markets, he drives architecture decisions and engineering culture across every engagement.

More articles by Amit

Want to talk tech?

We ship software that scales. Let's work together.

No long-term contracts
Senior engineers only
US · AU · NZ timezone coverage
14-day trial on retainers