@canvas-digital/blog-sdk
v2.3.7
Published
Unified Blog SDK — user (published blogs) and admin (CRUD) in one package.
Maintainers
Readme
@canvas-digital/blog-sdk
Unified React SDK for the Blog Platform — public blog (mode="user") and admin CMS (mode="admin") in one package.
Install in your host app (Next.js, Vite, CRA, etc.), import the CSS once, and render <BlogPlatform /> with credentials. Every API call sends email + apiKey (+ optional domain) as request headers.
| Mode | Audience | Auth | What you get |
|------|----------|------|----------------|
| mode="user" | Site visitors | Public API key | Blog list, post detail, categories, TOC, Top Related Blogs, comments UI |
| mode="admin" | Editors / admins | Admin API key or email + password login | Full CMS with toasts, confirm dialogs, button loaders, and live list refresh |
Not included: Super-admin / platform console. Domains and keys are issued from your Blog Platform backend — not from this npm package.
Install
npm install @canvas-digital/blog-sdk
# or
yarn add @canvas-digital/blog-sdk
# or
pnpm add @canvas-digital/blog-sdkPeer dependencies: react and react-dom ≥ 18.
Bundled: react-toastify, @tanstack/react-query, axios, react-select.
Import styles once in your app entry (required for admin + public layouts):
import "@canvas-digital/blog-sdk/style.css";
import { BlogPlatform } from "@canvas-digital/blog-sdk";Quick start
Public blog (user)
import "@canvas-digital/blog-sdk/style.css";
import { BlogPlatform } from "@canvas-digital/blog-sdk";
export default function BlogPage() {
return (
<BlogPlatform
mode="user"
email="[email protected]"
apiKey="bp_public_..."
domain="example.com"
basePath="/blog"
branding={{
name: "My Blog",
primaryColor: "#e01eae",
secondaryColor: "#19042d",
}}
/>
);
}Admin CMS (skip login — pass key directly)
<BlogPlatform
mode="admin"
email="[email protected]"
apiKey="bp_sdk_..."
branding={{ name: "Admin Blog" }}
/>Admin CMS (email + password login)
"use client"; // Next.js App Router
import { useState } from "react";
import "@canvas-digital/blog-sdk/style.css";
import { BlogPlatform } from "@canvas-digital/blog-sdk";
export default function AdminBlogPage() {
const [email, setEmail] = useState("");
const [apiKey, setApiKey] = useState("");
const [isAuthenticated, setIsAuthenticated] = useState(false);
return (
<main className="app-full" style={{ minHeight: "100dvh" }}>
<BlogPlatform
mode="admin"
email={email}
apiKey={apiKey}
isAuthenticated={isAuthenticated}
branding={{ name: "Admin Blog" }}
onLoginSuccess={(creds) => {
setEmail(creds.email);
setApiKey(creds.apiKey);
setIsAuthenticated(true);
}}
onLogout={() => {
setEmail("");
setApiKey("");
setIsAuthenticated(false);
}}
/>
</main>
);
}With isAuthenticated={false}, the SDK shows a login form. On success it calls POST {apiUrl}/admin/login, then opens the dashboard. Log out appears on the Profile page.
Host tip: give the admin wrapper a real height (
min-height: 100dvhor a full-height layout). The admin shell fills its parent.
Props reference
Required / common
| Prop | Type | Default | Notes |
|------|------|---------|--------|
| mode | "user" \| "admin" | — | Chooses public blog vs admin CMS |
| email | string | — | Required for user. Optional for admin when isAuthenticated={false} (login supplies it) |
| apiKey | string | — | Public key (bp_public_…) for user; admin key (bp_sdk_…) for admin. Optional when using login form |
Optional
| Prop | Type | Default | Notes |
|------|------|---------|--------|
| domain | string | — | Sent as domain / X-Client-Domain headers |
| apiUrl | string | from env / default | Override API base (usually omit; use env instead). Prefer HTTPS in production |
| timeoutMs | number | 3600000 | Request timeout (1 hour) |
| basePath | string | "/blog" | User mode. URL prefix for list/detail. Must match a host catch-all route |
| branding | object | — | name, subtitle, logo, primaryColor, secondaryColor, className, css |
| theme | object | — | User UI theme (layout, colors, fonts) |
| adminTheme | object | — | Admin UI theme |
| className | string | — | Wrapper class |
| publicOrigin | string | — | Admin. Origin for Preview links (e.g. https://yoursite.com) |
| isAuthenticated | boolean | true | Admin only. false → show login form |
| onLoginSuccess | (creds) => void | — | Admin only. { email, apiKey } after login |
| onLogout | () => void | — | Admin only. Fired when user logs out |
User mode (public blog)
What it does
- Blog index (hero, trust band, post grid, load more)
- Post detail (hero, prose with justified paragraphs, sticky TOC on laptop+, comments UI)
- Top Related Blogs band above the host footer (3 list-style cards + View all)
- Category filter via
/blog/category/{slug} - Client-side navigation between list ↔ detail (no full page reload on card click)
- Scroll-to-top on list ↔ detail navigation
- TOC clicks scroll only — they do not append heading slugs to the URL
Detail page load order
- Post API → article + comments UI
- Related API → “Top Related Blogs” section (own loader until response)
- List-hero background loads in parallel and does not block related
Top Related Blogs
Full-width section matching list gutters and card style:
┌─────────────────────────────────────────────┐
│ Top Related Blogs View all → │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ card │ │ card │ │ card │ │
│ └──────┘ └──────┘ └──────┘ │
└─────────────────────────────────────────────┘API: GET /blogs/{blog_id}/related?limit=3
Fallback order on the backend: same category → same tags → random published posts.
Standalone component (optional):
import { RelatedBlogs } from "@canvas-digital/blog-sdk";
<RelatedBlogs
config={{
apiUrl: process.env.NEXT_PUBLIC_BLOG_API_URL!,
publicApiKey: process.env.NEXT_PUBLIC_BLOG_PUBLIC_KEY!,
email: process.env.NEXT_PUBLIC_BLOG_EMAIL!,
domain: process.env.NEXT_PUBLIC_BLOG_DOMAIN,
}}
blog={{ id: postId, category_id: categoryId }}
limit={3}
title="Top Related Blogs"
viewAllHref="/blog"
getHref={(b) => `/blog/${b.slug}`}
/>URL shape
With basePath="/blog" (default):
| Path | View |
|------|------|
| /blog | List |
| /blog/{post-slug} | Post detail |
| /blog/category/{category-slug} | Filtered list |
Critical: host catch-all route
The SDK updates the browser URL with the History API. Direct links and refresh only work if your host app serves the same page for every path under basePath.
Without a catch-all, https://yoursite.com/blog/my-post returns 404 even though /blog works.
Do not remount on pathname
// ❌ Causes “Maximum update depth exceeded” and blank related section
<BlogPlatform key={pathname} mode="user" ... />
// ✅ Correct — SDK owns list ↔ detail URL sync
<BlogPlatform mode="user" basePath="/blog" ... />Admin mode (CMS)
Auth options
- Pass credentials —
isAuthenticateddefaults totrue; provideemail+ adminapiKey. - Login form —
isAuthenticated={false}; admin signs in with email + password. SDK callsPOST /admin/loginand uses the returnedapiKeyfor subsequent Admin SDK calls.
After login
- Persist
email+apiKeyfromonLoginSuccessin host state (and optionallysessionStorage/ cookies). - Keep rendering the same
<BlogPlatform mode="admin" />instance so the in-SDK session is not unmounted. - On logout, clear host state in
onLogout. - Refresh-safe routes: the admin view (posts, editor, SEO, heroes, etc.) is restored from the URL /
sessionStorageafter reload — you stay on the same screen instead of jumping back to the dashboard.
UX built into admin
| Feature | Behavior |
|---------|----------|
| Success / error toasts | react-toastify top-right toasts on create, update, and delete |
| Confirm dialog | In-app modal (not window.confirm); confirm button shows a spinner until the API finishes |
| Button loaders | Create / Save / Delete / bulk actions show a spinner and stay disabled until the mutation settles |
| Live list refresh | After delete/create/update, tables refetch from the API (no stale 30s cache) |
Example toast usage if you build custom admin screens on top of the SDK:
import { adminToast, AdminToastHost } from "@canvas-digital/blog-sdk";
function MyAdminScreen() {
return (
<>
<AdminToastHost />
<button
type="button"
onClick={async () => {
try {
await deleteSomething();
adminToast.success("Deleted successfully.");
} catch (e) {
adminToast.error(e instanceof Error ? e.message : "Delete failed.");
}
}}
>
Delete
</button>
</>
);
}<BlogPlatform mode="admin" /> already mounts AdminToastHost — you do not need to add it for the built-in CMS.
Headers (after auth)
email: [email protected]
apiKey: bp_sdk_...
domain: example.comAdmin surfaces
Posts (list, quick edit, trash, publish), categories / tags / authors, media library, comments, users, brand settings, blog list heroes, redirects, robots.txt, GA4 / Search Console, import / export tools.
API base URL
| Priority | Source |
|----------|--------|
| 1 | apiUrl prop on <BlogPlatform /> |
| 2 | Host env BLOG_API_URL (also accepts VITE_BLOG_API_URL / NEXT_PUBLIC_BLOG_API_URL) |
| 3 | Built-in default http://app.yourwebsitemockup.net:8000/api/v1 |
# Production — use HTTPS to avoid mixed-content blocks on HTTPS sites
BLOG_API_URL=https://api.yourdomain.com/api/v1
# Local backend
BLOG_API_URL=http://localhost:8000/api/v1| Backend | URL |
|---------|-----|
| Default API | http://app.yourwebsitemockup.net:8000/api/v1 |
| Local API | http://localhost:8000/api/v1 |
| Swagger | http://localhost:8000/docs |
| Health | http://localhost:8000/health |
| Related posts | GET /api/v1/blogs/{id}/related?limit=3 |
Restart the host dev server after changing env files.
SSL / mixed content: if your site is served over
https://, the API must also behttps://. Anhttp://API URL will be blocked by the browser and public/admin calls will fail silently or with network errors.
Deploy by technology (complete examples)
1) Next.js App Router (recommended)
Project layout
app/
layout.tsx
blog/
[[...slug]]/
page.tsx ← public blog catch-all
admin-blog/
page.tsx ← admin CMS
.env.local
next.config.mjsnext.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
env: {
// Expose to the client bundle for the SDK
BLOG_API_URL: process.env.BLOG_API_URL,
},
};
export default nextConfig;.env.local
BLOG_API_URL=https://api.yourdomain.com/api/v1
# Prefer server-only vars; pass keys into the client page carefully.
# For a public blog page, public key is OK on the client.
[email protected]
NEXT_PUBLIC_BLOG_PUBLIC_KEY=bp_public_...
NEXT_PUBLIC_BLOG_DOMAIN=www.yoursite.comPublic blog — app/blog/[[...slug]]/page.tsx
"use client";
import "@canvas-digital/blog-sdk/style.css";
import { BlogPlatform } from "@canvas-digital/blog-sdk";
export default function BlogCatchAllPage() {
return (
<main className="app-full">
<BlogPlatform
mode="user"
email={process.env.NEXT_PUBLIC_BLOG_EMAIL!}
apiKey={process.env.NEXT_PUBLIC_BLOG_PUBLIC_KEY!}
domain={process.env.NEXT_PUBLIC_BLOG_DOMAIN}
basePath="/blog"
branding={{
name: "Book Publishing Services Blog",
primaryColor: "#e01eae",
secondaryColor: "#19042d",
}}
/>
</main>
);
}Do not wrap <BlogPlatform /> with key={pathname} (or remount on every usePathname() change). The SDK already syncs list ↔ detail from the URL; remounting on pathname fights that and can cause “Maximum update depth exceeded”, abort related-post fetches, and a blank related section.
The [[...slug]] segment is required so these all hit the same page:
/blog/blog/my-post-slug/blog/category/animation
Admin — app/admin-blog/page.tsx
"use client";
import { useEffect, useState } from "react";
import "@canvas-digital/blog-sdk/style.css";
import { BlogPlatform } from "@canvas-digital/blog-sdk";
const STORAGE_KEY = "bp-admin-session";
type Creds = { email: string; apiKey: string };
export default function AdminBlogPage() {
const [email, setEmail] = useState("");
const [apiKey, setApiKey] = useState("");
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [ready, setReady] = useState(false);
useEffect(() => {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as Creds;
if (parsed.email && parsed.apiKey) {
setEmail(parsed.email);
setApiKey(parsed.apiKey);
setIsAuthenticated(true);
}
}
} catch {
/* ignore */
}
setReady(true);
}, []);
if (!ready) return null;
return (
<main style={{ minHeight: "100dvh" }}>
<BlogPlatform
mode="admin"
email={email}
apiKey={apiKey}
isAuthenticated={isAuthenticated}
publicOrigin={typeof window !== "undefined" ? window.location.origin : undefined}
branding={{ name: "Admin Blog" }}
onLoginSuccess={(creds) => {
setEmail(creds.email);
setApiKey(creds.apiKey);
setIsAuthenticated(true);
sessionStorage.setItem(
STORAGE_KEY,
JSON.stringify({ email: creds.email, apiKey: creds.apiKey })
);
}}
onLogout={() => {
setEmail("");
setApiKey("");
setIsAuthenticated(false);
sessionStorage.removeItem(STORAGE_KEY);
}}
/>
</main>
);
}Deploy (Vercel)
# In the Next.js app repo
npm install @canvas-digital/blog-sdk
vercel env add BLOG_API_URL
vercel env add NEXT_PUBLIC_BLOG_EMAIL
vercel env add NEXT_PUBLIC_BLOG_PUBLIC_KEY
vercel env add NEXT_PUBLIC_BLOG_DOMAIN
vercel --prodOr connect the Git repo in the Vercel dashboard and set the same env vars under Project → Settings → Environment Variables.
2) Next.js Pages Router
pages/
blog/[[...slug]].tsx
admin-blog.tsxpages/blog/[[...slug]].tsx
import dynamic from "next/dynamic";
import "@canvas-digital/blog-sdk/style.css";
const BlogPlatform = dynamic(
() => import("@canvas-digital/blog-sdk").then((m) => m.BlogPlatform),
{ ssr: false }
);
export default function BlogPage() {
return (
<BlogPlatform
mode="user"
email={process.env.NEXT_PUBLIC_BLOG_EMAIL!}
apiKey={process.env.NEXT_PUBLIC_BLOG_PUBLIC_KEY!}
domain={process.env.NEXT_PUBLIC_BLOG_DOMAIN}
basePath="/blog"
/>
);
}pages/admin-blog.tsx
Same client logic as the App Router admin example (state + isAuthenticated={false} + onLoginSuccess / onLogout).
next.config.js should still map BLOG_API_URL via env: { BLOG_API_URL: process.env.BLOG_API_URL }.
3) Vite + React
vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
envPrefix: ["VITE_", "BLOG_"], // so BLOG_API_URL is exposed to import.meta.env
});.env / .env.production
BLOG_API_URL=https://api.yourdomain.com/api/v1
[email protected]
VITE_BLOG_PUBLIC_KEY=bp_public_...
VITE_BLOG_DOMAIN=www.yoursite.comRouter (React Router v6) — catch-all for public blog
// main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import "@canvas-digital/blog-sdk/style.css";
import { BlogPage } from "./BlogPage";
import { AdminBlogPage } from "./AdminBlogPage";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<Routes>
<Route path="/blog/*" element={<BlogPage />} />
<Route path="/admin-blog" element={<AdminBlogPage />} />
</Routes>
</BrowserRouter>
</React.StrictMode>
);// BlogPage.tsx
import { BlogPlatform } from "@canvas-digital/blog-sdk";
export function BlogPage() {
return (
<BlogPlatform
mode="user"
email={import.meta.env.VITE_BLOG_EMAIL}
apiKey={import.meta.env.VITE_BLOG_PUBLIC_KEY}
domain={import.meta.env.VITE_BLOG_DOMAIN}
basePath="/blog"
/>
);
}// AdminBlogPage.tsx — same login-state pattern as Next admin example
import { useState } from "react";
import { BlogPlatform } from "@canvas-digital/blog-sdk";
export function AdminBlogPage() {
const [email, setEmail] = useState("");
const [apiKey, setApiKey] = useState("");
const [isAuthenticated, setIsAuthenticated] = useState(false);
return (
<main style={{ minHeight: "100dvh" }}>
<BlogPlatform
mode="admin"
email={email}
apiKey={apiKey}
isAuthenticated={isAuthenticated}
onLoginSuccess={(c) => {
setEmail(c.email);
setApiKey(c.apiKey);
setIsAuthenticated(true);
}}
onLogout={() => {
setEmail("");
setApiKey("");
setIsAuthenticated(false);
}}
/>
</main>
);
}Deploy (static / Netlify / Cloudflare Pages)
npm run build
# Upload dist/ to your host
# Set BLOG_API_URL / VITE_* vars in the host dashboard for production buildsNetlify public/_redirects (SPA fallback so /blog/my-post does not 404):
/blog/* /index.html 200
/admin-blog /index.html 200
/* /index.html 200Cloudflare Pages / Vite: set “not found” behavior to serve index.html for SPA routes, or add a _routes / redirect rule equivalent to the above.
4) Create React App
CRA exposes only REACT_APP_* env vars. Pass the API URL with the prop (or rename and map it):
<BlogPlatform
mode="user"
apiUrl={process.env.REACT_APP_BLOG_API_URL}
email={process.env.REACT_APP_BLOG_EMAIL}
apiKey={process.env.REACT_APP_BLOG_PUBLIC_KEY}
domain={process.env.REACT_APP_BLOG_DOMAIN}
basePath="/blog"
/># .env
REACT_APP_BLOG_API_URL=https://api.yourdomain.com/api/v1
[email protected]
REACT_APP_BLOG_PUBLIC_KEY=bp_public_...
REACT_APP_BLOG_DOMAIN=www.yoursite.comUse React Router path="/blog/*" the same way as Vite. For production on Nginx:
location /blog {
try_files $uri /index.html;
}
location /admin-blog {
try_files $uri /index.html;
}Checklist before go-live
User blog
- [ ]
mode="user"with publicemail+apiKey - [ ]
basePathmatches your real URL (default/blog) - [ ] Catch-all route exists (
[[...slug]]or/blog/*) - [ ] No
key={pathname}on<BlogPlatform /> - [ ] SPA host rewrite serves
index.htmlfor deep links (Vite/CRA) - [ ]
BLOG_API_URLis HTTPS when the site is HTTPS - [ ] Backend has
GET /blogs/{id}/relatedavailable - [ ]
@canvas-digital/blog-sdk/style.cssimported once
Admin
- [ ]
mode="admin" - [ ] Either pass admin
email+apiKey, orisAuthenticated={false}+ login handlers - [ ]
onLoginSuccessstores{ email, apiKey }in host state - [ ] Admin page container has height (
min-height: 100dvh) - [ ] Do not put admin API keys in a public marketing page
- [ ] Confirm create / update / delete shows toasts + button loaders after linking the latest SDK
Package exports
import {
BlogPlatform,
BlogList,
BlogDetail,
RelatedBlogs,
BlogAdmin,
AdminLoginForm,
adminToast,
AdminToastHost,
useConfirmDialog,
BlogClient,
AdminSdkClient,
loginAdmin,
resolveBlogApiUrl,
} from "@canvas-digital/blog-sdk";
// or: import { BlogClient } from "@canvas-digital/blog-sdk/client";
import "@canvas-digital/blog-sdk/style.css";Develop this package
git pull
cp .env.example .env # playground only — not published
npm install
npm test
npm run typecheck
npm run build
npm run dev # local playgroundLink into a host app locally:
# in blog-platform-packages
npm run build
npm pack
# then in the host Next/Vite app
npm install ../path/to/canvas-digital-blog-sdk-2.3.6.tgzPublish to npm
# Bump version in package.json first (npm rejects duplicate versions)
npm version patch # or minor / major
npm config set //registry.npmjs.org/:_authToken=YOUR_TOKEN
npm whoami
npm publish --access publicThen in each host app:
npm install @canvas-digital/blog-sdk@latestTroubleshooting
| Symptom | Fix |
|---------|-----|
| /blog/my-post → 404 | Add catch-all route + SPA rewrite; set basePath to match |
| Maximum update depth exceeded | Remove key={pathname} from <BlogPlatform />; upgrade SDK |
| Related blogs empty / never load | Same as above; ensure GET /blogs/{id}/related exists; hard-refresh after SDK rebuild |
| Admin blank after login | Persist creds in onLoginSuccess; give wrapper min-height: 100dvh; upgrade SDK |
| Admin refresh jumps to dashboard | Upgrade SDK (route persist via URL + sessionStorage) |
| Delete succeeds but list unchanged | Upgrade SDK (force refetch after mutations) |
| No toast on save/delete | Upgrade SDK; import style.css; confirm react-toastify is installed with the package |
| Mixed content / API blocked | Use https:// for BLOG_API_URL on HTTPS sites |
| Double TOC | Upgrade SDK (CSS fix); ensure style.css is imported |
| API 401 | Wrong key type (public vs admin) or missing email / apiKey headers |
| Env ignored | Restart Vite/Next; map BLOG_API_URL in vite.config / next.config |
License
MIT
