npm package: @sprintup-cms/sdk
Description: Official SDK for SprintUp Forge CMS — typed API client, Next.js helpers, and React block renderer
Author: SprintUp
Latest version: 1.10.3

# @sprintup-cms/sdk

Official SDK for **SprintUp Forge CMS** — typed API client, Next.js App Router helpers, and a React block renderer.

---

## Table of contents

1. [Install](#install)
2. [Environment variables](#environment-variables)
3. [Initial setup](#initial-setup)
   - [Connect to the CMS](#connect-to-the-cms)
   - [Catch-all page route](#catch-all-page-route)
   - [On-demand revalidation webhook](#on-demand-revalidation-webhook)
   - [Preview mode exit](#preview-mode-exit)
   - [Sitemap](#sitemap)
4. [Verify your connection](#verify-your-connection)
5. [Adding new pages](#adding-new-pages)
6. [Custom page layouts](#custom-page-layouts)
7. [Navigation and footer](#navigation-and-footer) — automatically rendered by the SDK via CMS Globals
8. [Anchor links](#anchor-links) — scroll-to-section navigation (v1.9.0+)
9. [SEO settings](#seo-settings) — per-page meta tags and Open Graph
10. [Extending with custom blocks](#extending-with-custom-blocks)
11. [ISR and caching strategy](#isr-and-caching-strategy)
12. [API reference](#api-reference)
13. [Block types reference](#block-types-reference)
14. [Troubleshooting](#troubleshooting)

---

## Install

```bash
npm install @sprintup-cms/sdk@^1.9.4
# or
pnpm add @sprintup-cms/sdk@^1.9.4
# or
yarn add @sprintup-cms/sdk@^1.9.4
```

---

## Environment variables

Add the following to your website's `.env.local`. **Never commit these to git.**

```env
# ── Required ──────────────────────────────────────────────────────────────────
# Base URL of your SprintUp Forge CMS deployment
NEXT_PUBLIC_CMS_URL=https://your-cms.vercel.app

# API key from CMS Admin → Settings → API Keys → Create key
CMS_API_KEY=cmsk_xxxxxxxxxxxxxxxxxxxx

# App ID from CMS Admin → Settings → Apps
# (shown in the URL when you select a site: /admin/dashboard?app=school-website)
CMS_APP_ID=school-website

# ── Required for webhooks ──────────────────────────────────────────────────────
# Secret that the CMS will send with every webhook request — validate on your end
CMS_WEBHOOK_SECRET=your-random-secret

# ── Optional ──────────────────────────────────────────────────────────────────
# Public URL of this website — used by sitemap generation
NEXT_PUBLIC_SITE_URL=https://www.yourschool.edu
```

Generate a secure webhook secret:

```bash
openssl rand -hex 32
```

**Where to find your values in the CMS:**

| Variable | Location in CMS Admin |
|---|---|
| `NEXT_PUBLIC_CMS_URL` | The domain your CMS is deployed to |
| `CMS_API_KEY` | Settings → API Keys → Create new key |
| `CMS_APP_ID` | Settings → Apps → click your site → copy App ID |
| `CMS_WEBHOOK_SECRET` | Settings → Webhooks → add secret when registering the URL |

---

## Initial setup

### Connect to the CMS

The SDK reads your environment variables automatically. No extra configuration is required for the default singleton client.

```ts
// lib/cms.ts
import { cmsClient } from '@sprintup-cms/sdk'
export default cmsClient
```

For multiple sites or custom configuration:

```ts
import { createCMSClient } from '@sprintup-cms/sdk'

export const schoolCms = createCMSClient({
  baseUrl: process.env.NEXT_PUBLIC_CMS_URL,
  apiKey:  process.env.CMS_API_KEY,
  appId:   process.env.CMS_APP_ID,
})
```

---

### Catch-all page route

This single file automatically renders **every page** published in your CMS — no manual routing needed.

```ts
// app/[...slug]/page.tsx
export { CMSCatchAllPage as default, generateMetadata } from '@sprintup-cms/sdk/next'
```

How it works:
- Fetches the page by `slug` from the CMS on each request (ISR, 60s revalidation).
- Falls through to `notFound()` when the slug does not exist in the CMS.
- Activates draft/preview mode automatically when `draftMode()` is enabled.
- Renders `<CMSBlocks>` for standard pages and structured content for page-type pages.

---

### On-demand revalidation webhook

When an editor publishes or updates a page in the CMS, this webhook fires to instantly clear the ISR cache for that page.

```ts
// app/api/cms-revalidate/route.ts
export { POST } from '@sprintup-cms/sdk/next'
```

Then register the webhook in your **CMS Admin → Settings → Webhooks**:

```
URL:    https://www.yourschool.edu/api/cms-revalidate
Secret: (same value as CMS_WEBHOOK_SECRET)
Events: published, deleted, archived
```

Custom hook with extra logic:

```ts
// app/api/cms-revalidate/route.ts
import { createRevalidateHandler } from '@sprintup-cms/sdk/next'

export const POST = createRevalidateHandler({
  secret: process.env.CMS_WEBHOOK_SECRET,
  onRevalidate: async ({ slug, event }) => {
    console.log(`CMS revalidated /${slug} — event: ${event}`)
    // e.g. update a search index, notify Slack, etc.
  },
})
```

---

### Preview  mode exit

```ts
// app/api/cms-preview/exit/route.ts
export { previewExitGET as GET } from '@sprintup-cms/sdk/next'
```

The CMS editor links to `/api/cms-preview/exit?redirect=/your-slug` to leave draft mode. This handler disables `draftMode()` and redirects the editor back to the live page.

---

### Sitemap

```ts
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { cmsClient } from '@sprintup-cms/sdk'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const data = await cmsClient.getSitemap()
  if (!data?.enabled) return []

  return data.urls.map(url => ({
    url:             `${process.env.NEXT_PUBLIC_SITE_URL}${url.loc}`,
    lastModified:    url.lastmod,
    changeFrequency: url.changefreq as MetadataRoute.Sitemap[0]['changeFrequency'],
    priority:        url.priority,
  }))
}
```

---

## Verify your connection

Add a temporary status check page to confirm your API key and App ID are working:

```ts
// app/cms-status/page.tsx  (remove before going to production)
import { cmsClient } from '@sprintup-cms/sdk'

export default async function CmsStatusPage() {
  const status = await cmsClient.getStatus()
  if (!status) return <p>CMS connection failed — check your environment variables.</p>

  return (
    <div style={{ padding: 32, fontFamily: 'monospace' }}>
      <h1>CMS Status</h1>
      <p>App ID: {status.appId}</p>
      <p>Total pages: {status.totalPages}</p>
      <p>Published: {status.publishedPages}</p>
      <pre>{JSON.stringify(status.pages.slice(0, 5), null, 2)}</pre>
    </div>
  )
}
```

---

## Adding new pages

You **never write code** to add a new page. All content management happens in the CMS:

1. Go to **CMS Admin → Content** (scoped to your site).
2. Click **New Page**.
3. Choose a page type (e.g. Standard Page, Blog Post, Event).
4. Fill in the title, slug, and content blocks.
5. Click **Publish**.
6. The webhook fires, your Next.js site revalidates within seconds, and the page is live at `https://yoursite.com/<slug>`.

To add a **custom page type** (e.g. Programme, Staff Profile):

1. Go to **CMS Admin → Page Types**.
2. Click **New Page Type**.
3. Define sections and fields.
4. Create content using that type.
5. In your website, customize the rendering — see [Custom page layouts](#custom-page-layouts).

---

## Custom page layouts

Override the default rendering for a specific page type by wrapping `CMSCatchAllPage`:

```tsx
// app/[...slug]/page.tsx
import { notFound } from 'next/navigation'
import { cmsClient } from '@sprintup-cms/sdk'
import { CMSBlocks } from '@sprintup-cms/sdk/react'
import type { Metadata } from 'next'

export async function generateMetadata({ params }: { params: Promise<{ slug: string[] }> }): Promise<Metadata> {
  const { slug } = await params
  const page = await cmsClient.getPage(slug.join('/'))
  if (!page) return { title: 'Not Found' }
  return {
    title:       page.seo?.title       || page.title,
    description: page.seo?.description || '',
  }
}

export default async function Page({ params }: { params: Promise<{ slug: string[] }> }) {
  const { slug } = await params
  const page = await cmsClient.getPage(slug.join('/'))
  if (!page) notFound()

  // Render a blog post with a custom layout
  if (page.pageType === 'blog-post') {
    return (
      <article className="max-w-2xl mx-auto py-16 px-6">
        <h1 className="text-4xl font-bold">{page.title}</h1>
        <time className="text-sm text-gray-500">{page.publishedAt}</time>
        <CMSBlocks blocks={page.blocks} />
      </article>
    )
  }

  // Fall back to default layout for all other page types
  return (
    <main className="max-w-5xl mx-auto py-12 px-6">
      <h1 className="text-4xl font-bold mb-8">{page.title}</h1>
      <CMSBlocks blocks={page.blocks} />
    </main>
  )
}
```

---

## Navigation and footer

> **As of v1.8.62**, navigation and footer are managed as **CMS Globals** — configured entirely in the CMS Admin under **Globals → Navigation** and **Globals → Footer**. You do not need to write any header or footer code. The SDK renders them automatically.

### How it works

`CMSCatchAllPage` (the SDK's catch-all page handler) automatically fetches the active globals for your app and renders:
- `<CMSHeader>` — a sticky top nav bar with your logo, nav links, and CTA buttons
- `<CMSFooter>` — a configurable section grid with optional footer bottom bar

**You do not need to create `components/layout/header.tsx` or `components/layout/footer.tsx`.**

### Configuring navigation in the CMS

Go to **CMS Admin → Globals → Navigation** and add items:

| Item type | Rendered as | Options |
|---|---|---|
| `link` | Nav link in the centre area | Label, URL, open in new tab |
| `button` | CTA button, right-aligned | Label, URL, variant: `primary` / `outline` / `ghost` |
| `dropdown` | Link with children | Label, URL, child links |

### Configuring footer in the CMS

Go to **CMS Admin → Globals → Footer** and add sections in any order:

| Section type | Description |
|---|---|
| `brand` | Logo image and tagline |
| `links` | A column of links with a custom title — add as many as you need |
| `contact` | Email, phone, address with a custom section title |
| `social` | Facebook, X, Instagram, LinkedIn, YouTube icons |

Optionally enable **Footer Bottom** (a full-width legal bar):
- Copyright text
- Legal links (Privacy Policy, Terms, etc.)

### Links: internal vs external

When adding links inside footer sections, the link input auto-searches your published CMS pages as you type. Select a page to create an internal link, or type a full URL (`https://...`) for an external link. External links open in a new tab automatically.

### Custom layout: accessing globals manually

If you are building a custom page outside `CMSCatchAllPage`, you can access globals directly:

```tsx
// Only needed for custom layouts — CMSCatchAllPage handles this automatically
import { cmsClient } from '@sprintup-cms/sdk'

const globals = await cmsClient.getGlobals()
const nav    = globals?.nav     // CMSPage with sectionData.items[]
const footer = globals?.footer  // CMSPage with sectionData.sections[]
```

> **Deprecated:** `cmsClient.getSiteStructure()` and `menus.header` / `menus.footer` are the old approach and should not be used for navigation or footer.

---

## Anchor links

> **Added in v1.9.0** — Navigation items can now scroll to specific sections on any page.

### How it works

1. **Every block has an anchor ID** — auto-generated from the block title (e.g. "Our Features" → `#our-features`), or set manually in the editor.

2. **Add anchor nav items** — In CMS Admin → Globals → Navigation, add a nav item with type "Anchor" and pick a section from the dropdown.

3. **Cross-page support** — Anchors can link to sections on other pages. The `href` is resolved as `/about#team` for cross-page or `#features` for same-page.

### Using `navItems` from `getGlobals()`

```ts
const { navItems } = await cmsClient.getGlobals()

// navItems already have href resolved:
// navItems[0].href === "/about#team"     (cross-page)
// navItems[1].href === "#features"       (same-page)
```

### Handling anchor clicks

For same-page anchors, use smooth scrolling instead of navigation:

```tsx
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'

function NavLink({ href, item, children, className }) {
  const pathname = usePathname()
  const hashIdx = href.indexOf('#')
  const isSamePage = hashIdx >= 0 && (href.slice(0, hashIdx) === '' || href.slice(0, hashIdx) === pathname)

  if (item?.type === 'anchor' && isSamePage) {
    return (
      <a href={href} className={className}
        onClick={(e) => {
          e.preventDefault()
          document.querySelector(href.slice(hashIdx))?.scrollIntoView({ behavior: 'smooth' })
          window.history.pushState(null, '', href)
        }}>
        {children}
      </a>
    )
  }
  return <Link href={href} className={className}>{children}</Link>
}
```

### Block anchor IDs

Render `id` on block root elements so the browser can scroll to them:

```tsx
<section id={block.content.anchorId || toAnchorId(block.content.title)}>
```

---

## SEO settings

Every page includes SEO metadata configured in the CMS editor.

### Data shape

```ts
interface CMSPageSeo {
  title?: string          // Meta title (overrides page.title)
  description?: string    // Meta description
  keywords?: string[]     // Meta keywords
  ogImage?: string        // Open Graph image URL
  noIndex?: boolean       // Exclude from search engines
}
```

### Using with Next.js `generateMetadata`

```ts
export async function generateMetadata({ params }): Promise<Metadata> {
  const page = await cmsClient.getPage(slug)
  if (!page) return { title: 'Not Found' }

  return {
    title:       page.seo?.title || page.title,
    description: page.seo?.description,
    keywords:    page.seo?.keywords?.join(', '),
    robots:      page.seo?.noIndex ? 'noindex,nofollow' : undefined,
    openGraph: {
      title:       page.seo?.title || page.title,
      description: page.seo?.description,
      images:      page.seo?.ogImage ? [page.seo.ogImage] : undefined,
    },
  }
}
```

Global SEO defaults (site name, default OG image) are merged automatically by the CMS API — you always receive the resolved values.

---

## Extending with custom blocks

Override any built-in block or add a completely new block type using the `custom` prop:

```tsx
import { CMSBlocks } from '@sprintup-cms/sdk/react'
import { MyVideoPlayer } from '@/components/video-player'
import { ProgrammeCard } from '@/components/programme-card'

<CMSBlocks
  blocks={page.blocks}
  custom={{
    // Override built-in video block with your own player
    'video': (block) => <MyVideoPlayer src={block.data?.url} />,

    // Add a completely custom block type created in the CMS
    'programme-card': (block) => (
      <ProgrammeCard
        title={block.data?.title}
        duration={block.data?.duration}
        applyUrl={block.data?.applyUrl}
      />
    ),
  }}
/>
```

The `custom` prop is a `Record<blockType, (block: CMSBlock) => React.ReactNode>`. It takes priority over all built-in renderers.

---

## ISR and caching strategy

| Data | Revalidation | Cache tag |
|---|---|---|
| Individual page | 60 seconds | `cms-page-{slug}` |
| All pages list | 60 seconds | `cms-pages-{appId}` |
| Page type schema | 3600 seconds | `cms-page-type-{id}` |
| Globals (nav + footer) | 60 seconds | `cms-globals-{appId}` |
| Sitemap | 3600 seconds | `cms-sitemap-{appId}` |
| Status check | No cache | — |
| ~~Site structure~~ | ~~300 seconds~~ | Deprecated — use Globals |

The revalidation webhook calls `revalidateTag('cms-page-{slug}')` and `revalidatePath('/{slug}')` when the CMS publishes a page, giving you **instant updates** without a full rebuild.

---

## API reference

### `cmsClient`

| Method | Description |
|---|---|
| `getPage(slug)` | Fetch a single published page by slug |
| `getPages(options?)` | Fetch multiple pages — filterable by `type`, `group`, `page`, `perPage` |
| `getBlogPosts()` | Shorthand for `getPages({ type: 'blog-post' })` |
| `getEvents()` | Shorthand for `getPages({ type: 'event-page' })` |
| `getAnnouncements()` | Shorthand for `getPages({ type: 'announcement-page' })` |
| `getPageType(id)` | Fetch a page type schema by ID |
| `getGlobals()` | Fetch navigation and footer globals — returns `{ nav, footer }`. CMSCatchAllPage calls this automatically. |
| `getPreviewPage(token)` | Fetch a draft page for preview mode |
| `getPageWithPreview(slug, token?)` | Fetch page — preview if token present, live otherwise |
| `getSitemap()` | Fetch all published slugs with sitemap metadata |
| `getStatus()` | Connectivity check — returns counts and page list |
| ~~`getSiteStructure()`~~ | **Deprecated.** Used the old Site Structure editor. Navigation and footer are now CMS Globals — use `getGlobals()` or rely on CMSCatchAllPage. |

---

## Block types reference

| Block type | Key fields in `block.data` |
|---|---|
| `heading` | `text`, `level` (1–4) |
| `text` | `text` |
| `richtext` | `content` (HTML string) |
| `image` | `src`, `alt`, `caption` |
| `hero` | `title`, `subtitle`, `badge`, `primaryButton`, `primaryUrl`, `secondaryButton`, `secondaryUrl`, `alignment` |
| `centered-hero` | `title`, `subtitle`, `badge`, `primaryButton`, `primaryUrl`, `secondaryButton`, `secondaryUrl`, `backgroundImage`, `backgroundColor`, `overlayOpacity` |
| `product-hero` | `title`, `subtitle`, `badge`, `primaryButton`, `primaryUrl`, `image`, `imageAlt`, `browserChrome`, `urlBar`, `trustedBy[]`, `alignment` |
| `bento-hero` | `title`, `subtitle`, `badge`, `primaryButton`, `primaryUrl`, `cards[]` (`title`, `description`, `icon`, `featured`), `alignment` |
| `minimal-hero` | `eyebrow`, `title`, `subtitle`, `primaryButton`, `primaryUrl`, `secondaryButton`, `secondaryUrl`, `alignment` |
| `split-hero` | `title`, `subtitle`, `button`, `buttonUrl`, `image`, `imageAlt`, `imagePosition` (`left`/`right`), `alignment` |
| `cta` | `title`, `subtitle`, `primaryButton`, `primaryUrl`, `secondaryButton`, `secondaryUrl` |
| `faq` | `title`, `items[]` — each `{ question, answer }` |
| `stats` | `items[]` — each `{ value, label, description }` |
| `testimonial` | `quote`, `author`, `role`, `avatar` |
| `quote` | `quote`, `author`, `source` |
| `alert` | `message`, `type` (info/success/warning/error) |
| `divider` | — |
| `spacer` | `size` (sm/md/lg/xl) |
| `video` | `url` (YouTube or Vimeo), `title`, `autoplay` |

---

## Troubleshooting

**Pages return empty (`getPage` returns `null`)**

- Check `CMS_APP_ID` matches exactly what is shown in CMS Admin → Apps.
- Verify the page status is **Published** (not Draft).
- Confirm the API key has `read` permission for the app.

**`getStatus()` returns `null`**

- All three env vars (`NEXT_PUBLIC_CMS_URL`, `CMS_API_KEY`, `CMS_APP_ID`) must be set.
- The CMS URL must not have a trailing slash.
- Test the API key directly: `curl -H "X-CMS-API-Key: cmsk_xxx" https://your-cms.vercel.app/api/v1/school-website/status`

**Webhook not firing / pages stale**

- Confirm the webhook URL is registered in CMS Admin → Settings → Webhooks.
- `CMS_WEBHOOK_SECRET` on both sides must match exactly.
- Check your deployment logs for `[sprintup-cms] revalidate error:` messages.

**Navigation or footer not showing**

- Go to **CMS Admin → Globals → Navigation** (or Footer) and check that content is published (status = Published).
- Ensure at least one item/section is added. An empty globals document renders nothing.
- Changes are cached for 60 seconds. Hard-refresh or wait a moment after publishing.
- Do not use `getSiteStructure()` — it does not return globals data.

**Preview mode not working**

- The `/api/cms-preview/exit` route must exist.
- `draftMode()` requires a Next.js App Router project (not Pages Router).

---

## Requirements

- Node.js 18+
- Next.js 14+ (for `/next` entry)
- React 18+ (for `/react` entry)

---

## License

MIT — SprintUp IO