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.

How to Use SonicJS with Astro: Complete Integration Guide
TL;DR โ Connect your Astro site to SonicJS for a powerful edge-first CMS experience. Fetch content via REST API, create dynamic routes for blog posts, and deploy to Cloudflare Pages for global performance.
Key Stats:
- Sub-50ms API response times from edge
- Works with Astro SSG, SSR, and hybrid modes
- Zero config CORS โ start fetching immediately
- Deploy both to Cloudflare for optimal performance
Astro and SonicJS make a perfect pair: Astro's content-focused architecture meets SonicJS's edge-first performance. In this guide, you'll learn how to build a complete content-driven website using SonicJS as your headless CMS backend and Astro as your frontend framework.
Why Astro + SonicJS?
| Feature | Benefit |
|---|---|
| Edge-first architecture | Both run on Cloudflare's global network |
| Content-focused | Astro is built for content sites; SonicJS manages content |
| Zero JS by default | Astro ships minimal JavaScript; SonicJS delivers pure JSON |
| TypeScript support | Full type safety across your entire stack |
| Flexible rendering | SSG, SSR, or hybrid โ SonicJS supports all modes |
Prerequisites
Before we begin, make sure you have:
- Node.js 20+ โ Required for both Astro and SonicJS
- A running SonicJS instance โ Either local or deployed to Cloudflare Workers
- Basic familiarity with Astro โ Component syntax and routing
# Check your Node.js version
node --version # Should be v20.0.0 or higher
Part 1: Setting Up SonicJS Backend
If you don't already have a SonicJS instance running, let's set one up quickly.
Create a SonicJS Project
# Create a new SonicJS application
npx create-sonicjs my-cms
# Navigate to your project
cd my-cms
# Start the development server
npm run dev
# Your CMS is now running at http://localhost:8787
The create-sonicjs command automatically:
- Creates a new project directory
- Installs all dependencies
- Sets up the database schema
- Configures Cloudflare Workers
- Creates the admin user
- Runs initial migrations
Your SonicJS API is now running at http://localhost:8787.
Create a Blog Posts Collection
In the SonicJS admin panel (http://localhost:8787/admin), create a "Blog Posts" collection with these fields:
| Field | Type | Required |
|---|---|---|
| title | Text | Yes |
| slug | Text | Yes |
| excerpt | Text | No |
| content | Rich Text | Yes |
| featuredImage | Media | No |
| publishedAt | DateTime | No |
Or define it programmatically in src/collections/posts.ts:
import { defineCollection } from '@sonicjs-cms/core'
export const postsCollection = defineCollection({
name: 'blog-posts',
slug: 'blog-posts',
fields: {
title: {
type: 'string',
required: true,
maxLength: 200,
},
slug: {
type: 'string',
required: true,
unique: true,
},
excerpt: {
type: 'text',
maxLength: 300,
},
content: {
type: 'richtext',
required: true,
},
featuredImage: {
type: 'media',
},
publishedAt: {
type: 'datetime',
},
},
})
Add Sample Content
Create a few blog posts through the admin panel or via the API:
curl -X POST "http://localhost:8787/api/content" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"collectionId": "blog-posts-collection-id",
"title": "Getting Started with Astro",
"slug": "getting-started-with-astro",
"status": "published",
"data": {
"title": "Getting Started with Astro",
"slug": "getting-started-with-astro",
"excerpt": "Learn how to build fast websites with Astro",
"content": "<p>Astro is a modern web framework...</p>",
"publishedAt": "2025-12-23T00:00:00Z"
}
}'
Part 2: Setting Up Astro Frontend
Now let's create your Astro project and connect it to SonicJS.
Create an Astro Project
# Create a new Astro project
npm create astro@latest my-astro-site
# Navigate to the project
cd my-astro-site
# Install dependencies
npm install
When prompted, choose your preferences. For this guide, we recommend:
- Template: Empty
- TypeScript: Yes
- Strict TypeScript: Yes
Configure Environment Variables
Create a .env file in your Astro project root:
# .env
SONICJS_API_URL=http://localhost:8787
SONICJS_API_TOKEN=your-api-token-here
Update astro.config.mjs to load environment variables:
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
// Your Astro config
});
Create a SonicJS API Client
Create a utility file to handle all SonicJS API calls:
// src/lib/sonicjs.ts
const API_URL = import.meta.env.SONICJS_API_URL || 'http://localhost:8787';
const API_TOKEN = import.meta.env.SONICJS_API_TOKEN;
interface SonicJSResponse<T> {
data: T;
meta: {
count: number;
timestamp: string;
cache: {
hit: boolean;
source: string;
};
};
}
interface BlogPost {
id: string;
title: string;
slug: string;
status: string;
data: {
title: string;
slug: string;
excerpt?: string;
content: string;
featuredImage?: string;
publishedAt?: string;
};
created_at: number;
updated_at: number;
}
// Fetch all blog posts
export async function getBlogPosts(): Promise<BlogPost[]> {
const response = await fetch(
`${API_URL}/api/collections/blog-posts/content?filter[status][equals]=published&sort=-created_at`
);
if (!response.ok) {
throw new Error(`Failed to fetch posts: ${response.statusText}`);
}
const result: SonicJSResponse<BlogPost[]> = await response.json();
return result.data;
}
// Fetch a single blog post by slug
export async function getBlogPostBySlug(slug: string): Promise<BlogPost | null> {
const response = await fetch(
`${API_URL}/api/collections/blog-posts/content?filter[data.slug][equals]=${slug}&filter[status][equals]=published`
);
if (!response.ok) {
throw new Error(`Failed to fetch post: ${response.statusText}`);
}
const result: SonicJSResponse<BlogPost[]> = await response.json();
return result.data[0] || null;
}
// Fetch all collections
export async function getCollections() {
const response = await fetch(`${API_URL}/api/collections`);
if (!response.ok) {
throw new Error(`Failed to fetch collections: ${response.statusText}`);
}
return response.json();
}
// Generic content fetcher
export async function getContent<T>(
collection: string,
options?: {
limit?: number;
offset?: number;
sort?: string;
filters?: Record<string, string>;
}
): Promise<SonicJSResponse<T[]>> {
const params = new URLSearchParams();
if (options?.limit) params.set('limit', options.limit.toString());
if (options?.offset) params.set('offset', options.offset.toString());
if (options?.sort) params.set('sort', options.sort);
if (options?.filters) {
Object.entries(options.filters).forEach(([key, value]) => {
params.set(`filter[${key}]`, value);
});
}
const url = `${API_URL}/api/collections/${collection}/content?${params}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch content: ${response.statusText}`);
}
return response.json();
}
Part 3: Building Pages with SonicJS Content
Blog Listing Page
Create a page that displays all blog posts:
---
// src/pages/blog/index.astro
import { getBlogPosts } from '../../lib/sonicjs';
import Layout from '../../layouts/Layout.astro';
const posts = await getBlogPosts();
---
<Layout title="Blog">
<main>
<h1>Blog</h1>
<div class="posts-grid">
{posts.map((post) => (
<article class="post-card">
{post.data.featuredImage && (
<img
src={post.data.featuredImage}
alt={post.data.title}
loading="lazy"
/>
)}
<h2>
<a href={`/blog/${post.data.slug}`}>
{post.data.title}
</a>
</h2>
{post.data.excerpt && (
<p>{post.data.excerpt}</p>
)}
{post.data.publishedAt && (
<time datetime={post.data.publishedAt}>
{new Date(post.data.publishedAt).toLocaleDateString()}
</time>
)}
</article>
))}
</div>
</main>
</Layout>
<style>
.posts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 2rem;
}
.post-card {
border: 1px solid #e5e5e5;
border-radius: 8px;
overflow: hidden;
}
.post-card img {
width: 100%;
height: 200px;
object-fit: cover;
}
.post-card h2, .post-card p, .post-card time {
padding: 0 1rem;
}
.post-card a {
text-decoration: none;
color: inherit;
}
.post-card a:hover {
text-decoration: underline;
}
</style>
Dynamic Blog Post Page
Create a dynamic route for individual blog posts:
---
// src/pages/blog/[slug].astro
import { getBlogPosts, getBlogPostBySlug } from '../../lib/sonicjs';
import Layout from '../../layouts/Layout.astro';
// For static site generation, define all possible paths
export async function getStaticPaths() {
const posts = await getBlogPosts();
return posts.map((post) => ({
params: { slug: post.data.slug },
props: { post },
}));
}
// Get the post from props (SSG) or fetch it (SSR)
const { slug } = Astro.params;
const { post: propPost } = Astro.props;
const post = propPost || await getBlogPostBySlug(slug!);
if (!post) {
return Astro.redirect('/404');
}
---
<Layout title={post.data.title}>
<article>
<header>
<h1>{post.data.title}</h1>
{post.data.publishedAt && (
<time datetime={post.data.publishedAt}>
Published on {new Date(post.data.publishedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</time>
)}
</header>
{post.data.featuredImage && (
<img
src={post.data.featuredImage}
alt={post.data.title}
class="featured-image"
/>
)}
<div class="content" set:html={post.data.content} />
<footer>
<a href="/blog">โ Back to Blog</a>
</footer>
</article>
</Layout>
<style>
article {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
header {
margin-bottom: 2rem;
}
h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
time {
color: #666;
font-size: 0.9rem;
}
.featured-image {
width: 100%;
max-height: 500px;
object-fit: cover;
border-radius: 8px;
margin-bottom: 2rem;
}
.content {
line-height: 1.8;
font-size: 1.1rem;
}
.content :global(h2) {
margin-top: 2rem;
}
.content :global(pre) {
background: #1e1e1e;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
}
.content :global(code) {
font-family: 'Fira Code', monospace;
}
footer {
margin-top: 3rem;
padding-top: 2rem;
border-top: 1px solid #e5e5e5;
}
</style>
Base Layout
Create a simple layout component:
---
// src/layouts/Layout.astro
interface Props {
title: string;
description?: string;
}
const { title, description = 'My Astro site powered by SonicJS' } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<title>{title}</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<nav>
<a href="/">Home</a>
<a href="/blog">Blog</a>
</nav>
<slot />
</body>
</html>
<style is:global>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.6;
color: #333;
}
nav {
padding: 1rem 2rem;
background: #f5f5f5;
display: flex;
gap: 1rem;
}
nav a {
text-decoration: none;
color: inherit;
}
nav a:hover {
text-decoration: underline;
}
</style>
Part 4: Advanced Patterns
Server-Side Rendering (SSR)
For dynamic content that changes frequently, use Astro's SSR mode:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({
output: 'server',
adapter: cloudflare(),
});
Then update your dynamic route to fetch on each request:
---
// src/pages/blog/[slug].astro (SSR version)
import { getBlogPostBySlug } from '../../lib/sonicjs';
import Layout from '../../layouts/Layout.astro';
const { slug } = Astro.params;
const post = await getBlogPostBySlug(slug!);
if (!post) {
return new Response(null, {
status: 404,
statusText: 'Not Found',
});
}
---
<!-- Rest of the template remains the same -->
Hybrid Rendering
Use Astro's hybrid mode to mix static and dynamic pages:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({
output: 'hybrid',
adapter: cloudflare(),
});
Mark specific pages as server-rendered:
---
// src/pages/blog/[slug].astro
export const prerender = false; // This page is server-rendered
---
Pagination
Handle large content sets with pagination:
---
// src/pages/blog/page/[page].astro
import { getContent } from '../../../lib/sonicjs';
import Layout from '../../../layouts/Layout.astro';
const POSTS_PER_PAGE = 10;
export async function getStaticPaths() {
const response = await getContent('blog-posts', {
filters: { 'status[equals]': 'published' }
});
const totalPosts = response.meta.count;
const totalPages = Math.ceil(totalPosts / POSTS_PER_PAGE);
return Array.from({ length: totalPages }, (_, i) => ({
params: { page: (i + 1).toString() },
}));
}
const { page } = Astro.params;
const currentPage = parseInt(page!);
const offset = (currentPage - 1) * POSTS_PER_PAGE;
const response = await getContent('blog-posts', {
limit: POSTS_PER_PAGE,
offset,
sort: '-created_at',
filters: { 'status[equals]': 'published' }
});
const posts = response.data;
const totalPages = Math.ceil(response.meta.count / POSTS_PER_PAGE);
---
<Layout title={`Blog - Page ${currentPage}`}>
<main>
<h1>Blog</h1>
<div class="posts">
{posts.map((post) => (
<article>
<h2><a href={`/blog/${post.data.slug}`}>{post.data.title}</a></h2>
<p>{post.data.excerpt}</p>
</article>
))}
</div>
<nav class="pagination">
{currentPage > 1 && (
<a href={`/blog/page/${currentPage - 1}`}>โ Previous</a>
)}
<span>Page {currentPage} of {totalPages}</span>
{currentPage < totalPages && (
<a href={`/blog/page/${currentPage + 1}`}>Next โ</a>
)}
</nav>
</main>
</Layout>
Category Filtering
Create category pages to filter content:
---
// src/pages/blog/category/[category].astro
import { getContent } from '../../../lib/sonicjs';
import Layout from '../../../layouts/Layout.astro';
export async function getStaticPaths() {
const response = await getContent('categories');
return response.data.map((category) => ({
params: { category: category.data.slug },
props: { categoryName: category.data.name },
}));
}
const { category } = Astro.params;
const { categoryName } = Astro.props;
const response = await getContent('blog-posts', {
filters: {
'status[equals]': 'published',
'data.category[equals]': category,
},
sort: '-created_at',
});
const posts = response.data;
---
<Layout title={`${categoryName} Posts`}>
<main>
<h1>{categoryName}</h1>
<p>{posts.length} posts in this category</p>
<div class="posts">
{posts.map((post) => (
<article>
<h2><a href={`/blog/${post.data.slug}`}>{post.data.title}</a></h2>
<p>{post.data.excerpt}</p>
</article>
))}
</div>
</main>
</Layout>
Image Optimization
Use Astro's built-in image optimization with SonicJS media:
---
// src/components/OptimizedImage.astro
import { Image } from 'astro:assets';
interface Props {
src: string;
alt: string;
width?: number;
height?: number;
}
const { src, alt, width = 800, height = 600 } = Astro.props;
---
<Image
src={src}
alt={alt}
width={width}
height={height}
format="webp"
quality={80}
/>
For remote images from SonicJS R2 storage, configure Astro:
// astro.config.mjs
export default defineConfig({
image: {
domains: ['pub-sonicjs-media-dev.r2.dev'],
remotePatterns: [{ protocol: 'https' }],
},
});
Part 5: Caching Strategies
Leverage SonicJS Caching
SonicJS includes a three-tiered caching system. For SSG sites, content is fetched at build time and cached statically. For SSR, you can leverage Cloudflare's edge caching:
// src/lib/sonicjs.ts - Add cache headers
export async function getBlogPosts(): Promise<BlogPost[]> {
const response = await fetch(
`${API_URL}/api/collections/blog-posts/content?filter[status][equals]=published`,
{
headers: {
'Cache-Control': 'max-age=300', // Cache for 5 minutes
},
}
);
// Log cache status from SonicJS
const cacheStatus = response.headers.get('X-Cache-Status');
const cacheSource = response.headers.get('X-Cache-Source');
console.log(`Cache: ${cacheStatus} from ${cacheSource}`);
const result = await response.json();
return result.data;
}
Incremental Static Regeneration Pattern
For Astro SSR with on-demand revalidation:
---
// src/pages/blog/[slug].astro
export const prerender = false;
// Set cache headers for CDN caching
Astro.response.headers.set('Cache-Control', 'public, max-age=60, s-maxage=300, stale-while-revalidate=86400');
---
Part 6: Deployment
Deploy SonicJS to Cloudflare Workers
# In your SonicJS project
npm run deploy
Note your production URL: https://my-cms.your-subdomain.workers.dev
Deploy Astro to Cloudflare Pages
Install the Cloudflare adapter:
npm install @astrojs/cloudflare
Update your Astro config:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({
output: 'hybrid', // or 'server' for full SSR
adapter: cloudflare(),
});
Update your production environment variables:
# .env.production
SONICJS_API_URL=https://my-cms.your-subdomain.workers.dev
Deploy to Cloudflare Pages:
# Build the site
npm run build
# Deploy via Wrangler
npx wrangler pages deploy ./dist
Or connect your Git repository to Cloudflare Pages for automatic deployments.
Environment Variables in Cloudflare Pages
Set your environment variables in the Cloudflare Pages dashboard:
- Go to your Pages project settings
- Navigate to Environment variables
- Add
SONICJS_API_URLwith your production SonicJS URL
Troubleshooting
CORS Issues
SonicJS has CORS enabled by default for all origins. If you encounter CORS issues:
- Verify your SonicJS instance is running
- Check the browser console for specific error messages
- Ensure you're using the correct API URL
Content Not Updating
For SSG sites, content is fetched at build time. To update:
# Rebuild and redeploy
npm run build
npx wrangler pages deploy ./dist
For SSR sites, check the cache headers and consider reducing TTL:
// In your fetch calls
const response = await fetch(url, {
headers: { 'Cache-Control': 'no-cache' }
});
404 on Dynamic Routes
Ensure your getStaticPaths function returns all possible paths:
---
export async function getStaticPaths() {
const posts = await getBlogPosts();
console.log(`Found ${posts.length} posts for static paths`);
return posts.map((post) => ({
params: { slug: post.data.slug },
}));
}
---
TypeScript Errors
Add proper types for your SonicJS responses:
// src/types/sonicjs.ts
export interface SonicJSContent<T> {
id: string;
title: string;
slug: string;
status: string;
data: T;
created_at: number;
updated_at: number;
}
export interface BlogPostData {
title: string;
slug: string;
excerpt?: string;
content: string;
featuredImage?: string;
publishedAt?: string;
}
export type BlogPost = SonicJSContent<BlogPostData>;
Key Takeaways
- SonicJS + Astro provides a powerful, edge-first content stack
- Use the API client pattern for clean, reusable data fetching
- getStaticPaths enables static generation for dynamic routes
- Choose SSG, SSR, or hybrid based on your content update frequency
- Deploy both to Cloudflare for optimal performance and co-location
- SonicJS caching provides sub-50ms response times out of the box
Next Steps
- Authentication โ Add user authentication to your Astro site
- Media Management โ Learn about image optimization and R2 storage
- Filtering & Querying โ Master advanced content queries
- Caching โ Understand SonicJS's three-tiered caching system
Have questions? Join our Discord community or open an issue on GitHub.
Happy building with Astro and SonicJS!
Related Articles

Understanding SonicJS Three-Tiered Caching Strategy
Learn how SonicJS implements a three-tiered caching strategy using memory, Cloudflare KV, and D1 to deliver sub-15ms response times globally.

Getting Started with SonicJS: Complete Beginner's Guide
Learn how to set up SonicJS, the edge-first headless CMS for Cloudflare Workers. This comprehensive guide covers installation, configuration, and your first content API.

How to Serve Custom Public Routes in SonicJS
Learn how to override the default login redirect and serve your own public pages on the root path or any custom route in SonicJS.