How to create a favorites system that works for guests with cookies in WordPress

Contents

Overview

This tutorial explains, step by step and in full detail, how to build a favorites (bookmark / wishlist) system for WordPress that works for guest visitors using browser cookies. The system will:

  • Let guests (not signed in) add/remove posts to a favorites list.
  • Store favorites in a cookie on the client (so it persists across pages and browser restarts until the cookie expires).
  • Render favorite state server-side (so buttons show the correct state before JavaScript runs).
  • Provide AJAX endpoints to do actions if needed and to optionally merge guest favorites into a user account on login.
  • Cover security, cookie size limits, encoding formats, synchronization, server-side rendering, and sample code you can drop into your theme or plugin.

Prerequisites

  • WordPress (any modern version).
  • Basic knowledge of PHP, JavaScript, and WordPress hooks (enqueueing scripts, AJAX actions, template functions).
  • Ability to add code to your theme functions.php or preferably a custom plugin.

High-level design

We will store an array of favorited post IDs in a cookie. The cookie will only contain the post IDs (as a compact string) to reduce size. JavaScript will update the cookie when the user toggles favorites. The server will read the cookie via _COOKIE and output the correct button state on page render. We will also add optional AJAX endpoints and a login hook to merge guest favorites into a user meta favorites list.

Important considerations

  • Cookie size limit: Cookies are typically limited to about 4 KB per cookie. Store only IDs, not whole objects. If users favorite many posts, consider a fallback or limit the number of stored IDs.
  • Cookie security: Cookies set by JavaScript cannot be HttpOnly. Do not store sensitive data. Ensure server-side sanitization of any incoming cookie data.
  • Cookie encoding: Use a compact format, e.g. pipe-separated or comma-separated list of numeric IDs, and sanitize them as integers on the server.
  • SameSite and Secure flags: Set SameSite and Secure attributes appropriately.
  • Nonces CSRF: For AJAX endpoints you should use nonces to mitigate CSRF even for guests (you can generate a nonce on page render and check it in requests).

Cookie format and usage recommendations

  • Cookie name: Favorites cookie, e.g. my_favorites (prefix with theme/plugin slug to avoid collisions).
  • Value format: Compact string of integers separated by pipe , example: 124598 (no whitespace).
  • Max items: Estimate how many IDs fit under ~4KB. Each ID can be up to several digits allow maybe 300-500 IDs in the best case but real-world safe limit is lower. Consider enforcing a configured max items (e.g., 200).
  • Expiry: Choose expiry days (e.g., 365 days). Use explicit expires or max-age.
  • Path: Use / so it is available site-wide.
  • Secure SameSite: If your site uses HTTPS set Secure, and set SameSite=Lax or Strict per needs.

Server-side rendering (PHP)

To ensure the favorite button shows the right state before JavaScript runs, read the cookie from _COOKIE on the server and output an is-favorited class when rendering templates. Well provide helper functions that you can use in theme templates or use a shortcode to render a favorites button or list.

Core PHP helper functions

Save this code in a plugin or functions.php. It defines cookie name, parsing, rendering helpers, and a shortcode for a favorites button and a favorites list.

 0 ) {
            ids[] = id
        }
    }
    ids = array_values(array_unique(ids))
    if ( count(ids) > MY_FAV_MAX_ITEMS ) {
        ids = array_slice(ids, 0, MY_FAV_MAX_ITEMS)
    }
    return ids
}

/
  Check if a post is favorited according to cookie.
 
  @param int post_id
  @return bool
 /
function my_fav_is_favorited_cookie(post_id) {
    ids = my_fav_get_from_cookie()
    return in_array((int)post_id, ids, true)
}

/
  Output a favorites button (use in template).
  It prints a button element with data-post-id and proper class.
 /
function my_fav_render_button(post_id = null) {
    if ( ! post_id ) {
        post_id = get_the_ID()
    }
    is = my_fav_is_favorited_cookie(post_id)
    class = is ? my-fav-btn is-favorited : my-fav-btn
    // Provide attributes for JS identification (data-post-id)
    echo 
}

/
  Shortcode [my_favorites_list] to show a list of favorited posts (guest cookie-based).
 /
