How to create a likes system with REST JS nonces in WordPress

Contents

Overview

This tutorial walks through a complete, production-aware implementation of a likes (thumbs-up) system for WordPress using the REST API, secure nonces, and client-side JavaScript. It covers:

  • Server-side REST endpoints (register_rest_route)
  • Nonce usage and permission checks
  • Storing likes (simple postmeta approach a scalable custom-table option)
  • Front-end JS that talks to the REST endpoint with X-WP-Nonce
  • Practical concerns: rate-limiting, guest likes, accessibility, preventing duplicate likes

High-level design

  1. User clicks a like button on a post.
  2. Client JS sends a POST to a REST endpoint with header X-WP-Nonce (wp_create_nonce(wp_rest)).
  3. Server verifies the nonce and user permissions, toggles the like (or returns an error if duplicate), updates storage, and returns the new count and status.
  4. Client updates the UI (count, active state) based on the response.

Which users can like?

  • Logged-in users: simplest and most secure. Nonce authentication works out of the box and you can store a user ID to prevent duplicate likes.
  • Guest users: possible, but requires additional anti-abuse measures (cookies, IP, throttling). An optional section below describes approaches.

Files youll create

  • One plugin file (example: wp-likes-system/wp-likes-system.php)
  • One JavaScript file (js/likes.js)
  • Optional: CSS file for button styling

Plugin: complete example (logged-in users only)

The following plugin provides a straightforward, secure REST-based likes system where only logged-in users can like/unlike posts. It stores likes in postmeta (an array of user IDs) and maintains a simple like count meta. This is easy to understand and fine for small-to-medium sites. For high scale, later we provide a custom table strategy.

1) Plugin boilerplate and REST route

Place the following into wp-likes-system/wp-likes-system.php

d ),
            array(
                methods             => POST,
                callback            => array( this, handle_toggle_like ),
                permission_callback => array( this, permission_check ),
            )
        )

        register_rest_route(
            likes/v1,
            /post/(?Pd ),
            array(
                methods  => GET,
                callback => array( this, handle_get_status ),
                args     => array(
                    id => array(
                        validate_callback => function( param ) {
                            return is_numeric( param )
                        }
                    )
                )
            )
        )
    }

    public function permission_check( request ) {
        // X-WP-Nonce header is set client-side with wp_create_nonce(wp_rest)
        nonce = request->get_header( x_wp_nonce )

        if ( empty( nonce ) ) {
            return new WP_Error( rest_forbidden, Missing nonce., array( status => 403 ) )
        }

        if ( ! wp_verify_nonce( nonce, wp_rest ) ) {
            return new WP_Error( rest_forbidden, Invalid nonce., array( status => 403 ) )
        }

        // This example requires logged-in users
        if ( ! is_user_logged_in() ) {
            return new WP_Error( rest_forbidden, You must be logged in to like., array( status => 403 ) )
        }

        return true
    }

    public function handle_get_status( request ) {
        post_id = absint( request[id] )
        if ( ! get_post( post_id ) ) {
            return new WP_Error( invalid_post, Post not found., array( status => 404 ) )
        }

        count = intval( get_post_meta( post_id, self::META_LIKE_COUNT, true ) )
        liked = false
        user_id = get_current_user_id()
        if ( user_id ) {
            users = get_post_meta( post_id, self::META_LIKE_USERS, true )
            if ( ! is_array( users ) ) {
                users = array()
            }
            liked = in_array( user_id, users, true )
        }

        return rest_ensure_response( array(
            post_id => post_id,
            count   => count,
            liked   => liked,
        ) )
    }

    public function handle_toggle_like( request ) {
        post_id = absint( request[id] )
        if ( ! get_post( post_id ) ) {
            return new WP_Error( invalid_post, Post not found., array( status => 404 ) )
        }

        user_id = get_current_user_id()
        if ( ! user_id ) {
            return new WP_Error( not_logged_in, You must be logged in to like., array( status => 403 ) )
        }

        // Load users array
        users = get_post_meta( post_id, self::META_LIKE_USERS, true )
        if ( ! is_array( users ) ) {
            users = array()
        }

        liked_before = in_array( user_id, users, true )

        if ( liked_before ) {
            // Unlike: remove user ID
            users = array_values( array_diff( users, array( user_id ) ) )
            new_count = max( 0, intval( get_post_meta( post_id, self::META_LIKE_COUNT, true ) ) - 1 )
            update_post_meta( post_id, self::META_LIKE_USERS, users )
            update_post_meta( post_id, self::META_LIKE_COUNT, new_count )
            action = unliked
        } else {
            // Like: append user ID
            users[] = user_id
            new_count = intval( get_post_meta( post_id, self::META_LIKE_COUNT, true ) )   1
            update_post_meta( post_id, self::META_LIKE_USERS, users )
            update_post_meta( post_id, self::META_LIKE_COUNT, new_count )
            action = liked
        }

        // Optionally, trigger action so themes/plugins can react
        do_action( wp_likes_toggled, post_id, user_id, action )

        return rest_ensure_response( array(
            post_id => post_id,
            count   => new_count,
            liked   => ! liked_before,
            action  => action,
        ) )
    }

    public function enqueue_scripts() {
        // Ensure script is enqueued on single posts (or everywhere if you prefer)
        if ( is_singular() ) {
            wp_enqueue_script( wp-likes-js, plugin_dir_url( __FILE__ ) . js/likes.js, array(), 1.0, true )
            wp_localize_script( wp-likes-js, WP_Likes_Data, array(
                nonce    => wp_create_nonce( wp_rest ),
                rest_url => esc_url_raw( rest_url( likes/v1/post/ ) ), // well append ID on client-side
            ) )
            wp_enqueue_style( wp-likes-css, plugin_dir_url( __FILE__ ) . css/likes.css )
        }
    }
}

