@vulcantech/blog
v0.1.1
Published
Server-side blog fetch layer for VulcanTech consumer sites — ISR-tagged reads from the CMS, with signed draft-preview support. Shared so each site doesn't duplicate the fetch code.
Maintainers
Readme
@vulcantech/blog
Server-side blog fetch layer for VulcanTech consumer sites. Reads published posts from the CMS with Next.js ISR tags, and supports signed draft-preview tokens so unpublished posts can be reviewed on the live site. Shared so each site stops duplicating lib/cms/blog.ts.
Exports: fetchBlogPosts, fetchBlogPostBySlug, resolvePreviewToken, GET (the draft-preview route handler), tagsForBlogList / tagsForBlogPost, and the CMSBlogPost / CMSBlogAuthor types.
Server-only — uses VULCANTECH_API_KEY; import from server components / route handlers only.
Integration
Wire blog into a consumer site (Next.js App Router):
1. Add the dependency:
npm install @vulcantech/blog(Inside the VulcanTech monorepo, use the workspace wiring in MONOREPO.md instead.)
2. Set the env — see Required env below.
3. (Optional) thin re-export src/lib/cms/blog.ts to preserve @/lib/cms/blog import paths:
export * from "@vulcantech/blog";4. Blog pages — list + single post (server components):
import { fetchBlogPosts, fetchBlogPostBySlug, resolvePreviewToken } from "@vulcantech/blog";
// app/blog/page.tsx
const posts = await fetchBlogPosts();
// app/blog/[slug]/page.tsx — resolve the preview token (Draft Mode) and pass it through
const previewToken = await resolvePreviewToken();
const post = await fetchBlogPostBySlug(slug, { previewToken });In generateMetadata, prefer the post's derived keywords string (the CMS
builds it from the post's dedicated SEO keyword fields — target keyword first)
and fall back to tags:
keywords: post.keywords || (post.tags?.length ? post.tags : [post.title, "Brand"]),5. Draft-preview route src/app/api/preview/route.ts — one line, shared implementation:
export { GET } from "@vulcantech/blog";The CMS mints a signed token and links to /api/preview?token=…&slug=…; the handler validates it, enables Next Draft Mode, and redirects to the post. Reading the token opts the render dynamic — keep the public [slug] route static/ISR and let Draft Mode switch it.
6. ISR invalidation — ensure src/app/api/revalidate/route.ts exists; the CMS purges blog:{projectId} (list) and blog:{projectId}:{slug} (single) on edit.
Scoped routes (routePrefix)
Posts carry an optional routePrefix that decides which URL space they live in
on the consumer site: "blog" → /blog/<slug> (the default; legacy posts with
no prefix count as blog), "legal" → /legal/<slug>, "documentation" →
/documentation/<slug>, etc. Editors set it per-post in the CMS dashboard. One
Firestore collection powers all of them — the split happens at fetch/render time.
Wiring a scoped route (e.g. documentation) takes three pieces:
1. A scope predicate — keep /blog clean. fetchBlogPosts() returns every
published post regardless of prefix, so the blog list/related-posts must filter,
or scoped posts leak onto /blog. Centralize the predicate in one module (e.g.
src/lib/cms/postScope.ts) instead of scattering !== "legal" checks:
export const BLOG_ROUTE_PREFIX = "blog";
/** Legacy posts predate the field, so no prefix also means "blog". */
export function isBlogPost(post: { routePrefix?: string }): boolean {
return !post.routePrefix || post.routePrefix === BLOG_ROUTE_PREFIX;
}New prefixes are then excluded from /blog automatically, with no further edits.
2. The scoped pages. List pages fetch with the prefix; the [slug] page
must ALSO guard the prefix so a blog slug can never render on the scoped route:
// app/documentation/page.tsx
const docs = await fetchBlogPosts({ routePrefix: "documentation" });
// app/documentation/[slug]/page.tsx
const post = await fetchBlogPostBySlug(slug);
if (!post || post.routePrefix !== "documentation") notFound();generateStaticParams should use the same fetchBlogPosts({ routePrefix }) call.
3. Revalidation. Nothing extra — the CMS purges the same blog:{projectId}
tags on edit, so scoped pages revalidate with the blog as long as they use this
package's fetchers (which attach those tags).
Required env
VULCANTECH_PROJECT_ID, VULCANTECH_API_KEY (required) · VULCANTECH_CMS_URL (optional, default https://cms.vulcantech.io) · VULCANTECH_BLOG_NO_CACHE (optional, 1/true to bypass ISR while debugging).
Per-app values
None beyond env — all blog logic is shared.