function my_fav_shortcode_list(atts) {
    ids = my_fav_get_from_cookie()
    if ( empty(ids) ) {
        return 

No favorites yet.

} // Keep order as stored query = new WP_Query(array( post_type => post, // adjust if you store other post types post__in => ids, orderby => post__in, posts_per_page => -1, )) ob_start() echo ltul class=my-fav-listgt while (query->have_posts()) { query->the_post() echo ltligtlta href= . esc_url(get_permalink()) . gt . esc_html(get_the_title()) . lt/agtlt/ligt } echo lt/ulgt wp_reset_postdata() return ob_get_clean() } add_shortcode(my_favorites_list, my_fav_shortcode_list) ?>

Notes about server-side code

  • Use my_fav_render_button() in your loop or templates where you want the favorites button to appear.
  • Always sanitize cookie contents. We only use positive integers for IDs.
  • Server-side rendering ensures the correct initial UI state and is important for SEO or users who have JS disabled.

Front-end JavaScript: read and write cookie, toggle state

We provide a minimal, robust JavaScript snippet that reads and writes the cookie, toggles ui, and can optionally call an AJAX endpoint if you want server-side processing. All cookie operations are done on the client.

/
  my-favorites.js
  Minimal vanilla JS for favorites using cookies.
  - Reads cookie MY_FAV_COOKIE
  - Uses pipe  separated format
  - Updates cookie with expiration days
 /
(function () {
  var COOKIE_NAME = my_favorites
  var COOKIE_EXPIRE_DAYS = 365
  var MAX_ITEMS = 300 // keep in sync with PHP

  function readCookie() {
    var nameEQ = COOKIE_NAME   =
    var ca = document.cookie.split()
    for (var i = 0 i < ca.length i  ) {
      var c = ca[i].trim()
      if (c.indexOf(nameEQ) === 0) {
        var v = decodeURIComponent(c.substring(nameEQ.length))
        if (!v) return []
        // support both pipe and comma
        var parts = v.split(/[,] /)
        var ids = []
        for (var j = 0 j < parts.length j  ) {
          var p = parts[j].trim()
          if (p === ) continue
          var id = parseInt(p, 10)
          if (!isNaN(id)  id > 0) {
            ids.push(id)
          }
        }
        // unique
        var uniq = Array.from(new Set(ids))
        if (uniq.length > MAX_ITEMS) {
          uniq = uniq.slice(0, MAX_ITEMS)
        }
        return uniq
      }
    }
    return []
  }

  function writeCookie(ids) {
    // ensure only ints and unique
    var clean = ids.map(function (n) { return parseInt(n, 10) })
                   .filter(function (n) { return !isNaN(n)  n > 0 })
    clean = Array.from(new Set(clean))
    if (clean.length > MAX_ITEMS) {
      clean = clean.slice(0, MAX_ITEMS)
    }
    var value = clean.join()
    var expires = 
    if (COOKIE_EXPIRE_DAYS) {
      var date = new Date()
      date.setTime(date.getTime()   (COOKIE_EXPIRE_DAYS  24  60  60  1000))
      expires =  expires=   date.toUTCString()
    }
    var secure = (location.protocol === https:) ?  Secure : 
    var sameSite =  SameSite=Lax
    document.cookie = COOKIE_NAME   =   encodeURIComponent(value)    path=/   expires   secure   sameSite
  }

  function toggleFavorite(postId) {
    var ids = readCookie()
    var idx = ids.indexOf(postId)
    var added = false
    if (idx === -1) {
      ids.push(postId)
      added = true
    } else {
      ids.splice(idx, 1)
      added = false
    }
    writeCookie(ids)
    return added
  }

  function updateButtonState(btn, isFavorited) {
    if (isFavorited) {
      btn.classList.add(is-favorited)
      btn.setAttribute(aria-pressed, true)
      var icon = btn.querySelector(.fav-icon)
      if (icon) icon.textContent = ♥
      var label = btn.querySelector(.fav-label)
      if (label) label.textContent = Favorited
    } else {
      btn.classList.remove(is-favorited)
      btn.setAttribute(aria-pressed, false)
      var icon2 = btn.querySelector(.fav-icon)
      if (icon2) icon2.textContent = ♡
      var label2 = btn.querySelector(.fav-label)
      if (label2) label2.textContent = Add to favorites
    }
  }

  function attachButtons() {
    var buttons = document.querySelectorAll(.my-fav-btn[data-post-id])
    var current = readCookie()
    buttons.forEach(function (btn) {
      var pid = parseInt(btn.getAttribute(data-post-id), 10)
      if (isNaN(pid)  pid <= 0) return
      var isFav = current.indexOf(pid) !== -1
      updateButtonState(btn, isFav)

      // prevent adding multiple listeners
      btn.removeEventListener(click, btn._myFavHandler  function () {})
      var handler = function (e) {
        e.preventDefault()
        var added = toggleFavorite(pid)
        updateButtonState(btn, added)

        // Optional: fire a custom event for other scripts to react to
        var event = new CustomEvent(myFavToggled, { detail: { postId: pid, added: added } })
        document.dispatchEvent(event)

        // Optional: call AJAX to let the server know (e.g. sync or tracking)
        // sendAjaxUpdate(pid, added)
      }
      btn._myFavHandler = handler
      btn.addEventListener(click, handler)
    })
  }

  // Optional AJAX function template (requires localized ajaxUrl  nonce)
  function sendAjaxUpdate(postId, added) {
    if (typeof myFavAjax === undefined) return
    var data = new FormData()
    data.append(action, my_fav_toggle)
    data.append(post_id, postId)
    data.append(added, added ? 1 : 0)
    if (myFavAjax.nonce) data.append(nonce, myFavAjax.nonce)

    fetch(myFavAjax.ajax_url, {
      method: POST,
      credentials: same-origin,
      body: data
    }).then(function (res) { return res.json() })
      .then(function (json) {
        // handle response if needed
      }).catch(function (err) {
        console.error(Fav AJAX error, err)
      })
  }

  // Initialize on DOM ready
  if (document.readyState === loading) {
    document.addEventListener(DOMContentLoaded, attachButtons)
  } else {
    attachButtons()
  }

  // Public for debugging
  window.myFav = {
    readCookie: readCookie,
    writeCookie: writeCookie,
    toggleFavorite: toggleFavorite
  }
})()

How to enqueue the script in WordPress

Enqueue the JS file and localize any variables (cookie name, nonce, ajax URL). Below is example code.

 admin_url(admin-ajax.php),
        nonce    => nonce,
        cookie_name => MY_FAV_COOKIE,
    ))
}
add_action(wp_enqueue_scripts, my_fav_enqueue_scripts)
?>

