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
- User clicks a like button on a post.
- Client JS sends a POST to a REST endpoint with header X-WP-Nonce (wp_create_nonce(wp_rest)).
- 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.
- 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:
-
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.
-
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).
-
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.
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
- Not sending credentials (cookies) with fetch: Always use credentials: same-origin for cookie-based REST authentication.
- Not setting X-WP-Nonce or using wrong nonce action: use wp_create_nonce(wp_rest) to match core REST cookie auth.
- Relying on client-provided counts: always store and update counts server-side.
- 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
- Decide if only logged-in users are allowed or if guests will be supported.
- Choose storage: postmeta (simple) vs custom table (scalable).
- Ensure nonce is created with wp_create_nonce(wp_rest) and passed in X-WP-Nonce header.
- Add server-side validations and rate limiting.
- Test with caching layers and verify consistent UX for cached pages.
- 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 🙂 |