Last Updated: 2026-09-05 · Status: Active
Three ways to put engagement on a site. All three talk to the same public door, and all three need only a site_id — never an API key.
| Use when | |
|---|---|
React SDK (@vlozi/blog/engage) |
the site is React and you want components |
Server components (@vlozi/blog/server) |
the page is ISR/static and the discussion should be in the HTML |
embed.js |
the site isn't React, or you want one script tag |
React SDK
import { EngagementProvider, EngagementBeacon, ViewCount, Comments, postTarget } from "@vlozi/blog/engage";
<EngagementProvider config={{ siteId: "site_abc123" }}>
<EngagementBeacon target={postTarget(post)} />
<ViewCount target={postTarget(post)} />
<Comments target={postTarget(post)} />
</EngagementProvider>The island is independent of the rest of the SDK — no VloziClient, no API key, importable on its own in a plain Next/Vite/CRA app. It has an 8 KB brotli budget enforced by pnpm size, and a build test that fails if anything from src/react/* gets inlined into it.
| Export | |
|---|---|
<EngagementBeacon> |
fires view on mount, read once on dwell + scroll depth |
<ViewCount> |
the count, with an optimistic +1 for the reader's own visit |
<Reactions> <StarRating> <Helpful> |
reaction widgets |
<Comments> |
full thread: list, form, honeypot, Turnstile, reply, flag, edit |
<SignInWithEmail> |
passwordless reader sign-in |
<ReadingNow> |
live concurrent readers |
useEngagementSummary useReaction useComments useMember useTrendingPosts useEngageSiteConfig |
the hooks behind them |
postTarget(post) |
post:<id>, falling back to the slug |
engagementJsonLd productRatingJsonLd |
structured data — see below |
Batching. Every <ViewCount> mounted in one React commit shares a single GET /summary?t=a,b,c. A twelve-post index page costs one request, not twelve.
Server components
import { ServerComments } from "@vlozi/blog/server";
<ServerComments siteId="site_abc123" target={`post:${post.id}`} />Read-only, and deliberately so: posting needs a rate limiter, an origin check and a honeypot that a server render cannot provide. Pair it with the client <Comments> when you want a form, or leave it read-only for an archive page.
The reason to use it at all: on a statically built page, comments that arrive only via client fetch are invisible to crawlers and absent until JavaScript runs. For a post whose discussion is half its value, that's the difference between being indexed with that content and without it.
embed.js
<article data-vlozi-target="post:post_abc123">
<h1>…</h1>
<span data-vlozi-views>—</span>
<div data-vlozi-reactions="like"></div>
<div data-vlozi-comments></div>
</article>
<script src="https://api.vlozi.app/engagement/e/site_abc123/embed.js" defer></script>data-vlozi-target on a container; nested elements opt in by attribute. data-vlozi-comments="readonly" for a list with no form. Every target on the page is batched into one /summary call, same as the SDK.
For single-page apps, call window.VloziEngage.mount() after a route change (or mount(container) for one subtree).
Collections entries
Collections' own embed sets data-vlozi-target="entry:<id>" on every card automatically. Add data-vlozi-engage="reviews|helpful|all" to the mount container to get engagement widgets inside each card:
<div data-vlozi-collection="col_abc" data-vlozi-engage="reviews"></div>
<script src="https://api.vlozi.app/collections/c/embed.js" defer></script>
<script src="https://api.vlozi.app/engagement/e/site_abc/embed.js" defer></script>Off by default — a collection of office locations has no business asking for star ratings — and inert unless engagement's script is also on the page.
Getting a site id
Dashboard → Engagement → Sites. A "Production" site is created on first visit. Add your domains to the origin allowlist before going live: writes from an unlisted origin are rejected.
The site id is not a secret. It is in every visitor's page source by design. What protects the door is the origin allowlist, rate limits, bot filtering and (optionally) Turnstile — see architecture.md. Rotating a site id invalidates every existing snippet, which is the point of the button.
Structured data
// An article: counts only.
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify({
"@type": "BlogPosting", ...articleFields, ...engagementJsonLd(post, summary),
}) }} />engagementJsonLd never emits aggregateRating, and that refusal is deliberate. Google treats AggregateRating on your own Article as self-serving review markup: it's ignored, and in repeated cases draws a manual action. Ratings are surfaced as plain interactionStatistic counts instead, which crawlers can use or ignore without the policy risk.
productRatingJsonLd exists for the case that isn't self-serving — a Product-shaped collection entry rated by its buyers:
{ "@type": "Product", ...productFields, ...productRatingJsonLd(summary) }It returns null below three ratings rather than emitting ratingCount: 0, and it's only correct on a Product/Service/LocalBusiness node where the ratings genuinely came from visitors. No library can check that second condition for you.
What a reader's browser ends up holding
| When | |
|---|---|
| nothing | they only read |
vlz_vid |
first reaction or rating |
vlz_session:<siteId> |
they sign in |
No cookies at any point. The member session lives in localStorage and is only ever sent in bodies the SDK constructs, so it cannot be used for CSRF. Sessions are namespaced per site, so one browser reading two Vlozi-powered blogs keeps two separate identities.