AJAX endpoint (optional)

You can optionally provide a server-side AJAX action to receive favorite add/remove notifications, or to support server-side features. For guests, register the nopriv action. Use check_ajax_referer to validate the nonce.

 Invalid post_id), 400)
    }

    // For guests, you might simply echo OK since cookie is client-side.
    // For logged-in users you might also store a copy in user meta:
    if ( is_user_logged_in() ) {
        user_id = get_current_user_id()
        meta_key = my_favorites_user
        favorites = get_user_meta(user_id, meta_key, true)
        if ( !is_array(favorites) ) favorites = array()

        if (added) {
            if ( !in_array(post_id, favorites, true) ) {
                favorites[] = post_id
            }
        } else {
            favorites = array_filter(favorites, function(v) use (post_id) { return intval(v) !== intval(post_id) })
        }
        update_user_meta(user_id, meta_key, array_values(favorites))
    }

    wp_send_json_success(array(post_id => post_id, added => added))
}
add_action(wp_ajax_my_fav_toggle, my_fav_ajax_toggle)
add_action(wp_ajax_nopriv_my_fav_toggle, my_fav_ajax_toggle)
?>

Sync guest favorites to a user on login

When a guest logs in, you may want to merge cookie favorites into the users account favorites and then clear the cookie. Hook into wp_login.

ID, meta_key, true)
    if ( !is_array(saved) ) saved = array()
    // Merge unique
    merged = array_values(array_unique(array_merge(saved, cookie_ids)))
    if ( count(merged) > MY_FAV_MAX_ITEMS ) {
        merged = array_slice(merged, 0, MY_FAV_MAX_ITEMS)
    }
    update_user_meta(user->ID, meta_key, merged)

    // Clear cookie by setting it to expire in the past (client-side cookie)
    // Because PHP setcookie must run before output, use setcookie only if headers not sent.
    if ( ! headers_sent() ) {
        setcookie(MY_FAV_COOKIE, , time() - 3600, /)
    } else {
        // As a fallback, print an inline script to remove the cookie after login redirect.
        add_action(wp_footer, function() {
            echo ltscriptgtdocument.cookie =  . MY_FAV_COOKIE . = path=/ expires=Thu, 01 Jan 1970 00:00:00 GMTlt/scriptgt
        }, 9999)
    }
}
add_action(wp_login, my_fav_merge_on_login, 10, 2)
?>

