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 🙂 |
