How to Add a Wishlist to WooCommerce Without a Plugin (Code Tutorial)

Short answer: you do not need a wishlist plugin. With roughly 150 lines of PHP, a small vanilla JavaScript file and one shortcode, you can ship a save-for-later wishlist in WooCommerce that stores data in user meta for logged-in customers and in a cookie for guests. No extra database tables, no jQuery dependency, no third-party scripts loading on every page of your store.

This tutorial gives you the complete, production-ready code: the button, the AJAX toggle, the storage layer, the wishlist page template, the menu counter and the trick that keeps everything working behind full page caching.

Why build a WooCommerce wishlist without a plugin?

Most wishlist plugins do far more than you need. A typical free wishlist plugin will:

  • Enqueue its own CSS and JS on every single page, including the checkout and blog posts
  • Create custom database tables and cron jobs for price-drop emails you will never enable
  • Register dozens of options, admin pages and upsell notices
  • Add render-blocking requests that hurt your Largest Contentful Paint and Interaction to Next Paint scores
  • Lock your data in a proprietary structure that is painful to migrate later

A custom implementation loads two small files only where they are needed, stores product IDs in a format you control, and takes about 30 minutes to install. You own the code, so nothing breaks on the next plugin update. The point is broken down further in this analysis.

wishlist heart icon

What we are going to build

Feature How it works
Save / remove button Hooked into the shop loop and the single product summary
Logged-in storage A single _exp_wishlist user meta row containing an array of product IDs
Guest storage A JSON cookie, merged into the account on login
No page reload admin-ajax.php endpoint protected by a nonce
Wishlist page [exp_wishlist] shortcode with add-to-cart and remove actions
Header counter [exp_wishlist_count] shortcode updated live in JavaScript
Cache friendly A tiny REST endpoint that rehydrates the button state after a cached HTML response

Prerequisites

  1. WooCommerce installed and active, running on a recent WordPress release (tested on current versions as of September 2026)
  2. A child theme or, even better, a site-specific plugin in wp-content/mu-plugins/ so the code survives a theme change
  3. FTP or file manager access, plus a staging site if the store is live
  4. Basic comfort with PHP and browser dev tools

Step 1: Create a safe place for the code

Do not paste this into a parent theme functions.php. Create the file wp-content/mu-plugins/exp-wishlist.php and start it with a header:

<?php
/**
 * Plugin Name: EXP Lightweight Wishlist
 * Description: WooCommerce save-for-later wishlist with no third-party plugin.
 * Version: 1.0.0
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

define( 'EXP_WISHLIST_META', '_exp_wishlist' );
define( 'EXP_WISHLIST_COOKIE', 'exp_wishlist' );

Everything below goes into that same file. If you prefer a child theme, drop the header comment and paste the functions into functions.php instead.

wishlist heart icon

Step 2: The storage layer (user meta plus guest cookie)

Two helpers do all the work: one reads the list, one writes it. Product IDs are always cast to integers and deduplicated, which removes an entire class of security and data-integrity bugs.

/**
 * Return the current wishlist as an array of product IDs.
 */
function exp_get_wishlist() {
    if ( is_user_logged_in() ) {
        $ids = get_user_meta( get_current_user_id(), EXP_WISHLIST_META, true );
    } elseif ( ! empty( $_COOKIE[ EXP_WISHLIST_COOKIE ] ) ) {
        $ids = json_decode( wp_unslash( $_COOKIE[ EXP_WISHLIST_COOKIE ] ), true );
    } else {
        $ids = array();
    }

    if ( ! is_array( $ids ) ) {
        $ids = array();
    }

    $ids = array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) );

    return apply_filters( 'exp_wishlist_items', $ids );
}

/**
 * Persist the wishlist and return the cleaned array.
 */
function exp_save_wishlist( $ids ) {
    $ids = array_values( array_unique( array_filter( array_map( 'absint', (array) $ids ) ) ) );
    $ids = array_slice( $ids, 0, 100 ); // Hard cap, protects the cookie size.

    if ( is_user_logged_in() ) {
        update_user_meta( get_current_user_id(), EXP_WISHLIST_META, $ids );
    } else {
        $value = wp_json_encode( $ids );
        setcookie(
            EXP_WISHLIST_COOKIE,
            $value,
            time() + ( 90 * DAY_IN_SECONDS ),
            COOKIEPATH ? COOKIEPATH : '/',
            COOKIE_DOMAIN,
            is_ssl(),
            false
        );
        $_COOKIE[ EXP_WISHLIST_COOKIE ] = $value;
    }

    do_action( 'exp_wishlist_saved', $ids );

    return $ids;
}

