logicspike/docs

Engagement

Engagement — Embedding

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.

Engagement