A small, accessible like button for classic WordPress themes — vanilla
fetch, the WordPress AJAX API, and a like count in post meta. No plugin, no
jQuery, no build step.
It is a small "did you like this?" signal, not a hard statistic: duplicate
likes are kept down client-side via localStorage, while a nonce protects the
endpoint server-side. The honest limits of that trade-off are spelled out below.
Full walkthrough (German): https://marcelbest.com/webdesign/2026/like-button-fuer-wordpress-ohne-plugin/ The blog post explains the how and why — and the deliberate trade-offs — in detail; this README is the quick start.
- Renders a real
<button>with an SVG icon and a small count badge. - On click, increments
like_countin the post meta overadmin-ajax.php. - Remembers the liked state per browser in
localStorageand restores it on load, so a second click does not count again. - Announces the new count to screen readers via a polite
aria-liveregion, with correct singular/plural. - Caps the visible count at
99+, server- and client-side.
- A classic theme (PHP
functions.php). - PHP 7.4+ (developed against 8.x).
- A browser with
fetchandlocalStorage(all modern browsers).
- Copy the
like-button/folder into your theme, e.g.inc/like-button/(it containslike-button.php,like-button.js,like-button.css). - Require the PHP from your theme's
functions.php:require get_theme_file_path( 'inc/like-button/like-button.php' );
- Output the button anywhere in a single-post template:
echo yourtheme_get_like_button( get_the_ID() );
- Replace the
yourtheme_function prefix, theyourtheme-likenonce action, theyourthemeLikeJS object and theyourthemetext domain with your own namespace.
The assets are enqueued on is_singular() only. Adjust that condition in
yourtheme_like_enqueue() if you want the button elsewhere. If you place the
folder somewhere other than inc/like-button/, point the asset base at it:
add_filter( 'yourtheme_like_asset_base', fn() => get_theme_file_uri( 'assets/like-button' ) );<button type="button" class="btn-like" data-post-id="42"
aria-label="Like this post" aria-pressed="false">
<span class="like-icon" aria-hidden="true"><svg>…</svg></span>
<span class="like-count" aria-hidden="true">7</span>
<span class="like-status visually-hidden" aria-live="polite"></span>
</button>Only the per-post datum (data-post-id) sits on the element. The endpoint,
nonce and labels are passed once for the whole page via wp_localize_script.
| Filter | Default | Purpose |
|---|---|---|
yourtheme_like_meta_key |
like_count |
Post meta key for the count |
yourtheme_like_asset_base |
inc/like-button URI |
Base URL for the JS/CSS |
yourtheme_like_button_html |
generated markup | Final escape hatch to alter the button HTML |
Labels are translatable through the yourtheme text domain — no filter needed.
Plain CSS, no framework. Set an accent colour with a custom property:
.btn-like { --like-accent: #e2533b; }The accent drives the hover, focus and liked states. The visible .like-count
badge is decorative (aria-hidden); the real announcement lives in the
screen-reader-only .like-status region.
- Real
<button type="button">, not a clickable<div>/<a>. aria-labeldescribes the action; the icon isaria-hidden.aria-pressedreflects the liked state (set client-side, since it only lives inlocalStorage).- The new count is announced once, as a full sentence, via a polite live region — only on a fresh like, never on page load, so screen readers are not spammed.
- Visible focus state via the accent colour.
Note on
aria-pressed: the button is one-way (you cannot un-like), whilearia-pressedstrictly implies a toggle. This is a known, accepted edge case; the pressed state is still the most useful signal here.
With a full-page-cache plugin, the nonce baked into the cached HTML can age
out — then wp_verify_nonce() fails and the like is rejected. Two ways out:
- Exclude the button region from the cache (fragment / hole-punching), or
- fetch a fresh nonce from an uncached request before sending the like.
Without an HTML cache (or with a plain object cache) this is a non-issue.
Either way the rejection is no longer silent: a refused like announces
labelError in the live region, so the visitor is not left wondering whether
the click registered.
- No server-side duplicate protection. The lock lives in
localStorage— clearing it, or using another browser, allows another like. Accepted for a light signal; a hard count would need IP/user-based throttling server-side. - The nonce is weaker for logged-out visitors. A WordPress nonce binds to a user session; anonymous visitors barely have one, so the nonce acts more like a time-limited token (~24 h) than full CSRF protection. Fine for this feature, but worth knowing before treating it as airtight.
- Race conditions on
update_post_metaare theoretically possible under simultaneous likes; negligible for ordinary traffic.
One of several starting points was Amr AbdElkarem's tutorial, Add an AJAX like button to WordPress without a plugin. This version is a ground-up rewrite that adds escaping, input validation, accessibility, i18n and a localStorage-based lock instead of a cookie.
GPL-2.0-or-later. See LICENSE.