Contents
Overview
This article is a complete, detailed tutorial for WordPress developers showing how to create REST API endpoints for user favorites (add, remove, toggle, list) and how to build a robust JavaScript UI that interacts with those endpoints. It covers multiple storage strategies (user meta and custom table), REST route registration, permission and security considerations (nonces, capabilities, sanitization), server responses, and production-ready UI patterns (optimistic updates, error handling, batching, caching).
When to use which storage strategy
- User meta (recommended for small-scale) — store an array of post IDs in user meta. Simple, no schema changes, easy to implement. Works well when each user has hundreds or fewer favorites.
- Custom table (recommended for large scale) — create a dedicated table (user_id, post_id, created_at). Best when you expect many favorites, need fast queries, counts, or need to index and join efficiently.
High-level endpoints you will implement
- GET /wp-json/myfavorites/v1 — fetch current users favorites (or public favorites list)
- POST /wp-json/myfavorites/v1 — add a favorite (payload includes post_id)
- DELETE /wp-json/myfavorites/v1/{post_id} — remove a favorite
- POST /wp-json/myfavorites/v1/toggle — toggle favorite state for a post
Backend: Implementation using user meta (step-by-step)
1) Plugin scaffold (single file plugin)
Create a small plugin file (e.g., my-favorites.php) and register REST routes during REST API init. The next code block gives a full example of a plugin implementing the favorites endpoints using user meta storage, with permission callbacks and sanitization.
lt?php / Plugin Name: My Favorites API Description: REST endpoints for user favorites stored in user_meta. Version: 1.0 Author: Your Name / // Exit if accessed directly. if ( ! defined( ABSPATH ) ) { exit } / Register routes / add_action( rest_api_init, function () { namespace = myfavorites/v1 register_rest_route( namespace, /favorites, array( array( methods => GET, callback => mf_get_favorites, permission_callback => mf_can_view_favorites, ), array( methods => POST, callback => mf_add_favorite, permission_callback => mf_user_must_be_logged_in, args => array( post_id => array( required => true, validate_callback => rest_validate_request_arg, ), ), ), ) ) register_rest_route( namespace, /favorites/(?Pltpost_idgtd ), array( array( methods => DELETE, callback => mf_remove_favorite, permission_callback => mf_user_must_be_logged_in, args => array( post_id => array( required => true, validate_callback => rest_validate_request_arg, ), ), ), ) ) register_rest_route( namespace, /favorites/toggle, array( array( methods => POST, callback => mf_toggle_favorite, permission_callback => mf_user_must_be_logged_in, args => array( post_id => array( required => true, validate_callback => rest_validate_request_arg, ), ), ), ) ) } ) / Permission callbacks / function mf_user_must_be_logged_in() { return is_user_logged_in() } function mf_can_view_favorites() { // Public route could show favorites of a user if query param user_id provided, // For simplicity restrict to logged in users only (personal favorites) return is_user_logged_in() } / Helpers / function mf_get_user_favorites_ids( user_id ) { favs = get_user_meta( user_id, my_favorites, true ) if ( empty( favs ) ! is_array( favs ) ) { return array() } // Ensure ints and unique favs = array_map( absint, favs ) favs = array_values( array_unique( favs ) ) return favs } / GET handler - returns favorites as array of post objects (or IDs) / function mf_get_favorites( WP_REST_Request request ) { user_id = get_current_user_id() ids = mf_get_user_favorites_ids( user_id ) // If you want full post objects, use get_posts if ( empty( ids ) ) { return rest_ensure_response( array() ) } posts = get_posts( array( post__in => ids, post_status => publish, orderby => post__in, // preserve order posts_per_page => -1, ) ) data = array() foreach ( posts as p ) { data[] = array( id => p->ID, title => get_the_title( p ), link => get_permalink( p ), ) } return rest_ensure_response( data ) } / POST handler - add a favorite / function mf_add_favorite( WP_REST_Request request ) { user_id = get_current_user_id() post_id = absint( request->get_param( post_id ) ) if ( ! post_id get_post_status( post_id ) !== publish ) { return new WP_Error( invalid_post, Invalid post ID, array( status => 400 ) ) } favs = mf_get_user_favorites_ids( user_id ) if ( in_array( post_id, favs, true ) ) { return rest_ensure_response( array( message => Already favorited, post_id => post_id ) ) } favs[] = post_id update_user_meta( user_id, my_favorites, favs ) return rest_ensure_response( array( message => Added, post_id => post_id ) ) } / DELETE handler - remove a favorite / function mf_remove_favorite( WP_REST_Request request ) { user_id = get_current_user_id() post_id = absint( request->get_param( post_id ) ) favs = mf_get_user_favorites_ids( user_id ) if ( ! in_array( post_id, favs, true ) ) { return new WP_Error( not_found, Favorite not found, array( status => 404 ) ) } favs = array_values( array_diff( favs, array( post_id ) ) ) update_user_meta( user_id, my_favorites, favs ) return rest_ensure_response( array( message => Removed, post_id => post_id ) ) } / Toggle handler / function mf_toggle_favorite( WP_REST_Request request ) { user_id = get_current_user_id() post_id = absint( request->get_param( post_id ) ) if ( ! post_id get_post_status( post_id ) !== publish ) { return new WP_Error( invalid_post, Invalid post ID, array( status => 400 ) ) } favs = mf_get_user_favorites_ids( user_id ) if ( in_array( post_id, favs, true ) ) { // remove favs = array_values( array_diff( favs, array( post_id ) ) ) update_user_meta( user_id, my_favorites, favs ) return rest_ensure_response( array( message => Removed, post_id => post_id, favorited => false ) ) } else { // add favs[] = post_id update_user_meta( user_id, my_favorites, favs ) return rest_ensure_response( array( message => Added, post_id => post_id, favorited => true ) ) } } ?
Notes about the code above
- Sanitization: Inputs are sanitized with absint and validated using get_post_status to ensure published posts only.
- Permissions: permission_callback returns is_user_logged_in(), so only authenticated users may modify favorites.
- Responses: Use rest_ensure_response or WP_Error with appropriate HTTP status codes.
- Nonce: For cookie-based auth to the REST API, the front-end must send X-WP-Nonce generated via wp_create_nonce(wp_rest) — see the JS section.
Alternative backend: Custom DB table (for high scale)
If you expect many favorites per user or need efficient queries and counts, create a custom table and use dbDelta in plugin activation to set it up. The following code shows table creation and basic handlers.
lt?php // Activation: create table register_activation_hook( __FILE__, mf_create_table ) function mf_create_table() { global wpdb table_name = wpdb->prefix . user_favorites charset_collate = wpdb->get_charset_collate() sql = CREATE TABLE table_name ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT(20) UNSIGNED NOT NULL, post_id BIGINT(20) UNSIGNED NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY user_post (user_id, post_id), KEY post_id (post_id) ) charset_collate require_once( ABSPATH . wp-admin/includes/upgrade.php ) dbDelta( sql ) } // Example add favorite using wpdb function mf_db_add_favorite( user_id, post_id ) { global wpdb table = wpdb->prefix . user_favorites // Check exists exists = wpdb->get_var( wpdb->prepare( SELECT id FROM table WHERE user_id = %d AND post_id = %d, user_id, post_id ) ) if ( exists ) { return false } wpdb->insert( table, array( user_id => user_id, post_id => post_id, ), array( %d, %d ) ) return wpdb->insert_id } ?
Why custom table?
- Faster filtering and counting via indexed columns.
- Able to query favorites across users efficiently (e.g., most-favorited posts).
- Avoids bloating usermeta for large datasets.
Security and best practices for endpoints
- Validate inputs with absint, sanitize_text_field, esc_sql when needed.
- Capability checks — ensure only the appropriate users can modify data. Use current_user_can if necessary.
- Nonce — For front-end JavaScript using cookie-based authentication, pass X-WP-Nonce header generated via wp_create_nonce(wp_rest). On the server side, permission_callback combined with WordPress internal rest cookie auth will validate the nonce.
- Rate-limiting — If spam is a concern, throttle endpoint usage or implement limits per user/IP.
- Use WP_REST_Response to set status codes when needed.
Front-end: Enqueue scripts and pass REST data
Enqueue your JavaScript and expose the REST namespace URL and a nonce to the script using wp_localize_script or wp_add_inline_script. This example enqueues a script and provides an object window.wpFavorites with restUrl and nonce.
// Enqueue in plugin or theme add_action( wp_enqueue_scripts, mf_enqueue_scripts ) function mf_enqueue_scripts() { wp_enqueue_script( mf-favorites, plugin_dir_url( __FILE__ ) . js/mf-favorites.js, array(), 1.0, true ) // Provide the nonce and endpoint base data = array( root => esc_url_raw( rest_url() ), nonce => wp_create_nonce( wp_rest ), namespace => myfavorites/v1, ) wp_localize_script( mf-favorites, wpFavorites, data ) }
Front-end UI in JavaScript
Below is a fully featured JS module that:
- Attaches click handlers to elements with .favorite-button and data-post-id.
- Sends toggle requests to the REST API using fetch.
- Performs optimistic UI updates and reverts on error.
- Supports updating favorite counts and accessible labels for screen readers.
// File: js/mf-favorites.js (function () { use strict if ( typeof wpFavorites === undefined ) { console.warn(wpFavorites not defined) // not fatal return } const root = wpFavorites.root.replace(///, ) const namespace = wpFavorites.namespace const base = {root}/{namespace} const nonce = wpFavorites.nonce const headers = { Content-Type: application/json, X-WP-Nonce: nonce } // Utility to send toggle async function toggleFavorite(postId) { const url = {base}/favorites/toggle const body = JSON.stringify({ post_id: postId }) const res = await fetch(url, { method: POST, headers, body }) if (!res.ok) { const data = await res.json().catch(() => ({})) throw { status: res.status, data } } return res.json() } // Add aria and class updates for UI function setButtonState(button, favorited) { if (!button) return button.classList.toggle(favorited, favorited) const label = favorited ? Remove favorite : Add favorite button.setAttribute(aria-pressed, favorited ? true : false) button.setAttribute(title, label) // update visually inside e.g., icon or counter elements const countEl = button.querySelector(.favorite-count) if (countEl) { const current = parseInt(countEl.textContent, 10) 0 countEl.textContent = favorited ? current 1 : Math.max(0, current - 1) } } // Handler for click events supports event delegation if container specified function handleClick(e) { const btn = e.target.closest e.target.closest(.favorite-button) if (!btn) return e.preventDefault() const postId = btn.getAttribute(data-post-id) if (!postId) return const prevState = btn.classList.contains(favorited) // Optimistic UI update setButtonState(btn, !prevState) btn.classList.add(loading) btn.setAttribute(aria-busy, true) toggleFavorite(postId) .then((data) => { // Server indicates final state const favorited = !!data.favorited setButtonState(btn, favorited) }) .catch((err) => { // Revert UI on error setButtonState(btn, prevState) console.error(Favorite toggle failed, err) // Optionally show a user-visible error message // showToast(Could not update favorites. Please try again.) }) .finally(() => { btn.classList.remove(loading) btn.removeAttribute(aria-busy) }) } // Initialize: attach click listeners to all existing buttons optionally delegate for dynamic content function init(container) { const rootEl = container document rootEl.addEventListener(click, handleClick) } // Prefetch favorites for the current user and set initial state for visible buttons async function prefetchAndMark() { try { const url = {base}/favorites const res = await fetch(url, { headers }) if (!res.ok) return const data = await res.json() const ids = data.map(item => String(item.id)) document.querySelectorAll(.favorite-button[data-post-id]).forEach((btn) => { const pid = btn.getAttribute(data-post-id) setButtonState(btn, ids.indexOf(pid) !== -1) }) } catch (e) { console.warn(Failed to fetch favorites, e) } } // Auto-initialize on DOMContentLoaded document.addEventListener(DOMContentLoaded, function () { init() prefetchAndMark() }) // Expose helpers if needed window.MyFavorites = { toggleFavorite, init, prefetchAndMark, } })()
Place a button or link with class favorite-button and attribute data-post-id. Use an inner element for the count if present.
CSS (basic): visual states
.favorite-button { cursor: pointer border: none background: transparent display: inline-flex align-items: center gap: .5rem } .favorite-button .favorite-icon { color: #999 transition: color .15s ease } .favorite-button.favorited .favorite-icon { color: #ffcc00 } .favorite-button.loading { opacity: .6 pointer-events: none }
UX considerations and advanced patterns
- Optimistic updates — change the UI immediately to feel fast, but revert on error. Provide subtle animation or loading state to communicate an in-progress operation.
- Batching — if many toggles occur quickly (e.g., mobile list scroll), batch network requests or use a debounce queue to reduce server load.
- Edge cases — handle deleted posts (post no longer exists), network failures, and permissions revoked.
- Server-side rendering (SEO) — favorites are per-user and typically client-only. If you need to show counts at build time, query the DB table or use transient caches for performance.
- Analytics — track favorite events if needed, but obey privacy rules and let users opt-out.
- Accessibility — use aria-pressed, aria-busy, and clear labels. Provide keyboard focus styling and ensure the button is reachable via keyboard.
Error handling and monitoring
- Return helpful error codes from the REST API (400 for bad input, 401/403 for auth issues, 404 for not found).
- Log server errors when unexpected exceptions occur (use error logging only for admins avoid logging PII).
- In the JS, show unobtrusive messages for failures and provide retries for transient network errors.
Testing and debugging tips
- Use the REST API console (e.g., WP-CLI or Postman) to exercise endpoints with cookies and the X-WP-Nonce header.
- For cookie-auth: ensure you are sending X-WP-Nonce generated via wp_create_nonce(wp_rest), and that cookies from the site are present (same site origin).
- Inspect network requests and responses in browser dev tools to verify status codes and payloads.
- Write unit tests where possible (use WP_UnitTestCase) and integration tests for the REST routes.
Extending the feature
- Public favorite counts — maintain a post meta counter or aggregate a custom table count for public display. Update counts on favorite add/remove and use atomic queries when using custom tables.
- User collections — allow users to group favorites into named lists by creating a CPT or taxonomy, or extend the custom table with collection_id.
- Social features — show friends who liked or most-favorited lists by querying the custom table with joins and aggregates.
- Rate limiting abuse prevention — track rapid toggles per user/IP and enforce throttling.
Summary of endpoints (quick reference)
- GET /wp-json/myfavorites/v1/favorites — returns current users favorites (array of {id, title, link})
- POST /wp-json/myfavorites/v1/favorites — body: {post_id} — adds a favorite
- DELETE /wp-json/myfavorites/v1/favorites/{post_id} — removes the favorite
- POST /wp-json/myfavorites/v1/favorites/toggle — body: {post_id} — toggles favorite returns {favorited: truefalse}
Complete checklist before shipping
- All inputs sanitized and validated.
- Appropriate permission callbacks implemented.
- Client sends X-WP-Nonce and uses same-origin cookies or proper auth.
- Optimistic UI with fallback on error implemented.
- Edge cases (deleted posts, concurrent updates) tested.
- Performance tested: usermeta vs custom table choice validated against expected load.
- Accessibility validated (aria attributes, keyboard).
- Monitoring/logging for server errors configured.
Example: Troubleshooting common issues
- 403 on requests — check that the X-WP-Nonce header is set and that wp_create_nonce(wp_rest) was used. Ensure cookies are sent.
- Favorites not persisting — verify update_user_meta calls succeed and that you are using the correct meta key across code.
- Counts mismatch — if using caching/transients, ensure invalidation on add/remove when using usermeta, counts require queries across users and are expensive — prefer custom table with indexed post_id.
Final implementation notes
This tutorial supplied a practical, production-minded approach to creating REST endpoints and a JavaScript UI for user favorites in WordPress. Start with the usermeta approach for simplicity and migrate to a custom table as scale demands. Ensure security via permission callbacks and nonces, and make the front-end robust with optimistic updates and graceful error handling.
|
Acepto donaciones de BAT's mediante el navegador Brave 🙂 |