@vlozi/blog
v2.2.0
Published
Official blog SDK for Vlozi — headless client and pre-built React components for posts, categories, and tags.
Downloads
637
Maintainers
Readme
@vlozi/blog
Official blog SDK for Vlozi — a headless API client plus pre-built React components, React Server Components, and Next.js helpers for rendering blog posts, categories, and tags.
- Zero-dependency headless client with retries, timeouts, caching, deduplication, and a typed error hierarchy.
- A React entry covering every blog UI pattern — list, infinite scroll, detail, archive, category/tag nav, related posts, prev/next, plus rich-content blocks (carousel, mermaid, YouTube).
- Full Next.js support — App Router metadata helpers, static params, and React Server Components that ship no client JS by default.
- Live content — an already-built site can refresh itself when you edit a post, instead of showing whatever existed at build time. Opt-in, one line.
- Accessibility-first — ARIA roles,
aria-busy, live regions,prefers-reduced-motion, article labelling, carouselaria-roledescription. - Rich-content hydration — interactive carousel, live mermaid diagrams, YouTube embeds. Progressive enhancement: SEO crawlers and no-JS clients still see the full content.
- RSS + sitemap generators for SEO.
- Engagement — view counts, reactions, ratings and comments via
@vlozi/blog/engage, an island independent of everything above. - Extensive test coverage in vitest, plus a Storybook catalog with interaction tests.
Bundle sizes are enforced per entry by
size-limitand asserted in CI; runpnpm sizefor the current figures. They are deliberately not written out here — a number in a README drifts on the first change that moves it, and a stale one is worse than none.
Installation
npm install @vlozi/blog
# or
pnpm add @vlozi/blogQuick start
Headless client (no React)
import { VloziClient } from "@vlozi/blog";
const client = new VloziClient({
apiKey: process.env.VLOZI_PUBLIC_KEY!, // pk_*
baseUrl: "https://api.vlozi.app",
});
const { data, meta } = await client.blog.list({ page: 1, limit: 10 });
const post = await client.blog.get("my-post-slug");
const { data: categories } = await client.blog.categories.list();
const { data: tags } = await client.blog.tags.list();React components
import { VloziClient } from "@vlozi/blog";
import { VloziProvider, BlogList, BlogPost } from "@vlozi/blog/react";
import "@vlozi/blog/styles.css";
const client = new VloziClient({
apiKey: process.env.NEXT_PUBLIC_VLOZI_KEY!,
baseUrl: "https://api.vlozi.app",
});
export function Layout({ children }: { children: React.ReactNode }) {
return <VloziProvider client={client}>{children}</VloziProvider>;
}
export function BlogIndex() {
return <BlogList columns={3} limit={9} searchable sortable />;
}
export function PostPage({ slug }: { slug: string }) {
return <BlogPost slug={slug} />;
}Entry points
| Import | Contents |
|---|---|
| @vlozi/blog | Headless client, types, error classes, cache adapters, RSS/sitemap generators |
| @vlozi/blog/react | VloziProvider, hooks, client components, Slot type |
| @vlozi/blog/server | React Server Components (async; no client JS unless live is enabled) |
| @vlozi/blog/next | Next.js App Router helpers (metadata, static params, webhook receiver) |
| @vlozi/blog/live | Client boundary for live content refresh — loaded for you, not imported directly |
| @vlozi/blog/styles.css | Default prose styles targeting .vlz-content |
Components
| Component | Description |
|---|---|
| <BlogList> | Paginated post grid/list with search, sort, multi-filter, scroll restoration, hover prefetch |
| <BlogInfiniteList> | IntersectionObserver-based infinite scroll variant of BlogList |
| <BlogPost> | Full post view — featured image, header, sanitized HTML body via <BlogContent>, article labelling |
| <BlogContent> | Renders sanitized post HTML and progressively hydrates [data-type="carousel"] and pre code.language-mermaid blocks |
| <Carousel> | Embla-powered image carousel with arrows, dots, drag-to-swipe — used by <BlogContent> and exported standalone |
| <MermaidBlock> | Lazy-loaded Mermaid diagram renderer with theme-aware output and graceful fallback |
| <BlogCard> | Individual post card (default / featured / compact variants) |
| <BlogCategoryNav> | Category navigation (sidebar / tabs / pills variants), ARIA-aware |
| <BlogTagNav> | Tag navigation (pills / cloud variants), ARIA-aware |
| <BlogArchive> | All posts grouped by year/month (grouped or flat layout) |
| <RelatedPosts> | Category/tag overlap scoring — fail-silent on errors |
| <PrevNextNav> | Previous/next post navigation at the bottom of a post |
Every component supports a unified slot API: renderLoading, renderError, renderEmpty, renderItem, etc. accept either a ReactNode or a function that receives contextual args (the post, the error, etc.).
<BlogList
renderEmpty={<p>No posts yet.</p>}
renderError={(err) => <p>Something broke: {err.message}</p>}
renderItem={(post) => <MyCustomCard post={post} />}
/>Hooks
| Hook | Returns |
|---|---|
| usePosts(params?) | { data, loading, error, refetch, page, totalPages, hasNextPage, hasPrevPage } |
| usePost(slug) | { data, loading, error } |
| useCategories() | { data, loading, error, refetch } |
| useTags() | { data, loading, error, refetch } |
| useArchive({ maxPosts? }) | { data, loading, error, refetch } |
| useRelatedPosts(slug, { limit?, pool? }) | { data, loading, error } |
| useNeighbors(slug, { pool? }) | { data, loading, error } |
| useVlozi() | VloziClient from context (throws if no provider) |
| useOptionalVlozi() | VloziClient | null — for components that work standalone |
All hook return values are stable references across renders — they only change identity when their underlying state changes, so you can put them in dependency lists without triggering spurious re-runs.
Filtering & pagination
const { data } = usePosts({
page: 1,
limit: 10,
category: ["tutorials", "news"], // string | string[]
tag: ["react", "nextjs"], // OR-matched
search: "getting started",
sort: "publishedAt", // "publishedAt" | "title" | "createdAt"
order: "desc", // "asc" | "desc"
});Typed error hierarchy
Every error thrown by the SDK extends VloziError. Catch at whatever specificity you need:
import {
VloziError,
VloziApiError,
VloziAuthError,
VloziRateLimitError,
VloziNetworkError,
VloziTimeoutError,
VloziConfigError,
VloziSecretKeyError,
} from "@vlozi/blog";
try {
await client.blog.get(slug);
} catch (err) {
if (err instanceof VloziAuthError) {
// 401/403 — redirect to login
} else if (err instanceof VloziRateLimitError) {
// 429 — respect err.retryAfter
} else if (err instanceof VloziApiError) {
// Any HTTP error with a response — err.status, err.url, err.requestId
} else if (err instanceof VloziTimeoutError) {
// Exceeded timeoutMs
} else if (err instanceof VloziNetworkError) {
// DNS, offline, CORS, etc.
}
}Error messages are formatted for log aggregators:
Vlozi API Error (500): Server meltdown [GET https://api.vlozi.app/blog/public/posts] (requestId=req_abc123)Caching
VloziClient ships with an in-memory LRU + TTL + stale-while-revalidate cache and request deduplication. You can swap in your own (Redis, Upstash, Cloudflare KV) by implementing CacheAdapter:
import { VloziClient, type CacheAdapter } from "@vlozi/blog";
class RedisCache implements CacheAdapter { /* ... */ }
const client = new VloziClient({
apiKey: "pk_...",
baseUrl: "https://api.vlozi.app",
cache: new RedisCache(),
cacheTtlMs: 60_000,
});Invalidate entries after mutations:
client.invalidate(/\/posts/); // pattern-based
client.mutate("GET …/my-slug", post); // optimistic update / SSR hydrationNext.js — App Router
Server components (no client JS by default)
// app/blog/page.tsx
import { VloziClient } from "@vlozi/blog";
import { ServerBlogList } from "@vlozi/blog/server";
const client = new VloziClient({ apiKey: process.env.VLOZI_KEY!, baseUrl: "..." });
export default async function BlogIndex() {
return <ServerBlogList client={client} limit={9} />;
}generateMetadata + generateStaticParams
// app/blog/[slug]/page.tsx
import { generateMetadataForPost, generateStaticParamsForPosts } from "@vlozi/blog/next";
import { VloziClient } from "@vlozi/blog";
import { ServerBlogPost } from "@vlozi/blog/server";
const client = new VloziClient({ apiKey: process.env.VLOZI_KEY!, baseUrl: "..." });
export const generateMetadata = ({ params }) =>
generateMetadataForPost({ client, slug: params.slug });
export const generateStaticParams = () =>
generateStaticParamsForPosts({ client, paramKey: "slug" });
export default async function PostPage({ params }) {
return <ServerBlogPost client={client} slug={params.slug} />;
}Keeping a built site current
If your site renders posts ahead of time — generateStaticParams, output: "export",
or any static host — that content is frozen when the build finishes. Edit a post in
the Vlozi dashboard and your live site keeps showing the old text until someone
rebuilds. There are two ways to close that gap, and which you need depends on how
you deploy.
Live content (works on any host)
Each rendered surface remembers the content version it was built from, and on page view asks Vlozi whether that's still current. Only a real change triggers a refetch, so the common case costs one ~30-byte response, fetched during idle time.
One line, in a server module your root layout imports:
// app/layout.tsx
import { setVloziLiveDefaults } from "@vlozi/blog/server";
setVloziLiveDefaults({
apiKey: process.env.NEXT_PUBLIC_VLOZI_KEY!, // publishable pk_* key
enabled: true,
});Every @vlozi/blog/server component picks it up. Override per component with
live={false}, live, or live={{ ttlMs: 30_000 }}.
It's off by default, deliberately. Turning it on adds a small client bundle (pnpm size reports the live island's exact budget) and
puts a publishable key in your HTML — two things worth choosing rather than
inheriting. With it off, your pages render exactly as before and load no extra
JavaScript at all.
Use a publishable (pk_*) key and set its allowed domains. The SDK refuses any
other key rather than serialize a secret into your markup.
Webhook revalidation (Next.js with a server)
If you deploy somewhere that can re-render on demand, let Vlozi call you instead:
// app/api/vlozi/revalidate/route.ts
import { handleVloziWebhook } from "@vlozi/blog/next";
export const POST = (request: Request) =>
handleVloziWebhook(request, { secret: process.env.VLOZI_WEBHOOK_SECRET! });Add the route's URL under Blog → Settings → Site updates in the dashboard and
copy the signing secret it gives you. Deliveries are HMAC-SHA256 signed over
`${timestamp}.${rawBody}`; the handler verifies them and rejects anything older
than five minutes. Pair it with nextTags: [VLOZI_BLOG_TAG] on your client so
there's something tagged to purge.
Need custom handling? Compose the parts — but read the body with request.text(),
never request.json(), since the signature covers the exact bytes sent:
import { verifyVloziSignature, revalidateVloziBlog } from "@vlozi/blog/next";
const raw = await request.text();
const result = await verifyVloziSignature(request, raw, { secret });
if (!result.ok) return Response.json({ error: result.reason }, { status: result.status });
await revalidateVloziBlog({ slug: JSON.parse(raw).slug });What each approach covers
| Change | Live content | Webhook | Deploy hook |
|---|---|---|---|
| Edit a published post | ✅ ~30s | ✅ seconds | ✅ 1–3 min |
| Unpublish or delete | ✅ switches to not-found + noindex | ✅ | ✅ |
| Rename a category or tag | ✅ | ✅ | ✅ |
| Publish a brand-new post | ❌ | ✅ | ✅ |
| Fresh HTML for crawlers | ❌ | ✅ | ✅ |
New posts need a rebuild on a static export. The page for a post published after your build doesn't exist as a file, and no client-side code can create one — the list updates and then links to a URL that 404s. Configure a deploy hook in the dashboard so new posts trigger one.
Custom links that survive a refresh
hrefFor is a function, so it can't cross into the browser and turns live refresh
off for that component. Use the serializable hrefPattern instead:
<ServerBlogCategoryList client={client} hrefPattern="/blog/category/{slug}" live />The same applies to renderItem and render — passing one disables live refresh for
that surface rather than re-rendering it with different markup.
RSS + Sitemap
// app/feed.xml/route.ts
import { generateRSS, VloziClient } from "@vlozi/blog";
const client = new VloziClient({ apiKey: process.env.VLOZI_KEY!, baseUrl: "..." });
export async function GET() {
const xml = await generateRSS({
client,
siteUrl: "https://example.com",
title: "Example Blog",
description: "Latest posts",
limit: 50,
});
return new Response(xml, {
headers: { "Content-Type": "application/xml; charset=utf-8" },
});
}// app/sitemap.xml/route.ts
import { generateSitemap } from "@vlozi/blog";
export async function GET() {
const xml = await generateSitemap({
client,
siteUrl: "https://example.com",
additionalUrls: [
{ loc: "https://example.com/", priority: 1.0, changefreq: "daily" },
],
});
return new Response(xml, { headers: { "Content-Type": "application/xml; charset=utf-8" } });
}Rich content (carousel, mermaid, YouTube)
<BlogPost> automatically hydrates the editor's rich-content blocks. Most consumers don't need to do anything beyond installing the SDK.
If you build your own post page and only need the renderer, use <BlogContent> directly. The SDK's .vlz-content baseline is self-sufficient — don't add prose prose-lg dark:prose-invert (Tailwind Typography plugin) on top, the two stylesheets compete:
import { BlogContent } from "@vlozi/blog/react";
import "@vlozi/blog/styles.css";
export function CustomPost({ post }: { post: { title: string; content: string } }) {
return (
<article className="my-article-layout">
<h1>{post.title}</h1>
<BlogContent html={post.content} />
</article>
);
}Carousels
The editor emits <div data-type="carousel" data-slides='[…]'> with a fallback <figure> stack. <BlogContent> upgrades each placeholder to an <Carousel> after mount via createPortal. SEO crawlers still see every slide indexed in the figure stack.
You can also use <Carousel> standalone:
import { Carousel, type CarouselSlide } from "@vlozi/blog/react";
const slides: CarouselSlide[] = [
{ src: "/a.jpg", alt: "Slide 1", caption: "Optional caption" },
{ src: "/b.jpg", alt: "Slide 2" },
];
<Carousel slides={slides} />;Mermaid diagrams
Code blocks tagged ```mermaid render as live SVG diagrams. The mermaid package (~1.5 MB) is lazy-imported only on pages that contain at least one diagram, so the base bundle is unaffected.
mermaid is an optional peerDependency — install it only if your posts use mermaid:
pnpm add mermaid
# or
npm install mermaidIf mermaid isn't installed, the diagram source falls back to a styled code block with a one-line install hint. Render errors surface inline with a "Show source" disclosure.
Build-time: as of 2.1.5+, mermaid is marked external in tsup so the SDK build doesn't bundle it — the consumer's bundler resolves it from node_modules/mermaid and emits a code-split chunk that loads on-demand. You must install mermaid if your authors publish mermaid blocks. Without it, the consumer build emits a "module not found" warning and the runtime promise rejects, which the SDK catches and renders as the "install mermaid" fallback UI.
Heads-up — 2.1.5 had a regression here. That release wrapped the
import("mermaid")call innew Function("specifier", "return import(specifier)")to hide the import from consumer bundlers. The runtimeimport()then ran in browser-native mode, which can't resolve bare specifiers like"mermaid"without an import map. Result: every mermaid block fell through to the "missing-dep" fallback even when consumers hadmermaidinstalled. Fixed in 2.1.6 — back to a literalimport("mermaid")that bundlers can analyze. Upgrade if you're seeing raw mermaid source rendered in beige boxes.
Theme: defaults to "auto" (follows prefers-color-scheme and re-renders when the OS toggle flips mid-session). Override per-component via <MermaidBlock theme={...} /> or globally via the provider config:
<VloziProvider client={client} config={{ mermaidTheme: "default" }}>{children}</VloziProvider>mermaidTheme accepts "auto" | "default" | "dark" | () => "default" | "dark". Pass a function to read your own theme system (next-themes, custom data-theme, etc.).
YouTube embeds
The sanitizer allows <iframe> elements whose src starts with https://www.youtube-nocookie.com/ or https://youtube-nocookie.com/. Everything else (vimeo, generic, on-handlers, malicious) is stripped. Embeds are wrapped in a 16:9 aspect-ratio container via .vlz-content iframe[src*="youtube-nocookie.com"].
If your global CSS sets iframe { display: none }, scope the rule or override under .vlz-content:
.vlz-content iframe[src*="youtube-nocookie.com"] {
display: block;
}Custom image components
All image-rendering components accept a pluggable imageComponent prop — drop in Next.js <Image> for automatic format conversion, responsive sources, and blur placeholders:
import Image from "next/image";
import { BlogList, type VloziImageComponent } from "@vlozi/blog/react";
const NextBlogImage: VloziImageComponent = ({ src, alt, className }) => (
<div className={`relative aspect-video ${className ?? ""}`}>
<Image src={src} alt={alt} fill style={{ objectFit: "cover" }} />
</div>
);
<BlogList imageComponent={NextBlogImage} />;Styling — .vlz-content and the five --vlz-* knobs
import "@vlozi/blog/styles.css";Import this first in your root layout (before your own globals.css) so consumer overrides naturally win on specificity ties. Importing in a nested app/blog/layout.tsx puts it after globals, requiring !important or extra-specific selectors to override.
Every component the SDK ships writes its body into a <div class="vlz-content">. Theming is driven entirely by five CSS custom properties layered over currentColor — the prose stylesheet derives every themeable value from these, so you only have to set them once:
| Custom property | Used for | Default |
| --- | --- | --- |
| --vlz-accent | Link color, syntax keywords, blockquote rule, task checkbox | #3b82f6 |
| --vlz-muted-fg | Figcaptions, list markers, syntax comments | color-mix(in oklch, currentColor 55%, transparent) |
| --vlz-border | Tables, <hr>, <details> borders | color-mix(in oklch, currentColor 18%, transparent) |
| --vlz-surface | Code-block bg, table header, <summary> | color-mix(in oklch, currentColor 6%, transparent) |
| --vlz-surface-hover | <summary> hover | color-mix(in oklch, currentColor 10%, transparent) |
Map these to your design tokens once at the root:
/* shadcn/ui — HSL channels */
.vlz-content {
--vlz-accent: hsl(var(--primary));
--vlz-muted-fg: hsl(var(--muted-foreground));
--vlz-border: hsl(var(--border));
--vlz-surface: hsl(var(--muted) / 0.6);
--vlz-surface-hover: hsl(var(--muted) / 0.8);
}
/* Tailwind v4 with @theme color tokens */
.vlz-content {
--vlz-accent: var(--color-primary);
--vlz-muted-fg: var(--color-muted-foreground);
--vlz-border: var(--color-border);
--vlz-surface: var(--color-muted);
--vlz-surface-hover: var(--color-accent);
}
/* Plain hex / OKLCH / rgb tokens */
.vlz-content {
--vlz-accent: var(--my-primary);
}The defaults look coherent on any text color out of the box — non-shadcn consumers don't need an override stylesheet at all.
Motion is gated on motion-safe: and @media (prefers-reduced-motion: reduce) so the user's reduced-motion preference is automatically respected.
As of 2.1.5, every SDK component renders with .vlz-* classes that derive their colors from the same five --vlz-* knobs above — there's no Tailwind dependency, no shadcn-token coupling, and no JIT-scanning-of-node_modules requirement. Components still accept your className prop for additional overrides; the per-component class names (vlz-card, vlz-list, vlz-pagination-button, etc.) are documented in styles.css and stable for consumer override.
Hydration contract
<BlogContent> upgrades two block types in the post HTML to interactive React components, mounted via createPortal:
<div data-type="carousel" data-slides='[...]'>→<Carousel><pre><code class="language-mermaid">...</code></pre>→<MermaidBlock>
When the scanner hydrates a host, it stamps the element with stable data attributes you can use to scope your own styling:
| Attribute | Set on | Value |
| --- | --- | --- |
| data-vlz-hydrated | The host element | "mermaid" or "carousel" |
| data-vlz-source | Mermaid host (<pre>) | The original mermaid source code (for second-pass recovery) |
| data-vlz-slides | Carousel host | JSON-serialized slide array |
| data-vlz-syntax | The <BlogContent> root | "default" when syntax highlighting is enabled, omitted otherwise |
Why this matters: if your globals.css styles bare <pre>, those rules currently apply to the mermaid host because the SDK portal-mounts INTO the original <pre>. Scope under :not([data-vlz-hydrated]) to target only ordinary code blocks:
/* Style my code blocks but skip mermaid hosts */
.vlz-content pre:not([data-vlz-hydrated]) {
background: var(--my-code-bg);
border-radius: 0.5rem;
}The hydration scanner is idempotent — it can be called multiple times on the same container and returns the same target list every time. This makes it safe under React 19 StrictMode (which double-runs effects in development).
Known limitations
mermaidis an optional peer dependency that must be installed if your authors use mermaid blocks. Without it, diagrams fall back to source code with a one-line install hint. Pre-2.1.5 versions also failed at build time whenmermaidwas missing — that's fixed in 2.1.5+.- Sanitizer is defense-in-depth, not authoritative. Blog content is sanitized server-side before it reaches the SDK; the SDK's own
sanitizeHtmlruns to a hard cap of 5 fixed-point iterations. For HTML pulled from sources outside the editor (RSS imports, AI drafts, user-submitted), wrap with DOMPurify yourself. generateStaticParamsForPostspaginates sequentially in 100-post batches. For blogs over ~1000 posts, build time scales linearly. Parallelization is planned for a future release.- OS theme toggle in
automode triggers a re-render of every visible mermaid diagram via aMediaQueryListsubscription. For pages with many diagrams, force a static theme via<VloziProvider config={{ mermaidTheme: "default" }}>to avoid the re-render churn.
Accessibility
The SDK is built around ARIA from day one:
- Loading states use
role="status"+aria-live="polite"andaria-busyon containers - Error states use
role="alert" - Active category/tag buttons expose
aria-pressed(toggles) oraria-current="page"(sidebar) <BlogPost>article is labelled by its<h1>viaaria-labelledby- All skeleton animations and hover zooms are gated on
motion-safe:
Storybook
A full story catalog ships with the package for live component documentation:
pnpm --filter @vlozi/blog storybook # dev server on :6006
pnpm --filter @vlozi/blog build-storybook # static buildEvery component has 4–11 stories covering success, loading, empty, error, and edge-case states. Play functions run interaction tests (search debounce, article labelling, aria-pressed toggling) directly in the browser.
Security
Blog content is sanitized server-side before reaching the SDK. As a defense-in-depth layer, <BlogContent> (used internally by <BlogPost>) runs every HTML body through a built-in sanitizer that iterates to a fixed point, strips <script>/<style>/<object>/<embed>/<meta>/<base> and their orphan tags, removes inline event handlers (including unquoted forms), and neutralizes javascript:/vbscript:/data:text/html URLs.
<iframe> elements are allowlisted rather than blanket-stripped: only those whose src begins with https://www.youtube-nocookie.com/ or https://youtube-nocookie.com/ are kept (matching the editor's Youtube extension which forces nocookie: true). All other iframes are dropped, including those with javascript: or relative-path sources, sourceless iframes, and on-handlers on allowlisted iframes.
For strict XSS requirements, install dompurify and run it alongside.
License
MIT