Why user meta and not a custom table? A wishlist is a short list of integers attached to one user. User meta is already cached by WordPress object caching, requires zero migration code, and is included in every standard WordPress backup or export.

Merge the guest list at login

A shopper who saves three products as a guest and then logs in should keep those products. One hook handles it:

add_action( 'wp_login', 'exp_wishlist_merge_on_login', 10, 2 );
function exp_wishlist_merge_on_login( $user_login, $user ) {
    if ( empty( $_COOKIE[ EXP_WISHLIST_COOKIE ] ) ) {
        return;
    }

    $guest = json_decode( wp_unslash( $_COOKIE[ EXP_WISHLIST_COOKIE ] ), true );
    if ( ! is_array( $guest ) || empty( $guest ) ) {
        return;
    }

    $saved = get_user_meta( $user->ID, EXP_WISHLIST_META, true );
    if ( ! is_array( $saved ) ) {
        $saved = array();
    }

    $merged = array_values( array_unique( array_map( 'absint', array_merge( $saved, $guest ) ) ) );
    update_user_meta( $user->ID, EXP_WISHLIST_META, $merged );

    setcookie( EXP_WISHLIST_COOKIE, '', time() - 3600, COOKIEPATH ? COOKIEPATH : '/', COOKIE_DOMAIN );
}

Step 3: Output the save-for-later button

WooCommerce gives you clean hooks for both the archive and the product page. The same function is reused later inside the wishlist table, which is why it accepts an optional product ID. A comparable breakdown sits on 10web.io.

add_action( 'woocommerce_after_shop_loop_item', 'exp_wishlist_button', 15 );
add_action( 'woocommerce_single_product_summary', 'exp_wishlist_button', 35 );

function exp_wishlist_button( $product_id = 0 ) {
    if ( ! $product_id ) {
        global $product;
        if ( ! $product instanceof WC_Product ) {
            return;
        }
        $product_id = $product->get_id();
    }

    $in_list = in_array( (int) $product_id, exp_get_wishlist(), true );

    printf(
        '<button type="button" class="exp-wishlist-btn%1$s" data-product-id="%2$d" aria-pressed="%3$s"><span class="exp-wishlist-icon" aria-hidden="true">&#9825;</span> <span class="exp-wishlist-label">%4$s</span></button>',
        $in_list ? ' is-active' : '',
        (int) $product_id,
        $in_list ? 'true' : 'false',
        esc_html( $in_list ? __( 'Saved', 'exp' ) : __( 'Save for later', 'exp' ) )
    );
}

Hook priorities you may want to change:

Hook Position
woocommerce_after_shop_loop_item (15) Right after the add-to-cart button in product grids
woocommerce_single_product_summary (35) Between the add-to-cart form and the product meta
woocommerce_before_shop_loop_item_title (5) Floating heart over the thumbnail (add CSS position:absolute)

Block themes note: if your shop page uses the WooCommerce Product Collection block instead of classic templates, these loop hooks may not fire. In that case, render the button with the render_block filter or keep the classic template for archives.

Step 4: Handle the AJAX toggle

One endpoint handles both add and remove. It validates the nonce, checks the post type, and returns the new state plus the item count.

add_action( 'wp_ajax_exp_wishlist_toggle', 'exp_wishlist_toggle' );
add_action( 'wp_ajax_nopriv_exp_wishlist_toggle', 'exp_wishlist_toggle' );

function exp_wishlist_toggle() {
    check_ajax_referer( 'exp_wishlist', 'nonce' );

    $product_id = isset( $_POST['product_id'] ) ? absint( $_POST['product_id'] ) : 0;

    if ( ! $product_id || ! in_array( get_post_type( $product_id ), array( 'product', 'product_variation' ), true ) ) {
        wp_send_json_error( array( 'message' => __( 'Invalid product.', 'exp' ) ), 400 );
    }

    $list  = exp_get_wishlist();
    $index = array_search( $product_id, $list, true );

    if ( false !== $index ) {
        unset( $list[ $index ] );
        $state = 'removed';
    } else {
        $list[] = $product_id;
        $state = 'added';
    }

    $list = exp_save_wishlist( $list );

    wp_send_json_success(
        array(
            'state'      => $state,
            'count'      => count( $list ),
            'product_id' => $product_id,
        )
    );
}
wishlist heart icon

Step 5: Enqueue the script and pass the nonce

Load the assets only where they are useful. That single condition is the main reason this solution is lighter than any wishlist plugin.