new WP_Likes_System()

2) Front-end HTML snippet (theme)

Place this in your single.php or content template where you want the button to appear (the server renders initial count and the data-post-id attribute so JS can act immediately):

lt?php
// Inside the Loop
post_id = get_the_ID()
count = intval( get_post_meta( post_id, _likes_count, true ) )
liked = false
if ( is_user_logged_in() ) {
    users = get_post_meta( post_id, _likes_user_ids, true )
    if ( ! is_array( users ) ) users = array()
    liked = in_array( get_current_user_id(), users, true )
}
?gt

ltbutton class=wp-like-buttonlt?php echo liked ?  liked :  ?gt data-post-id=lt?php echo esc_attr( post_id ) ?gt aria-pressed=lt?php echo liked ? true : false ?gtgt
    ltspan class=wp-like-icongt❤lt/spangt
    ltspan class=wp-like-countgtlt?php echo count ?gtlt/spangt
lt/buttongt

3) Client-side JavaScript

Save as js/likes.js inside the plugin directory. It uses the localized WP_Likes_Data object (rest_url and nonce) injected by wp_localize_script.

document.addEventListener(click, function (e) {
  var btn = e.target.closest  e.target.closest(.wp-like-button)
  if (!btn) return

  e.preventDefault()
  var postId = btn.getAttribute(data-post-id)
  if (!postId) return

  // Visual feedback
  btn.classList.add(loading)

  fetch(WP_Likes_Data.rest_url   postId, {
    method: POST,
    credentials: same-origin, // ensures cookies are sent
    headers: {
      Content-Type: application/json,
      X-WP-Nonce: WP_Likes_Data.nonce
    },
    body: JSON.stringify({}) // no payload needed for this toggle
  }).then(function (response) {
    return response.json()
  }).then(function (data) {
    btn.classList.remove(loading)
    if (data  !data.code) {
      // success
      var countEl = btn.querySelector(.wp-like-count)
      if (countEl) countEl.textContent = data.count
      if (data.liked) {
        btn.classList.add(liked)
        btn.setAttribute(aria-pressed, true)
      } else {
        btn.classList.remove(liked)
        btn.setAttribute(aria-pressed, false)
      }
    } else {
      // REST returned WP_Error structure display or console.log
      console.error(Like error:, data)
      // Optionally expose user-facing messages
    }
  }).catch(function (err) {
    btn.classList.remove(loading)
    console.error(Fetch error:, err)
  })
})