Shortcode to list favorites

We already showed [my_favorites_list]. Use it on a page to allow users to view their guest favorites. The server reads the cookie and renders the list.

Styling (basic)

You can style the button using CSS. Keep styles accessible (use aria-pressed). Below is an example.

/ my-favorites.css /
.my-fav-btn {
  display: inline-flex
  align-items: center
  gap: 0.5rem
  padding: 0.35rem 0.6rem
  border: 1px solid #ddd
  background: #fff
  cursor: pointer
  border-radius: 4px
}
.my-fav-btn .fav-icon { font-size: 1.1rem color: #bdbdbd }
.my-fav-btn.is-favorited { border-color: #ff6b6b background: #fff5f6 }
.my-fav-btn.is-favorited .fav-icon { color: #ff4b4b }

Template placement

Insert the button where appropriate, e.g. in single.php inside the loop:


Edge cases troubleshooting

  1. Cookies blocked or cleared:

    If the visitor blocks cookies or clears them, favorites are lost. Document this limitation in UI if needed.

  2. Cookie size overflow:

    Enforce a max number of items. When client tries to add beyond the limit, reject and optionally show a message.

  3. HTTP vs HTTPS:

    If your site runs both protocols, be careful with Secure flag. Prefer setting Secure only when using HTTPS.

  4. Multiple devices:

    Cookies are device/browser specific. To have favorites across devices, require login and sync to user meta.

  5. Nonces for guests:

    wp_create_nonce outputs a nonce value you can use for guest AJAX security check_ajax_referer will validate it. This does not prevent all attacks but raises the bar.

  6. Server-side renders different from JS:

    Ensure PHP and JS use the same parsing rules for cookie values (both use integer IDs and the same separator).

Testing checklist

  • Verify that favorites are added/removed in the cookie (check Application → Cookies in browser devtools).
  • Reload pages and verify server-rendered buttons display favorited state based on cookie.
  • Log in as a guest then register/login to see if merging works (cookie merges to user meta).
  • Test with many items near the cookie size limit to see behavior.
  • Test on both HTTP and HTTPS and with SameSite behavior for cross-site navigation if relevant.

Advanced ideas and extensions

  • LocalStorage fallback: If you need more space, you can store a larger favorites list in localStorage, and still keep a small cookie for server-side rendering. The cookie could mirror only the first N IDs for server rendering.
  • Compression: Store IDs in a compact base36 format or use a short hash map, but add complexity. Only recommended if you must store many items.
  • Server-side API: Create a REST API route to manage favorites for guests, the API can rely on cookie values and merge behavior for logged-in users.
  • Pagination lazy loading: If listing favorites from cookie results in many entries, paginate or limit initial load.
  • Analytics: Track favorite events via your analytics system by firing events in the JS toggle handler.

Security sanitization summary

  • Never trust cookie input. Always cast to int and validate post IDs on server-side.
  • Escape any output used in HTML attributes (use esc_attr/esc_html in WordPress).
  • Do not store any secret or PII in cookies that are accessible to JavaScript.
  • Use nonces for AJAX endpoints use HTTPS and Secure cookie flag if site uses HTTPS.

Full-file placement suggestions

  • Place PHP helper functions in a simple plugin file or in functions.php (prefer plugin for portability).
  • Enqueue the JS and CSS through wp_enqueue_scripts include the JS file path that matches your theme/plugin layout.
  • Place markup in templates by calling my_fav_render_button() or use the provided header/footer hooks.

Conclusion

This tutorial provided a complete cookie-based favorites approach for guest users: server-side rendering helpers, a compact and safe cookie format, JavaScript for toggling and maintaining the cookie, optional AJAX server endpoints, merging on login, and a shortcode to list favorites. Follow the sanitation, cookie size, and security notes to ensure reliable behavior across browsers and devices.



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *