Docs
Add a blog to your Next.js site
Spook hosts your articles and your Next.js app renders them from a read-only API. No CMS, no database, no webhooks, and nothing extra to npm install. Copy one folder, set two env vars, and you have a full SEO blog at /blog, complete with a table of contents, FAQ accordion, related posts, and RSS. Freshness is handled by Next.js ISR: publish in Spook and posts appear on the next revalidation.
What you get
- Auto-generated table of contents with scroll-spy: sticky sidebar on desktop (beside the title and hero), always visible on mobile
- Accessible, animated FAQ accordion
- Reading time, author, and publish date
- Share buttons with SVG icons (copy link, X, LinkedIn) at the bottom of each article
- Related posts (“Keep reading”)
- Breadcrumbs with BreadcrumbList structured data
- SEO metadata + Article / FAQ JSON-LD (built by Spook)
- Paginated post list of responsive cards
- RSS 2.0 feed at /blog/rss.xml + a sitemap at /blog/sitemap.xml
- IndexNow key file at /blog/indexnow.txt - Spook pings Bing, Yandex and friends (the indexes AI assistants search) the moment a post goes live
- Light theme, scoped so it won’t touch the rest of your site
Get the folder
The template is a public GitHub repo. Copy just the app/blog folder into your existing Next.js app with one command:
npx degit VictorKildahl/spook-nextjs-blog/app/blog app/blogPrefer to browse or download it yourself? View the repo on GitHub (use Code → Download ZIP, then copy app/blog out of it).
Setup
- 1
Turn on the integration in Spook
In your site's Publishing settings, choose NextJS and click Generate API key. Copy the API base URL and key shown there. - 2
Copy the app/blog folder into your project
Run thedegitcommand above (or download it from GitHub) so you haveapp/blog/…. Styling comes with it. The folder is self-contained, so there's nothing to add to your global CSS and no packages to install. - 3
Add your credentials
Put your key in.env.local. Keep it server-side. Never expose it with aNEXT_PUBLIC_prefix.# .env.local (server-side only - never prefix with NEXT_PUBLIC_) SPOOK_BLOG_API_URL=https://<your-deployment>.convex.site SPOOK_BLOG_API_KEY=spook_blog_xxxxxxxx # Used by the blog sitemap + RSS feed: NEXT_PUBLIC_SITE_URL=https://yoursite.com - 4
Ship it
Runnpm run devand open/blog. Deploy, and every article you publish live in Spook shows up automatically.
The folder
One folder, copied under app/blog/. Only _lib/spook.ts is Spook-specific; the pages and components are ordinary Next.js. Folders prefixed with _ are private and never routed.
app/blog/
├── layout.tsx # imports blog.css, scopes styles to /blog
├── blog.css # self-contained styles (delete to use your own)
├── page.tsx # post list + pagination
├── [slug]/page.tsx # the article page
├── rss.xml/route.ts # RSS 2.0 feed
├── indexnow.txt/route.ts # IndexNow key file (instant Bing/Yandex indexing)
├── sitemap.ts # /blog/sitemap.xml
├── _lib/
│ ├── spook.ts # the Spook read-API client
│ ├── content.ts # heading ids + table of contents + reading time
│ └── format.ts # date / reading-time helpers
└── _components/
├── table-of-contents.tsx # scroll-spy TOC (client)
├── faq.tsx # FAQ accordion (client)
├── share-buttons.tsx # copy / social share (client)
├── article-body.tsx # renders the article HTML
├── post-card.tsx # a card in the list
├── pagination.tsx # numbered pagination
└── breadcrumbs.tsx # breadcrumb trailThe Spook client - _lib/spook.ts
The one file that talks to Spook. The pages call it as plain async functions; ISR (revalidate) keeps everything fresh.
// app/blog/_lib/spook.ts - the only Spook-specific file
import "server-only";
const API_BASE = (process.env.SPOOK_BLOG_API_URL ?? "").replace(/\/$/, "");
const API_KEY = process.env.SPOOK_BLOG_API_KEY ?? "";
const REVALIDATE_SECONDS = 3600;
async function feed(path: string): Promise<Response | null> {
if (!API_BASE || !API_KEY) throw new Error("Set SPOOK_BLOG_API_URL and SPOOK_BLOG_API_KEY");
try {
return await fetch(`${API_BASE}${path}`, {
headers: { Authorization: `Bearer ${API_KEY}` },
next: { revalidate: REVALIDATE_SECONDS },
});
} catch { return null; }
}
export async function getArticles(limit = 100) {
const res = await feed(`/blog/articles?limit=${limit}`);
return res?.ok ? (await res.json()).articles ?? [] : [];
}
export async function getArticle(slug: string) {
const res = await feed(`/blog/articles/${encodeURIComponent(slug)}`);
return res?.ok ? (await res.json()).article ?? null : null;
}
export async function getMeta() {
const res = await feed("/blog/meta");
return res?.ok ? await res.json() : null;
}The table of contents - _lib/content.ts
The article body arrives as HTML. A single dependency-free pass slugs the headings, injects matching ids, and returns the table of contents, so the sidebar links always resolve, and a scroll-spy highlights your place as you read.
// app/blog/_lib/content.ts - inject ids + build the table of contents
// A single pass over the article HTML gives the body its anchor-linkable
// headings and the TOC that points at them, so the two can never drift apart.
const { html, toc } = withHeadingIds(article.contentHtml);
// <TableOfContents items={toc} /> + <ArticleBody html={html} />API reference
Prefer to build your own pages? Call the API directly. Base URL is your SPOOK_BLOG_API_URL; authenticate with Authorization: Bearer <key>. Only published articles are ever returned.
GET/blog/articles
Published articles, newest first (summary fields). Supports ?limit= and ?cursor= for pagination.
GET/blog/articles/{slug}
A single published article: full HTML + Markdown body, FAQs, and ready-to-embed JSON-LD.
GET/blog/meta
Your site's name, domain, language, and IndexNow key, used by the blog header, RSS feed, and /blog/indexnow.txt.
Example /blog/articles/{slug} response
{
"site": { "name": "Your Site", "domain": "yoursite.com" },
"article": {
"id": "k17c…",
"title": "How to pack for a ski trip",
"slug": "how-to-pack-ski-trip",
"teaser": "Short intro shown in the list and above the article.",
"featuredSnippet": "One-paragraph answer optimized for Google.",
"contentHtml": "<p>Article body as HTML…</p>",
"contentMarkdown": "## How to pack…",
"metaTitle": "How to pack for a ski trip | Your Site",
"metaDescription": "A practical packing guide for your next ski holiday.",
"faqs": [{ "question": "What boots do I need?", "answer": "…" }],
"language": "en",
"featuredImageUrl": "https://…",
"featuredImageAlt": "A packed ski bag",
"publishedAt": "2026-07-05T12:00:00.000Z",
"url": "https://yoursite.com/blog/how-to-pack-ski-trip",
"author": "Your Name",
"structuredData": { "@context": "https://schema.org", "@graph": [] },
"jsonLd": "<script type=\"application/ld+json\">…</script>"
}
}Styling & images
Styling lives in blog.css (imported by layout.tsx) and is scoped under a .blog-root wrapper, so it never leaks into the rest of your site. Retheme the blog by overriding the CSS variables at the top of that file, or delete it and style the blog-* classes yourself, or swap .blog-content for prose if you use the Tailwind typography plugin. Featured images arrive as absolute URLs and render with a plain <img>, so there's nothing to configure.
Looking for a push-based CMS integration instead? See WordPress, Ghost, Shopify, REST & webhooks.