4) Styling (optional)

.wp-like-button {
  display: inline-flex
  align-items: center
  gap: 6px
  padding: 6px 10px
  border: 1px solid #ddd
  background: #fff
  cursor: pointer
  border-radius: 4px
}
.wp-like-button.liked {
  background: #ffeef0
  border-color: #ffccd5
}
.wp-like-button.loading {
  opacity: 0.6
  pointer-events: none
}
.wp-like-count {
  font-weight: 600
}

REST endpoint summary

Endpoint Method Auth Purpose
/wp-json/likes/v1/post/{id} GET optional (to show whether current user liked) Return count and whether current user has liked
/wp-json/likes/v1/post/{id} POST X-WP-Nonce (logged-in) Toggle like/unlike for the current user

Security and nonces explained

  • X-WP-Nonce is the accepted header for REST calls authenticated via cookie auth. On the server side you verify it with wp_verify_nonce(nonce, wp_rest) or simply rely on the permission_callback to check it.
  • Use wp_create_nonce(wp_rest) when localizing the script. That is what WordPress core uses for REST cookie authentication.
  • Always cast and validate inputs server-side (absint for IDs). Never trust client-supplied counts or flags.
  • If you allow guest likes, nonces are not sufficient because wp_create_nonce is user-specific and requires a logged-in session. For guests you must implement alternative tokens or server-side anti-abuse checks (see below).

Making it robust and production-ready

Recommended considerations:

  • Race conditions: if you worry about concurrent updates, use transactions or a custom table with row-level updates (shown later). Postmeta updates are usually ok for low volume but can suffer with high concurrency.
  • Caching: If full page cache is used you should render the like button via JavaScript (AJAX) to show dynamic state per user, or use edge-side includes. The plugin example enqueues the JS only on singular pages and server outputs initial count that will be stale with full-page caching unless you serve the button dynamic.
  • Rate limiting: Add server-side checks to avoid abuse (simple throttle per IP or per user using transients).
  • Accessibility: Use aria-pressed, provide accessible labels, and ensure keyboard support.
  • Use HTTPS so cookies and nonces are secure.

Rate-limiting example (simple)

Inside handle_toggle_like, you can add a transient-based throttle:

// Inside handle_toggle_like() before processing:
throttle_key = like_throttle_ . user_id
last = get_transient( throttle_key )
if ( last ) {
    return new WP_Error( rate_limited, You are liking too fast. Try again later., array( status => 429 ) )
}
set_transient( throttle_key, time(), 2 ) // block another like for 2 seconds

Guest likes: options and trade-offs

If you want anonymous users to like posts (no login), consider these approaches:

  1. Cookie IP approach

    • On first like, set a long-lived cookie with a UUID and store that UUID in the postmeta list (or custom table) to block repeats.
    • Also store hashed IP with timestamp to do additional abuse checks.
    • Issues: users can clear cookies to like again multiple people behind same NAT share IP cookies can be forged unless server issues them securely.
  2. Temporary server token trick

    • When rendering the page, generate a one-time token (transient) bound to a cookie. The token allows one operation and is short-lived.
    • This prevents automated replays but requires server-side handling and an initial GET to generate token or embedding token in page (reduces cache-ability).
  3. Require small friction: social login / email / or recaptcha

    • Requiring a low-friction account or a social login reduces spam/abuse significantly.
    • Adding reCAPTCHA on the like button can reduce bot activity but may hurt UX.

Guest cookie example (simple)

Concept: if not logged in, server generates a UUID cookie wp_like_guest and JS passes that value with requests. Server stores hashed cookie value per post. Very basic and vulnerable to clearing cookies or spoofed headers, but better than nothing.

