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 Build a Blog with SonicJS and Cloudflare Workers
Step-by-step tutorial on building a blazingly fast blog using SonicJS headless CMS deployed on Cloudflare Workers with D1 database and R2 storage.