Likes

A generic “like” feature for content. Logged-in users can like a content object once and withdraw their like again; everybody sees the resulting count. Likes are not tied to a specific content type — the feature is switched on per type through a behavior, typically on News.

Enabling the feature

The behavior is called Likeable, dotted name wcs.backend.likes.behavior.ILikeable. It is deliberately not enabled on any content type out of the box.

To switch it on, go to Site Setup → Content Types → type → Behaviors and tick Likeable. The change takes effect immediately and applies to existing content of that type as well — no reindex, no upgrade step, no migration.

Turning the behavior off again hides the feature everywhere (viewlet and REST endpoint), but does not delete the likes that were already cast. Re-enabling it brings the previous counts back.

Who may do what

Sees the count

Likes / unlikes

Sees who liked

Anonymous visitor

yes, through the REST API

no

no

Logged-in user

yes

yes

no

Site Administrator / Manager

yes

yes

yes

Each user has at most one like per object. Liking twice is an error, and so is withdrawing a like that was never cast — a client never has to guess at the current state, because every response says whether the calling user has liked.

Seeing who liked requires the permission titled wcs.backend Likes: Inspect likes, granted to Manager and Site Administrator. It can be handed to other roles the usual way through Site Setup → Security if needed.

Where likes live

Likes are stored on the content object itself, as a mapping of user id to the time of the like. Two consequences follow from that:

  • Copies start empty. Duplicating a content object does not carry the original’s likes over; the copy starts at zero. The same holds for the working copy created by staging.

  • Likes survive staging. Because a check-in writes the working copy’s field values back onto the baseline rather than replacing the object, the baseline’s likes stay untouched through a checkout/check-in cycle. Likes cast while a working copy exists land on the baseline and are still there afterwards.

Classic UI

A viewlet below the content body shows the current count together with a Like / Unlike button. The button toggles without reloading the page. The backend never serves pages to anonymous visitors, so the viewlet always renders for a known user; anonymous consumers read the count through the REST API instead.

Users holding the inspect permission additionally get a Who liked this? link next to the button. It leads to an inspection page listing every user who liked the content — full name and the date they liked, newest first.

REST API

The @likes endpoint is available on every content object whose type has the behavior enabled. On all other content it answers 404, which is also the quickest way for a client to find out whether the feature is on for a given type.

GET @likes

GET /Plone/news/my-item/@likes HTTP/1.1
Host: localhost:8080
Accept: application/json
{
    "@id": "http://localhost:8080/Plone/news/my-item/@likes",
    "total": 5,
    "can_like": true,
    "user_liked": false
}

can_like is false for anonymous callers — use it to decide whether to render a button at all. user_liked says whether the calling user has already liked, and therefore whether the button should trigger a POST or a DELETE.

Callers holding the inspect permission get an additional users array, newest like first:

{
    "@id": "http://localhost:8080/Plone/news/my-item/@likes",
    "total": 5,
    "can_like": true,
    "user_liked": false,
    "users": [
        {
            "userid": "jane.doe",
            "fullname": "Jane Doe",
            "liked_at": "2026-09-22T09:12:03+00:00"
        }
    ]
}

The key is absent for everybody else — do not treat a missing users as an empty list.

POST @likes

Likes the content as the current user. Takes no body and returns the same payload as GET, already updated, so no follow-up request is needed.

DELETE @likes

Withdraws the current user’s like. Returns the same updated payload.

Expanding likes into a content request

Likeable content advertises the endpoint in its @components, so a plain content request already tells a client that the feature is switched on:

{
    "@id": "http://localhost:8080/Plone/news/my-item",
    "@components": {
        "likes": {"@id": "http://localhost:8080/Plone/news/my-item/@likes"}
    }
}

Add ?expand=likes to pull the whole payload into that same response and save a round trip:

GET /Plone/news/my-item?expand=likes HTTP/1.1
Accept: application/json
{
    "@id": "http://localhost:8080/Plone/news/my-item",
    "@components": {
        "likes": {
            "@id": "http://localhost:8080/Plone/news/my-item/@likes",
            "total": 5,
            "can_like": true,
            "user_liked": false
        }
    }
}

The expanded value is byte-for-byte what GET @likes returns, users array included when the caller may inspect. Expansion combines with the other components, e.g. ?expand=likes,breadcrumbs. Content whose type does not have the behavior enabled has no likes entry in @components at all.

CSRF

Both writing verbs require a CSRF token, whatever the authentication method — cookie, Basic auth or JWT alike. Send it as an X-CSRF-TOKEN header; the token is available from the @@authenticator/token view on any context. A write without a valid token answers 403.

The token is bound to the user it was minted for, so fetch it with the same credentials used for the write.

Errors

Status

Cause

400

Liking twice, or withdrawing a like that does not exist

401

Not logged in

403

Missing or invalid CSRF token

404

The behavior is not enabled on this content type

Integration

Like and unlike from JavaScript

const itemUrl = '/Plone/news/my-item';

async function csrfToken() {
    const response = await fetch(`${itemUrl}/@@authenticator/token`);
    return (await response.text()).trim();
}

async function vote(method, token) {
    const response = await fetch(`${itemUrl}/@likes`, {
        method: method,
        headers: {
            'Accept': 'application/json',
            'X-CSRF-TOKEN': token,
        },
    });
    if (!response.ok) {
        throw new Error(`Vote failed with status ${response.status}`);
    }
    return response.json();
}

// Read the item and its likes in one request, then toggle.
const item = await (await fetch(`${itemUrl}?expand=likes`, {
    headers: { 'Accept': 'application/json' },
})).json();
const state = item['@components'].likes;

if (state.can_like) {
    const token = await csrfToken();
    const updated = await vote(state.user_liked ? 'DELETE' : 'POST', token);
    console.log(updated.total, updated.user_liked);
}

The response of the POST / DELETE is the new state — render it directly instead of re-fetching.

Read the count from Python

import requests

response = requests.get(
    'http://localhost:8080/Plone/news/my-item/@likes',
    headers={'Accept': 'application/json'},
)
data = response.json()
print(data['total'])        # 5
print(data['user_liked'])   # False

Authenticate the request to get a meaningful user_liked; an unauthenticated call always reports can_like: false and user_liked: false.