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/blog

Prefer to browse or download it yourself? View the repo on GitHub (use Code → Download ZIP, then copy app/blog out of it).

Setup

  1. 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. 2

    Copy the app/blog folder into your project

    Run the degit command above (or download it from GitHub) so you have app/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. 3

    Add your credentials

    Put your key in .env.local. Keep it server-side. Never expose it with a NEXT_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. 4

    Ship it

    Run npm run dev and 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 trail

The 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.