@sprintup-cms/sdk
v1.9.9
Published
Official SDK for SprintUp Forge CMS — typed API client, Next.js helpers, and React block renderer
Maintainers
Readme
@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
- Install
- Environment variables
- Initial setup
- Verify your connection
- Adding new pages
- Custom page layouts
- Navigation and footer — automatically rendered by the SDK via CMS Globals
- Anchor links — scroll-to-section navigation (v1.9.0+)
- SEO settings — per-page meta tags and Open Graph
- Extending with custom blocks
- ISR and caching strategy
- API reference
- Block types reference
- Troubleshooting
Install
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.4Environment variables
Add the following to your website's .env.local. Never commit these to git.
# ── 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.eduGenerate a secure webhook secret:
openssl rand -hex 32Where 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.
// lib/cms.ts
import { cmsClient } from '@sprintup-cms/sdk'
export default cmsClientFor multiple sites or custom configuration:
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.
// app/[...slug]/page.tsx
export { CMSCatchAllPage as default, generateMetadata } from '@sprintup-cms/sdk/next'How it works:
- Fetches the page by
slugfrom 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.
// 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, archivedCustom hook with extra logic:
// 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
// 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
// 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:
// 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:
- Go to CMS Admin → Content (scoped to your site).
- Click New Page.
- Choose a page type (e.g. Standard Page, Blog Post, Event).
- Fill in the title, slug, and content blocks.
- Click Publish.
- 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):
- Go to CMS Admin → Page Types.
- Click New Page Type.
- Define sections and fields.
- Create content using that type.
- In your website, customize the rendering — see Custom page layouts.
Custom page layouts
Override the default rendering for a specific page type by wrapping CMSCatchAllPage:
// 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:
// 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()andmenus.header/menus.footerare 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
Every block has an anchor ID — auto-generated from the block title (e.g. "Our Features" →
#our-features), or set manually in the editor.Add anchor nav items — In CMS Admin → Globals → Navigation, add a nav item with type "Anchor" and pick a section from the dropdown.
Cross-page support — Anchors can link to sections on other pages. The
hrefis resolved as/about#teamfor cross-page or#featuresfor same-page.
Using navItems from getGlobals()
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:
'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:
<section id={block.content.anchorId || toAnchorId(block.content.title)}>SEO settings
Every page includes SEO metadata configured in the CMS editor.
Data shape
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
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:
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_IDmatches exactly what is shown in CMS Admin → Apps. - Verify the page status is Published (not Draft).
- Confirm the API key has
readpermission 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_SECRETon 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/exitroute must exist. draftMode()requires a Next.js App Router project (not Pages Router).
Requirements
- Node.js 18+
- Next.js 14+ (for
/nextentry) - React 18+ (for
/reactentry)
License
MIT — SprintUp IO