// Example helper to ensure cookie exists (call early in page generation)
function ensure_like_guest_cookie() {
    if ( ! isset( _COOKIE[wp_like_guest] ) ) {
        uuid = wp_generate_uuid4()
        setcookie( wp_like_guest, uuid, time()   YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true )
        _COOKIE[wp_like_guest] = uuid
    }
}

Then the REST endpoint would read the cookie (via _COOKIE) and use a hashed value for storage and de-duplication. Always combine cookie with rate-limiting and optional IP checks.

Scaling with custom table (recommended for very large sites)

When you expect millions of likes or heavy concurrency, postmeta is inefficient. Instead, create a custom table like wp_post_likes with rows (id, post_id, user_id, guest_hash, created_at). This table makes counts and checks fast via indexed queries.

CREATE TABLE wp_post_likes (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  post_id BIGINT UNSIGNED NOT NULL,
  user_id BIGINT UNSIGNED DEFAULT NULL,
  guest_hash VARCHAR(128) DEFAULT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY post_user_unique (post_id, user_id),
  UNIQUE KEY post_guest_unique (post_id, guest_hash),
  KEY post_idx (post_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4

On plugin activation you can create the table via dbDelta. Use wpdb->insert and wpdb->get_results for checks, and use transactions or UPDATE … WHERE … for atomic counters if you store an aggregated count column.

// Example of inserting like for logged-in user using wpdb
global wpdb
table = wpdb->prefix . post_likes

// Try insert (unwrap duplicate key error)
inserted = wpdb->insert(
    table,
    array(
        post_id => post_id,
        user_id => user_id,
    ),
    array(
        %d,
        %d
    )
)
if ( false === inserted ) {
    // handle DB error
}
// Then compute count quickly:
count = wpdb->get_var( wpdb->prepare( SELECT COUNT() FROM {table} WHERE post_id = %d, post_id ) )

Testing and debugging

  • Check REST route is registered: visit https://developer.wordpress.org/rest-api/ or test with curl (authenticated via cookies X-WP-Nonce).
  • Open browser devtools Network tab to inspect the POST to /wp-json/likes/v1/post/{id}. Verify X-WP-Nonce is sent and the response JSON is correct.
  • For server-side errors, enable WP_DEBUG and check PHP error logs for WP_Error messages.

Common pitfalls

  1. Not sending credentials (cookies) with fetch: Always use credentials: same-origin for cookie-based REST authentication.
  2. Not setting X-WP-Nonce or using wrong nonce action: use wp_create_nonce(wp_rest) to match core REST cookie auth.
  3. Relying on client-provided counts: always store and update counts server-side.
  4. Caching: If your pages are cached, the server-rendered like state will be stale. Render the button client-side or fetch status via GET on page load (AJAX) if using full-page cache.

Extending the system

  • Emit custom actions (do_action) when likes change so other plugins can react (notifications, reputation, stats).
  • Provide REST endpoints for admin reporting (top liked posts, user likes).
  • Expose a single endpoint for batch queries (multiple post IDs) to reduce network overhead.
  • Provide optimistic UI updates for best UX (update UI immediately, revert on error).

Full flow checklist before deploying

  1. Decide if only logged-in users are allowed or if guests will be supported.
  2. Choose storage: postmeta (simple) vs custom table (scalable).
  3. Ensure nonce is created with wp_create_nonce(wp_rest) and passed in X-WP-Nonce header.
  4. Add server-side validations and rate limiting.
  5. Test with caching layers and verify consistent UX for cached pages.
  6. Add analytics or admin reports if needed.

References

Notes

This article focused on a secure logged-in user implementation and provided patterns for guests and scaling. Use the postmeta approach for fast integration and a custom table for high scale. Nonces plus the X-WP-Nonce header provide secure REST cookie authentication when used with wp_create_nonce(wp_rest) and wp_verify_nonce on the server.



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

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