@qewordly/react
v0.7.0
Published
Headless-first React SDK for Qewordly blogs. Server Components by default, isolated client islands for engagement.
Readme
@qewordly/react
Headless-first blog SDK. Data functions run on the server (RSC, SSG, Route Handlers); components render to static HTML with zero client JS.
5-minute integration (Next.js App Router)
// lib/qw.ts
import { createQewordlyClient } from "@qewordly/react";
export const API_URL = process.env.QEWORLDLY_API_URL!; // e.g. https://api.qewordly.com
export const qw = createQewordlyClient({
baseUrl: API_URL,
apiKey: process.env.QEWORLDLY_API_KEY!, // qw_live_… - server env only
});// app/blog/[slug]/page.tsx
import { QewordlyPost, QewordlyJsonLd, getPostJsonLd, getPostMetadata } from "@qewordly/react";
import { qw, API_URL } from "@/lib/qw";
import { notFound } from "next/navigation";
// imageBaseUrl is the API origin: relative /uploads/… cover paths resolve
// against it. Without it they render against the site host and break.
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const post = await qw.getPost((await params).slug);
if (!post) return {};
const meta = getPostMetadata(post, { imageBaseUrl: API_URL });
return { title: meta.title, description: meta.description };
}
export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
const post = await qw.getPost((await params).slug, { revalidate: 60 });
if (!post) notFound();
const url = `https://yoursite.com/blog/${post.slug}`;
return (
<>
{/* shareUrl renders the share row (X/LinkedIn/Facebook/email intents).
Omit it and no row renders - e.g. unpublished previews. */}
<QewordlyPost post={post} imageBaseUrl={API_URL} shareUrl={url} />
<QewordlyJsonLd data={getPostJsonLd(post, { url, imageBaseUrl: API_URL })} />
</>
);
}// app/sitemap.ts
import { qw } from "@/lib/qw";
export default async function sitemap() {
const entries = await qw.getSitemapEntries();
return entries.map((e) => ({
url: `https://yoursite.com/blog/${e.slug}`,
lastModified: new Date(e.updatedAt),
}));
}Rules
- Keys stay server-side - except publishable keys. Never prefix
qw_live_withNEXT_PUBLIC_, never fetch reads from the browser. There are no client hooks in the read client - that is deliberate, not missing. The one exception isQewordlyActionBar(reactions/share menu,"use client"): it runs on aqw_pub_publishable key, which is embed-safe by design (tenant + engage scope only, revocable) - see Engagement below. - Theme:
qw.getTheme()+<QewordlyStyles theme={theme} />injects--qw-*variables.QewordlyPost/QewordlyHeadership a designed default (title block, meta, typography) skinned by those variables - override viaclassName(.qw-post,.qw-post-body,.qw-header,.qw-nav-link,.qw-cta,.qw-actionbar,.qw-share,.qw-share-menuhooks) orshowHeader={false}/renderNavfor full control. - Preview: issue a token (
POST /workspace/preview-token), enable Next draft mode, passpreviewTokento any read. Drafts resolve; archived never does. - Caching: pass
revalidate/tagsto map onto Next fetch semantics. Publish webhooks (US-094) will call yourrevalidateTagendpoint for instant updates. - Eject anytime: every component is a thin wrapper -
qw.getPosts()returns plain data you can render however you like.
Engagement (reactions + share menu)
// Server component: render the bar with the post. Reactions activate when
// `engagement` carries your publishable key (NEXT_PUBLIC_QEWORLDLY_PUBLIC_KEY
// is safe - qw_pub_ keys only resolve to tenant + engage scope).
import { QewordlyActionBar } from "@qewordly/react";
<QewordlyActionBar
slug={post.slug}
shareUrl={url}
engagement={{ baseUrl: API_URL, publicKey: process.env.NEXT_PUBLIC_QEWORLDLY_PUBLIC_KEY! }}
/>;A post renders two bars (header + footer). They stay in sync through
QewordlyEngagementProvider — QewordlyPost wraps itself automatically;
hosts composing bars manually should wrap once per post. A standalone bar
outside any provider owns its own state (same behavior, one extra fetch).
// Or drive your own UI (framework-free, works anywhere fetch exists):
import { createQewordlyEngagement } from "@qewordly/react";
const engage = createQewordlyEngagement({ baseUrl: API_URL, publicKey: process.env.NEXT_PUBLIC_QEWORLDLY_PUBLIC_KEY! });
await engage.getReactions(slug); // { slug, count, reacted }
await engage.setReaction(slug, true); // explicit state - idempotent, retries converge- Issue the key in dashboard Settings → API Keys → Publishable (shown once, like all keys).
- Omit
engagementand the reaction button renders disabled - share menu and comments are unaffected. Draft previews never show live counts.
Responses (comments)
// Server component: approve-only thread (never leaks pending rows).
import { QewordlyCommentList, QewordlyCommentForm } from "@qewordly/react";
const comments = await createQewordlyComments({
baseUrl: API_URL,
publicKey: process.env.NEXT_PUBLIC_QEWORLDLY_PUBLIC_KEY!,
}).getComments(post.slug);
<QewordlyCommentList comments={comments} />
<QewordlyCommentForm
slug={post.slug}
engagement={{ baseUrl: API_URL, publicKey: process.env.NEXT_PUBLIC_QEWORLDLY_PUBLIC_KEY! }}
/>;Or let the post render the whole thread: pass comments (+ commentEngagement for the form) to QewordlyPost - the bar count derives from the thread automatically.
- Submits always land pending; the form says so honestly. No emails, no accounts.
- Bodies render as plain text - guest HTML can never execute.
- The
websitehoneypot is server-enforced; bots get a fake success.
