Using SonicJS with Next.js: A Complete Integration Guide
Build edge-fast content sites with Next.js 15 App Router and SonicJS โ typed fetch helpers, RSC, ISR, generateStaticParams, and Cloudflare Pages deployment.

Using SonicJS with Next.js: A Complete Integration Guide
TL;DR โ Pair Next.js 15's App Router with SonicJS to ship a fully typed, edge-cached, content-driven site. Fetch from React Server Components with the native fetch cache, statically generate dynamic routes with generateStaticParams, opt into ISR with revalidate, and proxy authenticated mutations through Route Handlers. Deploy both halves to Cloudflare for sub-50ms global response times.
Key Stats:
- Next.js 15 App Router + React 19 Server Components
- Native
fetchcache withrevalidatefor per-route ISR (60s default in this guide) force-staticfor build-time SSG,force-dynamicfor live data โ same component- Sub-50ms SonicJS API responses from the edge KV cache
- One stack, two render modes: static export and server-rendered on Cloudflare Pages
Next.js 15 and SonicJS are a natural fit. Next.js gives you the most flexible React rendering model on the market โ Server Components, streaming, ISR, and partial prerendering โ while SonicJS delivers content from the edge with KV-backed caching that keeps p95 latency under 50ms. Put them together and you get a CMS-driven site that's fast at build time, fast at request time, and trivial to deploy globally.
This guide walks through a complete integration: typed API client, Server Component data fetching, dynamic routing with generateStaticParams, ISR via revalidate, authenticated Route Handlers, and deployment to Cloudflare Pages in both static-export and server-render modes.
Why Next.js + SonicJS?
| Feature | Benefit |
|---|---|
| App Router + RSC | Fetch SonicJS content on the server with zero client JS |
Native fetch cache | Per-request revalidate and tag-based invalidation built in |
| Edge-first SonicJS | Sub-50ms responses from Cloudflare Workers + KV |
| TypeScript end-to-end | Type your collection schemas once, reuse everywhere |
| Cloudflare Pages support | Both halves of the stack on the same global network |
| Image optimization | next/image with R2-hosted media via remotePatterns |
Prerequisites
Before we begin, you'll need:
- Node.js 20+ โ required for both Next.js 15 and SonicJS
- A running SonicJS instance โ local on
:8787or deployed to Cloudflare Workers - Familiarity with the App Router โ Server Components, Route Handlers, and the
app/directory
# Verify your Node version
node --version # v20.0.0 or higher
Part 1: Spin Up the SonicJS Backend
If you don't already have SonicJS running, scaffold a project in two commands:
npx create-sonicjs my-cms
cd my-cms
npm run dev
# CMS available at http://localhost:8787
create-sonicjs provisions the database schema, registers the default collections (including blog-posts), creates the admin user, and starts the Cloudflare Workers dev server. The blog posts collection lives at src/collections/blog-posts.collection.ts and is auto-registered in src/index.ts โ see the collections guide for the full configuration reference.
Open http://localhost:8787/admin, log in, and add a few posts. Set their status to published so they appear in your Next.js frontend.
The SonicJS REST API at a Glance
Every collection you register is automatically exposed under /api/collections/{name}/content. The endpoints you'll use most:
| Endpoint | Purpose |
|---|---|
GET /api/collections | List every collection in the workspace |
GET /api/collections/{name}/content | List items (supports limit, offset, sort, filter[โฆ]) |
GET /api/collections/{name}/content/{id} | Fetch a single item by ID |
POST /api/collections/{name}/content | Create (requires Authorization: Bearer โฆ) |
PATCH /api/collections/{name}/content/{id} | Update (auth) |
DELETE /api/collections/{name}/content/{id} | Delete (auth) |
Read endpoints are public by default. Write endpoints require a JWT โ see the authentication guide. For the full payload shapes, query parameters, and pagination metadata, the API reference is the canonical source.
Part 2: Bootstrap the Next.js App
npx create-next-app@latest my-next-site \
--typescript --app --tailwind --eslint \
--src-dir --import-alias "@/*"
cd my-next-site
When prompted, accept the App Router defaults. We'll be working entirely under src/app/.
Configure Environment Variables
# .env.local
SONICJS_API_URL=http://localhost:8787
SONICJS_API_TOKEN=
# NOTE: Server-side only โ never prefix with NEXT_PUBLIC_.
# Read endpoints don't require a token; populate this when you start
# making authenticated mutations from Route Handlers.
Because the App Router renders on the server by default, you keep secrets on the server side without leaking them to the browser. Never prefix SONICJS_API_TOKEN with NEXT_PUBLIC_.
Part 3: A Typed SonicJS Fetch Helper
Create a small client that wraps fetch and exposes type-safe helpers. This is the same pattern recommended in the Astro guide, adapted for Next.js's caching primitives.
// src/lib/sonicjs.ts
import 'server-only'
const API_URL = process.env.SONICJS_API_URL ?? 'http://localhost:8787'
const API_TOKEN = process.env.SONICJS_API_TOKEN
export interface SonicJSResponse<T> {
data: T
meta: {
count: number
timestamp: string
cache: { hit: boolean; source: string }
}
}
export interface SonicJSItem<TData> {
id: string
title: string
slug: string
status: 'draft' | 'published' | 'archived'
data: TData
created_at: number
updated_at: number
}
export interface BlogPostData {
title: string
slug: string
excerpt?: string
content: string
featuredImage?: string
publishedAt?: string
}
export type BlogPost = SonicJSItem<BlogPostData>
interface FetchOptions {
// Cache for N seconds, then revalidate in the background (ISR).
revalidate?: number | false
// Tag the response so you can `revalidateTag()` from a webhook.
tags?: string[]
// Force a fresh request โ use sparingly.
noStore?: boolean
}
async function sonicFetch<T>(
path: string,
options: FetchOptions = {},
): Promise<T> {
const url = `${API_URL}${path}`
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (API_TOKEN) headers.Authorization = `Bearer ${API_TOKEN}`
const next: RequestInit['next'] = options.noStore
? undefined
: { revalidate: options.revalidate ?? 60, tags: options.tags }
const res = await fetch(url, {
headers,
cache: options.noStore ? 'no-store' : undefined,
next,
})
if (!res.ok) {
throw new Error(`SonicJS ${res.status}: ${res.statusText} (${path})`)
}
return res.json() as Promise<T>
}
// Public: list published posts (default ISR window: 60s)
export function getBlogPosts(revalidate = 60) {
return sonicFetch<SonicJSResponse<BlogPost[]>>(
'/api/collections/blog-posts/content?sort=-created_at',
{ revalidate, tags: ['blog-posts'] },
).then((r) =>
// Filter client-side for v2.x compatibility; see Troubleshooting.
r.data.filter((p) => p.status === 'published'),
)
}
// Public: fetch a single post by slug
export async function getBlogPostBySlug(slug: string) {
const r = await sonicFetch<SonicJSResponse<BlogPost[]>>(
'/api/collections/blog-posts/content',
{ revalidate: 60, tags: [`blog-post:${slug}`] },
)
return (
r.data.find((p) => p.data.slug === slug && p.status === 'published') ??
null
)
}
A few Next.js-specific notes:
'server-only'at the top is a hard guarantee โ if you accidentally import this file from a Client Component, the build fails.next: { revalidate, tags }is how App Router opts into ISR. SonicJS's edge cache and Next.js's data cache compose cleanly: SonicJS deduplicates across regions, Next.js deduplicates per route segment.- Tags let you invalidate from a webhook with
revalidateTag('blog-posts')after a publish event.
Part 4: Server Components Consuming SonicJS
Blog Listing Page
// src/app/blog/page.tsx
import Link from 'next/link'
import Image from 'next/image'
import { getBlogPosts } from '@/lib/sonicjs'
// Statically build at deploy, revalidate every 60s in the background.
export const revalidate = 60
export default async function BlogIndex() {
const posts = await getBlogPosts()
return (
<main className="mx-auto max-w-5xl px-6 py-12">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{posts.map((post) => (
<article
key={post.id}
className="rounded-xl border border-slate-200 overflow-hidden"
>
{post.data.featuredImage && (
<Image
src={post.data.featuredImage}
alt={post.data.title}
width={600}
height={400}
className="w-full h-48 object-cover"
/>
)}
<div className="p-4">
<h2 className="text-xl font-semibold mb-2">
<Link href={`/blog/${post.data.slug}`}>
{post.data.title}
</Link>
</h2>
{post.data.excerpt && (
<p className="text-slate-600">{post.data.excerpt}</p>
)}
</div>
</article>
))}
</div>
</main>
)
}
The page is a Server Component โ there's no 'use client' marker, so React renders the HTML on the server, ships zero JavaScript for this view, and Next.js caches the result.
Dynamic Post Page with generateStaticParams
// src/app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
import { getBlogPosts, getBlogPostBySlug } from '@/lib/sonicjs'
// Per-post ISR window โ overrides parent layout's revalidate.
export const revalidate = 60
// Statically generate every published slug at build time.
export async function generateStaticParams() {
const posts = await getBlogPosts()
return posts.map((p) => ({ slug: p.data.slug }))
}
// Per-page metadata โ picked up by Next.js's <head> generator.
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await getBlogPostBySlug(slug)
if (!post) return {}
return {
title: post.data.title,
description: post.data.excerpt,
openGraph: {
title: post.data.title,
description: post.data.excerpt,
images: post.data.featuredImage ? [post.data.featuredImage] : [],
},
}
}
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await getBlogPostBySlug(slug)
if (!post) notFound()
return (
<article className="mx-auto max-w-3xl px-6 py-12">
<header className="mb-8">
<h1 className="text-4xl font-bold mb-2">{post.data.title}</h1>
{post.data.publishedAt && (
<time
dateTime={post.data.publishedAt}
className="text-slate-500"
>
{new Date(post.data.publishedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
)}
</header>
{post.data.featuredImage && (
<Image
src={post.data.featuredImage}
alt={post.data.title}
width={1200}
height={630}
className="w-full rounded-xl mb-8"
priority
/>
)}
{/* Content from SonicJS Quill editor is sanitized HTML */}
<div
className="prose prose-slate max-w-none"
dangerouslySetInnerHTML={{ __html: post.data.content }}
/>
<footer className="mt-12 pt-8 border-t border-slate-200">
<Link href="/blog" className="text-blue-600 hover:underline">
โ Back to Blog
</Link>
</footer>
</article>
)
}
generateStaticParams is the App Router successor to Pages Router's getStaticPaths. Combined with revalidate = 60, every published slug is built once at deploy time and re-fetched on the next request after 60 seconds โ classic ISR, no extra config.
Configuring next/image for R2 Media
SonicJS stores uploads in Cloudflare R2 and serves them from a public bucket URL. Tell next/image it's allowed to optimize those URLs:
// next.config.ts
import type { NextConfig } from 'next'
const config: NextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'pub-sonicjs-media-dev.r2.dev',
},
{
protocol: 'https',
hostname: 'media.your-cms.example.com',
},
],
},
}
export default config
Part 5: Authenticated Mutations via Route Handlers
Read endpoints are public, but writes (create / update / delete) require a JWT. The safest pattern is a Route Handler that runs on the server, attaches the token, and proxies the request โ your token never reaches the browser.
// src/app/api/posts/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { revalidateTag } from 'next/cache'
export const runtime = 'edge' // Cloudflare-compatible
export async function POST(req: NextRequest) {
// Validate the user's session (your auth of choice โ NextAuth, Clerk, etc.)
const session = await getSession(req)
if (!session) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
const body = await req.json()
const upstream = await fetch(
`${process.env.SONICJS_API_URL}/api/collections/blog-posts/content`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.SONICJS_API_TOKEN}`,
},
body: JSON.stringify(body),
},
)
if (!upstream.ok) {
const err = await upstream.text()
return NextResponse.json({ error: err }, { status: upstream.status })
}
// Bust the listing cache so the new post shows up on next request.
revalidateTag('blog-posts')
return NextResponse.json(await upstream.json(), { status: 201 })
}
// Stub โ replace with your real auth lookup
async function getSession(_req: NextRequest) {
return { userId: 'usr_demo' }
}
The same pattern works for PATCH, DELETE, file uploads to /api/media, and any other mutation endpoint. See the SDKs page for typed clients that wrap these endpoints if you'd rather skip the manual fetch.
Webhook-Driven Revalidation
Wire SonicJS to call a Next.js Route Handler whenever content changes, and ISR becomes near-instantaneous:
// src/app/api/revalidate/route.ts
import { NextResponse, type NextRequest } from 'next/server'
import { revalidateTag } from 'next/cache'
export async function POST(req: NextRequest) {
const secret = req.headers.get('x-sonicjs-webhook-secret')
if (secret !== process.env.SONICJS_WEBHOOK_SECRET) {
return NextResponse.json({ error: 'forbidden' }, { status: 403 })
}
const { collection, slug } = await req.json()
revalidateTag(collection) // e.g. 'blog-posts'
if (slug) revalidateTag(`blog-post:${slug}`)
return NextResponse.json({ revalidated: true })
}
Combine this with SonicJS's caching layers and you get the best of both worlds: aggressive default caching, surgical invalidation on writes.
Part 6: Static Export vs. Server Rendering on Cloudflare
Next.js on Cloudflare Pages supports two deployment shapes. Pick whichever matches your content cadence.
Option A โ Full Static Export
Best for marketing sites and docs where content changes a few times a day at most.
// next.config.ts
const config: NextConfig = {
output: 'export', // pure static HTML/CSS/JS โ no server runtime
images: { unoptimized: true }, // required for static export
}
export default config
npm run build
npx wrangler pages deploy ./out
Every page is built ahead of time using generateStaticParams and uploaded as static assets. No cold starts, no compute, just edge HTML. Trade-off: no ISR, no Route Handlers โ re-deploy to update.
Option B โ Server Rendering on Cloudflare Pages
Best for sites with frequent content updates, authenticated pages, or live data.
npm install -D @cloudflare/next-on-pages
Add a build script:
// package.json
{
"scripts": {
"build:cf": "next build && npx @cloudflare/next-on-pages",
"preview:cf": "wrangler pages dev .vercel/output/static",
"deploy:cf": "npm run build:cf && wrangler pages deploy .vercel/output/static"
}
}
npm run deploy:cf
You keep ISR, Route Handlers, revalidateTag, and next/image optimization โ all running on Cloudflare's edge runtime. Set SONICJS_API_URL, SONICJS_API_TOKEN, and SONICJS_WEBHOOK_SECRET in the Cloudflare Pages dashboard under Settings โ Environment variables.
Co-Locate the CMS
For the lowest possible latency, deploy SonicJS to Cloudflare Workers in the same account:
# In your SonicJS project
npm run deploy
# https://my-cms.<your-subdomain>.workers.dev
Now your Next.js app and SonicJS share the same global network โ internal API calls hop between Workers and Pages without ever leaving Cloudflare.
Part 7: Performance Tuning
Tag-Based Cache Strategy
A solid default: tag every list response with the collection name and every detail response with {collection}:{slug}. Then your webhook handler can precision-invalidate either or both.
// List โ tag the whole collection
sonicFetch('/api/collections/blog-posts/content', {
tags: ['blog-posts'],
})
// Detail โ tag the specific slug + the parent collection
sonicFetch(`/api/collections/blog-posts/content/${id}`, {
tags: ['blog-posts', `blog-post:${slug}`],
})
Streaming with Suspense
Long-running queries shouldn't block the shell. Wrap them in <Suspense> to stream the rest of the page first:
import { Suspense } from 'react'
export default function HomePage() {
return (
<main>
<Hero />
<Suspense fallback={<PostListSkeleton />}>
<RecentPosts /> {/* awaits getBlogPosts() */}
</Suspense>
</main>
)
}
Watch the Cache Headers
SonicJS returns X-Cache-Status (HIT/MISS) and X-Cache-Source (memory, kv, or origin) on every read. Log them in development to confirm your cache strategy is working:
const res = await fetch(url, { next: { revalidate: 60 } })
if (process.env.NODE_ENV === 'development') {
console.log({
status: res.headers.get('X-Cache-Status'),
source: res.headers.get('X-Cache-Source'),
})
}
Troubleshooting
Filters return all rows in v2.x
Server-side filter[โฆ] parameters land in a follow-up SonicJS release. Until then, fetch the collection and filter client-side (the helpers above already do this).
Image complains about a missing hostname
Add the SonicJS R2 bucket to next.config.ts's images.remotePatterns. Static-export builds need images.unoptimized: true instead.
'server-only' errors
You imported @/lib/sonicjs from a Client Component. Either drop the 'use client' directive or move the data fetch to a Server Component parent and pass results down as props.
Route Handler returns 401 in production
Your SONICJS_API_TOKEN isn't set in the Cloudflare Pages environment. Add it under Settings โ Environment variables, then redeploy.
ISR doesn't update after a publish
Either reduce revalidate or add a webhook from SonicJS to /api/revalidate (see Part 5). On-demand revalidateTag is always faster than waiting for the timer.
Key Takeaways
- Next.js 15 + SonicJS gives you a fully typed, edge-cached content stack on a single platform.
- Server Components keep CMS calls on the server โ no client JS, no leaked tokens, native
fetchcaching. generateStaticParams+revalidateis the clean App Router replacement for the oldgetStaticPaths+revalidatepair.- Route Handlers proxy authenticated mutations and call
revalidateTagto keep ISR in lockstep with publishes. - Cloudflare Pages runs both shapes โ pure static export for low-change sites, full SSR/ISR for live ones โ alongside SonicJS Workers.
Next Steps
- API reference โ every endpoint, query param, and response shape
- Collections โ designing schemas that your Next.js types can reuse
- Authentication โ wiring user logins through to SonicJS-protected routes
- Caching โ how SonicJS's three-tier cache composes with Next.js's data cache
- SDKs โ typed clients that wrap the REST API for you
Have questions or want to share what you're shipping? Join us on Discord or GitHub.
Happy building with Next.js and SonicJS!
Related Articles

How to Use SonicJS with Astro: Complete Integration Guide
Learn how to integrate SonicJS headless CMS with Astro for blazing-fast static and server-rendered websites. Step-by-step guide covering setup, content fetching, dynamic routing, and deployment.

Using Emdash with SonicJS: Run Parallel AI Coding Agents on Your CMS
Learn how to pair Emdash, the open-source agentic development environment, with SonicJS. Run multiple coding agents in parallel across isolated git worktrees to build collections, plugins, and frontends faster.

SonicJS Plugins: How to Extend Your CMS
Build, configure, and ship SonicJS plugins with TypeScript โ custom routes, lifecycle hooks, DB-backed settings, and admin pages on Cloudflare Workers.