Astro Integration

Complete guide to integrating SonicJS as a headless CMS backend for your Astro frontend. Build blazing-fast content-driven websites with edge-first performance.


Overview

Astro and SonicJS make a perfect pair: Astro's content-focused architecture meets SonicJS's edge-first performance.

🚀

Sub-50ms API Responses

Edge-first architecture delivers content globally in milliseconds

Zero Config CORS

Start fetching content immediately - no configuration needed

🔄

All Rendering Modes

Works with Astro SSG, SSR, and hybrid modes

☁️

Cloudflare Native

Deploy both to Cloudflare for optimal co-location performance

Why Astro + SonicJS?

FeatureBenefit
Edge-first architectureBoth run on Cloudflare's global network
Content-focusedAstro is built for content sites; SonicJS manages content
Zero JS by defaultAstro ships minimal JavaScript; SonicJS delivers pure JSON
TypeScript supportFull type safety across your entire stack
Flexible renderingSSG, SSR, or hybrid - SonicJS supports all modes

Setup

Prerequisites

  • Node.js 20+ - Required for both Astro and SonicJS
  • A running SonicJS instance - Local or deployed to Cloudflare Workers

Create Astro Project

Create 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:

  • Template: Empty
  • TypeScript: Yes
  • Strict TypeScript: Yes

Configure Environment

Create a .env file in your Astro project root:

.env

SONICJS_API_URL=http://localhost:8787
SONICJS_API_TOKEN=your-api-token-here

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';

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 published 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;
}

// Generic content fetcher with filtering
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();
}

Fetching Content

Blog Listing Page

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;
  }
</style>

Dynamic Routes

Static Generation (SSG)

For static sites, use getStaticPaths to pre-render all blog posts:

src/pages/blog/[slug].astro

---
import { getBlogPosts, getBlogPostBySlug } from '../../lib/sonicjs';
import Layout from '../../layouts/Layout.astro';

// Define all possible paths at build time
export async function getStaticPaths() {
  const posts = await getBlogPosts();

  return posts.map((post) => ({
    params: { slug: post.data.slug },
    props: { post },
  }));
}

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;
  }

  .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;
  }
</style>

Rendering Modes

Static Site Generation (SSG)

Default Astro mode. Content is fetched at build time.

astro.config.mjs

import { defineConfig } from 'astro/config';

export default defineConfig({
  // SSG is the default
});

Server-Side Rendering (SSR)

For dynamic content that changes frequently:

astro.config.mjs

import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';

export default defineConfig({
  output: 'server',
  adapter: cloudflare(),
});

SSR version of dynamic route:

src/pages/blog/[slug].astro (SSR)

---
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',
  });
}
---

<Layout title={post.data.title}>
  <!-- Template content -->
</Layout>

Hybrid Rendering

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:

Server-rendered page

---
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>

Deployment

Deploy SonicJS to Cloudflare Workers

Deploy SonicJS

# In your SonicJS project
npm run deploy

# Note your production URL:
# https://my-cms.your-subdomain.workers.dev

Deploy Astro to Cloudflare Pages

Install Adapter

npm install @astrojs/cloudflare

astro.config.mjs

import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';

export default defineConfig({
  output: 'hybrid',
  adapter: cloudflare(),
  image: {
    domains: ['pub-sonicjs-media-dev.r2.dev'],
    remotePatterns: [{ protocol: 'https' }],
  },
});

Deploy to Cloudflare Pages

# Build the site
npm run build

# Deploy via Wrangler
npx wrangler pages deploy ./dist

Environment Variables

Set your production environment variables in the Cloudflare Pages dashboard:

  1. Go to your Pages project settings
  2. Navigate to Environment variables
  3. Add SONICJS_API_URL with your production SonicJS URL

Troubleshooting

CORS Issues

SonicJS has CORS enabled by default for all origins. If you encounter CORS issues:

  1. Verify your SonicJS instance is running
  2. Check the browser console for specific error messages
  3. Ensure you're using the correct API URL

Content Not Updating

For SSG sites, content is fetched at build time. To update:

Rebuild and Deploy

npm run build
npx wrangler pages deploy ./dist

For SSR sites, check cache headers:

Disable Cache

const response = await fetch(url, {
  headers: { 'Cache-Control': 'no-cache' }
});

404 on Dynamic Routes

Ensure getStaticPaths returns all possible paths:

Debug Static 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 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>;

API Filtering

SonicJS provides powerful filtering via query parameters:

OperatorDescriptionExample
equalsExact matchfilter[status][equals]=published
not_equalsInverse matchfilter[status][not_equals]=draft
greater_thanNumeric/date comparisonfilter[created_at][greater_than]=1704067200
less_thanNumeric/date comparisonfilter[views][less_than]=100
likeCase-insensitive searchfilter[title][like]=astro
containsString presencefilter[data.tags][contains]=tutorial
inArray value matchingfilter[category][in]=news,updates

Filter Examples

// Published posts sorted by date
`/api/collections/blog-posts/content?filter[status][equals]=published&sort=-created_at`

// Posts in specific category
`/api/collections/blog-posts/content?filter[data.category][equals]=technology`

// Posts with pagination
`/api/content?limit=10&offset=20`

// JSON field querying
`/api/collections/blog-posts/content?filter[data.tags][contains]=astro`

Next Steps

Was this page helpful?