Warning
Warning: This library is alpha. It is developed for and tested alongside ActivistChecklist.org. It has not been validated on other deployments yet. APIs, environment contracts, and Mongo layout may change. It may work just fine for you. We'd love help testing it.
Think of this as Google Docs comments for your react site. It is meant to be easy to drop in so reviewers can leave comments on the text of a page.
Many tools put a "dot" on the page for markup-style comments. Those fit poorly when the layout moves (for example accordion sections). This package uses text-based highlighting and comments instead.
It was designed for deploy preview branches (Vercel, Railway, and similar) and may not suit a production site.
# Pick your package manager
npm install @activistchecklist/react-review-comments
yarn add @activistchecklist/react-review-comments
pnpm add @activistchecklist/react-review-commentsPeers: react, react-dom, next (App Router recommended), @annotorious/react, @recogito/react-text-annotator, lucide-react. mongodb is bundled as a regular dependency — you do not need to install it separately.
The package ships prebuilt ESM + type declarations.
1. Client: provider
In a Next.js layout, add ReviewCommentsProvider. It includes the shell and styles; path, locale, and scope are optional and inferred from the URL and window.location.host when omitted. enabled defaults to true when omitted.
import { ReviewCommentsProvider } from '@activistchecklist/react-review-comments';
export default async function Layout({ children }) {
const enabled = true;
return (
<ReviewCommentsProvider enabled={enabled}>
{children}
</ReviewCommentsProvider>
);
}2. Next.js App Router: one API route. Add a catch-all and forward to the package handler.
Add this to app/api/review-comments/[[...path]]/route.ts (or route.js if you're not using TypeScript):
import {
handleReviewCommentsRequest,
type ReviewCommentsRouteContext,
} from '@activistchecklist/react-review-comments/server';
import { getReviewCommentsConfig } from '@/lib/review-comments-env';
export const dynamic = 'force-dynamic';
const handlerOptions = {
getReviewCommentsRuntimeConfig: getReviewCommentsConfig,
};
function handler(request: Request, context: ReviewCommentsRouteContext) {
return handleReviewCommentsRequest(request, context, handlerOptions);
}
export const GET = handler;
export const POST = handler;
export const PATCH = handler;
export const DELETE = handler;2a. Create getReviewCommentsConfig
Create a small helper in your app, for example lib/review-comments-env.ts:
import { type ReviewCommentsRuntimeConfig } from '@activistchecklist/react-review-comments/server';
function isTrue(value: string | undefined): boolean {
return ['1', 'true', 'yes', 'on'].includes((value || '').toLowerCase());
}
export function getReviewCommentsConfig(env: NodeJS.ProcessEnv = process.env): ReviewCommentsRuntimeConfig {
return {
enabled: isTrue(env.REVIEW_COMMENTS_ENABLED),
publicReadWrite: isTrue(env.REVIEW_COMMENTS_PUBLIC_WRITE || 'true'),
};
}If your route is JavaScript, use lib/review-comments-env.js:
function isTrue(value) {
return ['1', 'true', 'yes', 'on'].includes(String(value || '').toLowerCase());
}
export function getReviewCommentsConfig(env = process.env) {
return {
enabled: isTrue(env.REVIEW_COMMENTS_ENABLED),
publicReadWrite: isTrue(env.REVIEW_COMMENTS_PUBLIC_WRITE || 'true'),
};
}Then import it in your route:
import { getReviewCommentsConfig } from '@/lib/review-comments-env';3. Environment variables
Required for the API:
REVIEW_COMMENTS_ENABLED:trueREVIEW_COMMENTS_MONGODB_URL: connection string. If the URL has no database path segment, the database name defaults toreview_comments.
Optional: REVIEW_COMMENTS_PUBLIC_WRITE (see Environment and security).
Preview vs production is only the hostname in the browser; no extra env vars for that.
The stock handler does not authenticate users. Who may call the API is up to your route or middleware: call handleReviewCommentsRequest only after your checks pass. getReviewCommentsRuntimeConfig is for feature flags (and similar) from process.env, not per-request auth.
UI: enable the shell when your auth says the user is logged in.
import { auth } from '@/auth'; // e.g. Auth.js / your session helper
export default async function GuideLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
return (
<ReviewCommentsProvider
enabled={Boolean(session)}
path={/* … */}
locale={/* … */}
scope={/* … */}
>
{children}
</ReviewCommentsProvider>
);
}API: reject anonymous requests before the stock handler. The client uses credentials: 'same-origin', so a session cookie is sent automatically when your session is cookie-based.
import { auth } from '@/auth';
import { handleReviewCommentsRequest, type ReviewCommentsRouteContext } from '@activistchecklist/react-review-comments/server';
import { getReviewCommentsConfig } from '@/lib/review-comments-env';
const handlerOptions = { getReviewCommentsRuntimeConfig: getReviewCommentsConfig };
async function gated(request: Request, context: ReviewCommentsRouteContext) {
const session = await auth();
if (!session) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
return handleReviewCommentsRequest(request, context, handlerOptions);
}
export const GET = gated;
export const POST = gated;
export const PATCH = gated;
export const DELETE = gated;Keep REVIEW_COMMENTS_ENABLED and Mongo env as a global kill switch; getReviewCommentsConfig can still return enabled: false when the feature is off entirely.
UI: validate the query on the server and pass enabled from that (do not expose the secret to the client as a prop).
export default async function Page({
children,
searchParams,
}: {
children: React.ReactNode;
searchParams: Promise<{ rrc?: string }>;
}) {
const sp = await searchParams;
const enabled = sp.rrc === process.env.RRC_REVIEW_SECRET;
return (
<ReviewCommentsProvider enabled={enabled} path={/* … */} locale={/* … */} scope={/* … */}>
{children}
</ReviewCommentsProvider>
);
}API: the default client does not append arbitrary query params to every request. Either:
- Set a short-lived cookie (e.g. in middleware) when
?rrc=is valid, and in the route handler require that cookie before callinghandleReviewCommentsRequest, or - Use a thin wrapper around
createReviewCommentsApi/ customfetchthat adds a header (e.g.X-Rrc-Preview: …) that your route compares toprocess.env.RRC_REVIEW_SECRET.
async function gatedBySecret(request: Request, context: ReviewCommentsRouteContext) {
const secret = request.headers.get('x-rrc-preview');
if (secret !== process.env.RRC_REVIEW_SECRET) {
return Response.json({ error: 'Forbidden' }, { status: 403 });
}
return handleReviewCommentsRequest(request, context, {
getReviewCommentsRuntimeConfig: getReviewCommentsConfig,
});
}Treat shared secrets like passwords: rotate them, prefer HTTPS, and do not log them.
These apply to ReviewCommentsProvider (and to ReviewCommentsContextProvider if you wire the shell yourself).
apiBase: base URL for fetches (no trailing slash), e.g./api/review-comments. Defaults to/api/review-comments.enabled: whenfalse, the shell renderschildrenonly (no panel, no listeners). Defaults totruewhen omitted.panelMode:'docked'(default) pins the panel to the side of the page;'floating'renders it as a floating overlay.path: stable document id for this page (e.g./guide/foo/with a trailing slash if your site uses one).locale: short locale string, e.g.en,es.scope:{ scopeKey: string }fromreviewCommentsScopeFromHostHeader(layouts / client) orreviewCommentsScopeFromRequest(route handlers). This ties client-side state (for example seen threads inlocalStorage) to the HTTP host. The API reads the same host from each request and does not accept a separate scope in query or body.labels: a partial object merged onto English defaults (button labels, errors, panel chrome). Seesrc/defaultLabels.tsor the exporteddefaultReviewCommentsLabelsfor the full list of keys.
The system auto-generates a random two-word handle (e.g. CalmPine) and stores it in sessionStorage.
labels: a partial object merged onto English defaults (button labels, errors, panel chrome). Import defaultReviewCommentsLabels from the package to reference the full set of defaults before overriding.
import { defaultReviewCommentsLabels } from '@activistchecklist/react-review-comments';
<ReviewCommentsProvider
labels={{
...defaultReviewCommentsLabels,
threadPanelTitle: 'Feedback',
addComment: 'Leave feedback',
}}
>Use this only with Next.js when you import this package from node_modules and Next must compile its TypeScript (and TSX) for the app build. If your bundler already consumes a precompiled JS build of the package, you do not need it.
// next.config.js
module.exports = {
transpilePackages: ['@activistchecklist/react-review-comments'],
};Types are exported from the package entry for the client API, provider props, threads, and labels.
Use ReviewCommentsContextProvider when you need a custom shell or split imports. Wrap the main article body with the context provider and ReviewCommentsShell. Pass scope from reviewCommentsScopeFromHostHeader (or window.location.host in a client-only tree) so it matches what the API will use.
'use client';
import {
ReviewCommentsContextProvider,
ReviewCommentsShell,
} from '@activistchecklist/react-review-comments';
import '@activistchecklist/react-review-comments/styles.css';
export function CommentsWrapper({ enabled, path, locale, scope, children }) {
return (
<ReviewCommentsContextProvider
apiBase="/api/review-comments"
enabled={enabled}
path={path}
locale={locale}
scope={scope}
>
<ReviewCommentsShell>{children}</ReviewCommentsShell>
</ReviewCommentsContextProvider>
);
}useReviewComments() returns the full context value (api, apiBase, enabled, panelMode, path, locale, scope, labels). Must be called inside a ReviewCommentsContextProvider (or ReviewCommentsProvider). Useful when building custom shells or reading context state in child components.
Pass getReviewCommentsRuntimeConfig only for feature flags (enabled, publicReadWrite). Document scope in Mongo always comes from the request Host (see src/scopeFromHost.ts). If you omit it, the handler uses getReviewCommentsRuntimeConfigFromEnv in server/env.ts.
Optional:
REVIEW_COMMENTS_PUBLIC_WRITE: defaults totrueif unset (anonymous write for PR-style review). When set tofalse, the stock handler returns 403 for POST, PATCH, and DELETE; GET routes (list threads, overview) still work for read-only embeds.REVIEW_COMMENTS_CLEANUP_DAYS: used by maintenance scripts only (for exampleyarn annotations:cleanup), not required for normal operation.BUILD_MODE=static: when set tostatic, the stock handler'sisReviewCommentsEnabledreturnsfalseregardless ofREVIEW_COMMENTS_ENABLED. Useful for static export builds where the API route should never activate.
Collections use the rrc_* prefix (see server/collections.ts).
The handler includes in-memory, per-IP rate limiting on all routes. Limits reset on server restart (not suitable as a hard security boundary — pair with infrastructure-level rate limiting for that). Current limits per 60-second window:
| Action | Limit |
|---|---|
| List threads (GET) | 120 |
| Overview (GET) | 60 |
| Create thread (POST) | 20 |
| Update thread status (PATCH) | 60 |
| Create comment (POST) | 40 |
| Update comment (PATCH) | 60 |
| Delete comment (DELETE) | 40 |
Exceeded requests receive HTTP 429 with a Retry-After header.
Security notes
- No stored HTML: comment bodies and quotes are plain text; the UI renders them as React text nodes (no
dangerouslySetInnerHTMLin the stock shell). - MongoDB: filters use fixed field names and string parameters. Client-supplied
anchorSelectoris sanitized (no$keys, no__proto__/constructorpaths, bounded depth and size) before insert. - IDs: thread and comment ids in URL segments and JSON bodies must match a normal UUID shape before updates or deletes.
- Trust model: there is no authentication in the stock handler; scope is derived from Host / X-Forwarded-Host. Treat this as suitable for low-risk, same-site review comments, not for sensitive workflows without your own auth layer.
If you use output: 'export', do not ship the API route or live comments UI: tree-shake or replace the shell and stub the API with your build, as you would for any dynamic backend.
createReviewCommentsApi(apiBase) returns methods used by the shell: fetchThreads, fetchOverview, createThread, createComment, patchThreadStatus, patchComment, deleteComment. You can reuse these if you build a custom layout.
src/– React UI, highlight helpers, client API (ReviewCommentsProvider,ReviewCommentsContextProvider, shell,scopeFromHost.ts).src/index.ts– main client exports: provider, shell,ReviewCommentsPanel,useReviewComments,createReviewCommentsApi,defaultReviewCommentsLabels, scope helpers, and all public types.server/handler.ts–handleReviewCommentsRequestfor Next.js.server/collections.ts– Mongo collection names (rrc_*).server/db.ts– exported as@activistchecklist/react-review-comments/server/db; low-level Mongo connection helper if you need direct access.shared/sanitize.ts– shared normalization for quotes, anchor metadata, and UUID validation.src/highlightDom.ts– exported as@activistchecklist/react-review-comments/highlightDom; DOM highlight utilities if you need to drive highlighting outside the shell.src/rrc.css– scoped panel and thread styles (rrc-*).
GPL-3.0. See LICENSE in this package.