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.

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
- WooCommerce installed and active, running on a recent WordPress release (tested on current versions as of September 2026)
- A child theme or, even better, a site-specific plugin in
wp-content/mu-plugins/so the code survives a theme change - FTP or file manager access, plus a staging site if the store is live
- 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.

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">♡</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,
)
);
}

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; }

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:
- The button state (and the counter) may belong to another visitor
- 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.

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 a0response 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-pressedattribute and a real text label, not only a heart glyph.
Where to go next
- 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. - Sharing: save a random hash in user meta and register a rewrite rule such as
/wishlist/{hash}/that reads the list read-only. - Analytics: listen to the
exp_wishlist_updatedcustom event and push anadd_to_wishlistevent to your data layer. - 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.