add_action( 'wp_enqueue_scripts', 'exp_wishlist_assets' );
function exp_wishlist_assets() {
    if ( ! function_exists( 'is_woocommerce' ) ) {
        return;
    }

    global $post;
    $has_shortcode = ( $post instanceof WP_Post ) && has_shortcode( $post->post_content, 'exp_wishlist' );

    if ( ! is_woocommerce() && ! is_cart() && ! $has_shortcode ) {
        return;
    }

    wp_enqueue_script(
        'exp-wishlist',
        get_stylesheet_directory_uri() . '/js/wishlist.js',
        array(),
        '1.0.0',
        true
    );

    wp_localize_script(
        'exp-wishlist',
        'expWishlist',
        array(
            'ajaxUrl'  => admin_url( 'admin-ajax.php' ),
            'restUrl'  => rest_url( 'exp/v1/wishlist' ),
            'nonce'    => wp_create_nonce( 'exp_wishlist' ),
            'labelAdd' => __( 'Save for later', 'exp' ),
            'labelIn'  => __( 'Saved', 'exp' ),
        )
    );
}

Step 6: The JavaScript

Create /js/wishlist.js in your child theme. It is dependency free, uses event delegation (so it works on AJAX-loaded product grids) and fires a custom event you can hook into later.

( function () {
    'use strict';

    function setState( button, added ) {
        button.classList.toggle( 'is-active', added );
        button.setAttribute( 'aria-pressed', added ? 'true' : 'false' );
        var label = button.querySelector( '.exp-wishlist-label' );
        if ( label ) {
            label.textContent = added ? expWishlist.labelIn : expWishlist.labelAdd;
        }
    }

    document.addEventListener( 'click', function ( event ) {
        var button = event.target.closest( '.exp-wishlist-btn' );
        if ( ! button ) {
            return;
        }

        event.preventDefault();
        button.disabled = true;

        var data = new URLSearchParams();
        data.append( 'action', 'exp_wishlist_toggle' );
        data.append( 'nonce', expWishlist.nonce );
        data.append( 'product_id', button.dataset.productId );

        fetch( expWishlist.ajaxUrl, {
            method: 'POST',
            credentials: 'same-origin',
            body: data
        } )
        .then( function ( response ) { return response.json(); } )
        .then( function ( result ) {
            if ( ! result.success ) {
                return;
            }

            var added = 'added' === result.data.state;
            setState( button, added );

            document.querySelectorAll( '.exp-wishlist-count' ).forEach( function ( el ) {
                el.textContent = result.data.count;
            } );

            var row = button.closest( '.exp-wishlist-row' );
            if ( row && ! added ) {
                row.remove();
            }

            document.dispatchEvent( new CustomEvent( 'exp_wishlist_updated', { detail: result.data } ) );
        } )
        .finally( function () {
            button.disabled = false;
        } );
    } );
} )();

Step 7: Minimal CSS

Add this to your child theme stylesheet. Keep it short, the goal is not to repaint the whole shop.

.exp-wishlist-btn {
    background: none;
    border: 1px solid currentColor;
    border-radius: 4px;
    cursor: pointer;
    font-size: 0.85em;
    padding: 6px 10px;
    line-height: 1.2;
}
.exp-wishlist-btn.is-active {
    background: #b8002e;
    border-color: #b8002e;
    color: #fff;
}
.exp-wishlist-btn[disabled] { opacity: 0.5; }
.exp-wishlist-table { width: 100%; border-collapse: collapse; }
.exp-wishlist-table th,
.exp-wishlist-table td { border-bottom: 1px solid #e3e3e3; padding: 12px; vertical-align: middle; }
.exp-wishlist-thumb img { max-width: 80px; height: auto; }
wishlist heart icon

Step 8: The wishlist page shortcode

Create a new page called My Wishlist and insert the shortcode [exp_wishlist]. A single WP_Query fetches every saved product in one database call and keeps the order in which items were saved.

add_shortcode( 'exp_wishlist', 'exp_wishlist_page' );

function exp_wishlist_page() {
    $ids = exp_get_wishlist();

    if ( empty( $ids ) ) {
        return '<p class="exp-wishlist-empty">' . esc_html__( 'Your wishlist is empty.', 'exp' ) .
            ' <a href="' . esc_url( wc_get_page_permalink( 'shop' ) ) . '">' .
            esc_html__( 'Browse the shop', 'exp' ) . '</a></p>';
    }

    $query = new WP_Query(
        array(
            'post_type'           => 'product',
            'post__in'            => $ids,
            'orderby'             => 'post__in',
            'posts_per_page'      => 100,
            'post_status'         => 'publish',
            'ignore_sticky_posts' => true,
            'no_found_rows'       => true,
        )
    );

    ob_start();

    echo '<table class="exp-wishlist-table"><thead><tr>';
    echo '<th>' . esc_html__( 'Product', 'exp' ) . '</th>';
    echo '<th></th>';
    echo '<th>' . esc_html__( 'Price', 'exp' ) . '</th>';
    echo '<th>' . esc_html__( 'Availability', 'exp' ) . '</th>';
    echo '<th>' . esc_html__( 'Actions', 'exp' ) . '</th>';
    echo '</tr></thead><tbody>';

    while ( $query->have_posts() ) {
        $query->the_post();

        $product = wc_get_product( get_the_ID() );
        if ( ! $product || ! $product->is_visible() ) {
            continue;
        }

        $GLOBALS['product'] = $product; // Needed by WooCommerce template functions.

        echo '<tr class="exp-wishlist-row">';
        echo '<td class="exp-wishlist-thumb"><a href="' . esc_url( $product->get_permalink() ) . '">' . $product->get_image( 'woocommerce_thumbnail' ) . '</a></td>';
        echo '<td class="exp-wishlist-name"><a href="' . esc_url( $product->get_permalink() ) . '">' . esc_html( $product->get_name() ) . '</a></td>';
        echo '<td class="exp-wishlist-price">' . wp_kses_post( $product->get_price_html() ) . '</td>';
        echo '<td class="exp-wishlist-stock">' . ( $product->is_in_stock() ? esc_html__( 'In stock', 'exp' ) : esc_html__( 'Out of stock', 'exp' ) ) . '</td>';

        echo '<td class="exp-wishlist-actions">';
        woocommerce_template_loop_add_to_cart( array( 'quantity' => 1 ) );
        exp_wishlist_button( $product->get_id() );
        echo '</td>';
        echo '</tr>';
    }

    echo '</tbody></table>';

    wp_reset_postdata();

    return ob_get_clean();
}

Because the button on this page is the exact same component, clicking it removes the item and the JavaScript deletes the table row instantly. No separate remove handler needed.

Step 9: Add a wishlist counter to the menu

add_shortcode( 'exp_wishlist_count', 'exp_wishlist_count_shortcode' );
function exp_wishlist_count_shortcode() {
    return '<span class="exp-wishlist-count">' . count( exp_get_wishlist() ) . '</span>';
}

// Allow the shortcode inside navigation menu item labels.
add_filter( 'walker_nav_menu_start_el', 'do_shortcode' );

Now a menu item labelled Wishlist ([exp_wishlist_count]) pointing at your wishlist page will display a live counter that updates on every toggle, thanks to the .exp-wishlist-count selector in the JavaScript.

Step 10: Make it work with full page caching

This is the step almost every tutorial skips. If you run Varnish, Cloudflare APO, LiteSpeed Cache or WP Rocket, guests receive a cached HTML page, which means two problems:

  1. The button state (and the counter) may belong to another visitor
  2. The nonce inside the cached HTML can be expired

The fix is to render a neutral state in HTML and hydrate it client side with one small REST call:

add_action( 'rest_api_init', function () {
    register_rest_route(
        'exp/v1',
        '/wishlist',
        array(
            'methods'             => 'GET',
            'permission_callback' => '__return_true',
            'callback'            => function () {
                $items = exp_get_wishlist();
                return array(
                    'items' => $items,
                    'count' => count( $items ),
                    'nonce' => wp_create_nonce( 'exp_wishlist' ),
                );
            },
        )
    );
} );

And at the end of wishlist.js:

document.addEventListener( 'DOMContentLoaded', function () {
    fetch( expWishlist.restUrl, { credentials: 'same-origin' } )
        .then( function ( r ) { return r.json(); } )
        .then( function ( data ) {
            expWishlist.nonce = data.nonce;

            document.querySelectorAll( '.exp-wishlist-count' ).forEach( function ( el ) {
                el.textContent = data.count;
            } );

            document.querySelectorAll( '.exp-wishlist-btn' ).forEach( function ( btn ) {
                var added = data.items.indexOf( parseInt( btn.dataset.productId, 10 ) ) !== -1;
                btn.classList.toggle( 'is-active', added );
                btn.setAttribute( 'aria-pressed', added ? 'true' : 'false' );
                var label = btn.querySelector( '.exp-wishlist-label' );
                if ( label ) {
                    label.textContent = added ? expWishlist.labelIn : expWishlist.labelAdd;
                }
            } );
        } );
} );

Also exclude the wishlist page itself from full page caching in your caching plugin, and exclude the exp_wishlist cookie from cache key stripping if your CDN removes unknown cookies.

wishlist heart icon

Testing checklist

  • Save a product as a guest, reload, confirm the heart stays active
  • Log in and confirm the guest items were merged into the account
  • Save an item on desktop, log in on mobile, confirm the list follows the account
  • Click the button twice quickly and confirm no duplicate IDs are stored
  • Delete a product in the admin and confirm the wishlist page skips it silently
  • Test with a variable product, an out-of-stock product and an external product
  • Purge the cache and test in a private window
  • Check the Network tab: the response to a toggle should be under 1 KB

Custom code vs a wishlist plugin

Criteria Custom code (this tutorial) Typical wishlist plugin
Front-end assets 1 small JS file, loaded only on shop pages Several CSS and JS files, often site wide
Database One user meta row Custom tables plus post types and options
Multiple lists per user Not included (easy to extend with a second meta key) Usually included
Public sharing links Needs extra work Built in
Price-drop and back-in-stock emails Not included Often a premium add-on
Maintenance You own it, no forced updates Depends on the vendor roadmap
Setup time About 30 minutes 5 minutes

Be honest with yourself: if your marketing team needs shareable public registries, multi-list support and automated wishlist reminder emails, a dedicated plugin is the pragmatic choice. If you simply want a save-for-later heart that does not slow down your store, the code above is all you need.

Common mistakes to avoid

  • Storing the whole product object in meta. Store IDs only, then fetch fresh data with wc_get_product() so prices and stock are never stale.
  • Skipping absint(). Unsanitised input in a query is how stores get exploited.
  • Forgetting wp_ajax_nopriv_. Without it, guests get a 0 response and nothing happens.
  • Editing the parent theme. The next theme update wipes your work. Use an mu-plugin.
  • Loading the script everywhere. The conditional in exp_wishlist_assets() is the whole performance argument, do not remove it.
  • Ignoring accessibility. Keep the aria-pressed attribute and a real text label, not only a heart glyph.

Where to go next

  1. Multiple wishlists: change the meta value to an associative array such as array( 'default' => array(), 'birthday' => array() ) and pass a list key in the AJAX request.
  2. Sharing: save a random hash in user meta and register a rewrite rule such as /wishlist/{hash}/ that reads the list read-only.
  3. Analytics: listen to the exp_wishlist_updated custom event and push an add_to_wishlist event to your data layer.
  4. Admin insight: a small WP-CLI command or admin report counting the most saved product IDs tells you exactly what to restock or discount.

FAQ

Can a WooCommerce wishlist work without a plugin for guest users?

Yes. Guests get a JSON cookie holding product IDs, valid for 90 days in the code above. When the guest creates an account or logs in, the wp_login hook merges the cookie into their user meta and deletes the cookie.

Does this wishlist have a shortcode?

Two of them: [exp_wishlist] renders the full wishlist table on any page, and [exp_wishlist_count] outputs the number of saved items, which is ideal inside a navigation menu or header block.

Will it slow down my store?

Far less than a plugin. The only front-end asset is a JavaScript file of roughly 2 KB, and it loads only on WooCommerce pages and the wishlist page. On the server side, the list is one cached user meta read. Related reading: Wishlist for WooCommerce: Multiple Wishlists per Customer.

Where should I paste the code, functions.php or a plugin file?

A must-use plugin in wp-content/mu-plugins/ is the safest option because it keeps working if you switch themes and cannot be deactivated by mistake. A child theme functions.php works fine for smaller sites.

Does it work with variable products?

Yes. The handler accepts both product and product_variation post types, so you can pass a variation ID from the single product page if you want shoppers to save a specific size or colour.

Is user meta safe for large stores?

Yes for normal usage. Each customer stores one row containing a capped array of up to 100 IDs. If you expect tens of thousands of active wishlists with reporting needs, move the data to a dedicated table later, the storage helpers are the only two functions you would rewrite.

How do I let customers move everything to the cart at once?

Add a button on the wishlist page that submits the saved IDs to a second AJAX action which loops over them and calls WC()->cart->add_to_cart( $id ), then redirects to wc_get_cart_url().

That is a complete WooCommerce wishlist without a plugin: storage, AJAX, button, shortcode page, counter and cache handling. Copy the snippets into a staging site, adjust the hook priorities and CSS to match your theme, and you get the feature customers ask for without adding a single third-party dependency to your store.

Recent Posts

No Posts Found!

Categories

Tags

    Subscribe

    You have been successfully Subscribed! Ops! Something went wrong, please try again.

    About Us

    Express Jam Studio was founded in 2004 by John Smith. John had previously worked for a courier company, but he saw an opportunity to start his own business in the web design and development industry.

    Contact Info

    Copyright © 2022 Express Jam Studio. All Rights Reserved.