Tutorials10 min read

Building a REST API with SonicJS in 10 Minutes

Build a production-ready REST API on Cloudflare Workers with SonicJS. Define a collection, deploy globally, and query with filters, sort, and pagination.

SonicJS Team

3D isometric visualization of REST API architecture with HTTP method nodes connected by glowing data pathways to a central server

Building a REST API with SonicJS in 10 Minutes

TL;DR โ€” Define a collection in TypeScript, run npm run deploy, and SonicJS gives you a fully-typed REST API on Cloudflare Workers โ€” complete with filtering, sorting, pagination, KV-cached reads, and JWT-protected writes. No route boilerplate. No ORM glue. No server.

Key Stats:

  • 10 minutes from npm create to a globally deployed REST API
  • 4 CRUD endpoints auto-generated per collection (GET list, GET one, POST, PUT, DELETE)
  • 16 filter operators built in: equals, contains, greater_than, in, like, and 11 more
  • Up to 1000 items per request with cursor-friendly limit and offset
  • Sub-millisecond cached reads via Cloudflare KV โ€” X-Cache-Status: HIT headers included

If you've ever built a REST API the traditional way, you know the drill: scaffold an Express app, wire up an ORM, write the same five CRUD handlers for the tenth time, deploy to a single region, and add caching as a separate project six months later. SonicJS collapses all of that into a single TypeScript file plus a deploy command.

This tutorial walks you from zero to a production REST API on Cloudflare's edge โ€” including filtering, sorting, pagination, cache headers, and JWT-protected writes. The code samples map directly to the real @sonicjs-cms/core API surface, so you can paste them into your project and they'll work.

What You Get for Free

Before we write any code, here's what SonicJS auto-generates the moment you register a collection:

EndpointMethodAuthPurpose
/api/collections/:name/contentGETOptionalList items with filter/sort/pagination
/api/content/:idGETOptionalGet one item by ID
/api/contentPOSTRequiredCreate an item
/api/content/:idPUTRequiredUpdate an item
/api/content/:idDELETERequiredDelete an item

Plus a few you'll appreciate as your project grows:

  • /api/ โ€” OpenAPI 3.0 spec for the entire CMS
  • /api/health โ€” health check with database and KV status
  • /api/collections โ€” list every active collection and its schema
  • /api/content/check-slug โ€” slug uniqueness check (great for admin UIs)

The reads run through a three-tiered cache (in-memory โ†’ Cloudflare KV โ†’ D1), and the writes invalidate the cache automatically.

Prerequisites

You need three things before you start:

  • Node.js 20+
  • A Cloudflare account (the free tier handles this whole tutorial)
  • Wrangler CLI โ€” Cloudflare's deploy tool
node --version          # v20.0.0 or higher
npm install -g wrangler
wrangler login

If you've never deployed a Cloudflare Worker before, our Getting Started guide walks through the account-linking step in detail.

Step 1: Scaffold the Project

npm create sonicjs-app my-api
cd my-api
npm install

This drops a working SonicJS project into my-api/. The structure is intentionally small:

my-api/
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ index.ts                    # App entry point
โ”‚   โ””โ”€โ”€ collections/
โ”‚       โ””โ”€โ”€ blog-posts.collection.ts
โ”œโ”€โ”€ wrangler.toml
โ””โ”€โ”€ package.json

Open src/index.ts and you'll see the canonical SonicJS bootstrap โ€” three function calls and you're done.

import { Hono } from 'hono'
import { createSonicJSApp, registerCollections } from '@sonicjs-cms/core'
import type { SonicJSConfig } from '@sonicjs-cms/core'
import blogPostsCollection from './collections/blog-posts.collection'

registerCollections([blogPostsCollection])

const config: SonicJSConfig = {
  collections: { autoSync: true },
}

const coreApp = createSonicJSApp(config)

const app = new Hono()
app.route('/', coreApp)

export default app

createSonicJSApp() returns a Hono app with every route โ€” admin UI, auth endpoints, and the REST API โ€” already mounted. registerCollections() is what makes your custom content types show up in the API.

Step 2: Define a Collection

Collections are the heart of SonicJS. They describe a content type with a JSON-schema-like definition, and SonicJS uses that schema to generate REST endpoints, admin forms, and SQL migrations.

Open src/collections/blog-posts.collection.ts:

import type { CollectionConfig } from '@sonicjs-cms/core'

export default {
  name: 'blog_posts',
  displayName: 'Blog Posts',
  description: 'Manage blog posts for the public site',
  icon: '๐Ÿ“',

  schema: {
    type: 'object',
    properties: {
      title: {
        type: 'string',
        title: 'Title',
        required: true,
        maxLength: 200,
      },
      slug: {
        type: 'slug',
        title: 'URL Slug',
        required: true,
        maxLength: 200,
      },
      excerpt: {
        type: 'textarea',
        title: 'Excerpt',
        maxLength: 500,
      },
      content: {
        type: 'quill',
        title: 'Content',
        required: true,
      },
      author: {
        type: 'string',
        title: 'Author',
        required: true,
      },
      publishedAt: {
        type: 'datetime',
        title: 'Published At',
      },
      status: {
        type: 'select',
        title: 'Status',
        enum: ['draft', 'published', 'archived'],
        default: 'draft',
      },
    },
    required: ['title', 'slug', 'content', 'author'],
  },

  listFields: ['title', 'author', 'status', 'publishedAt'],
  searchFields: ['title', 'excerpt', 'author'],
} satisfies CollectionConfig

A few details worth calling out:

  • name is what shows up in the URL โ€” /api/collections/blog_posts/content. Stick to snake_case.
  • required at the field level enforces required-ness at the schema layer. The top-level required array is for JSON-schema compatibility.
  • type: 'slug' auto-generates a URL-safe slug from the title in the admin UI.
  • listFields controls which columns show up in the admin list view; searchFields drives the admin search box.

Other field types you can use today: string, textarea, quill (rich text), media, datetime, select, checkbox, number, and slug. See the collections reference for the full list.

Step 3: Provision D1 and Deploy

SonicJS persists content to Cloudflare D1 โ€” a SQLite database that runs on the edge alongside your Worker. The CLI does the wiring for you:

wrangler d1 create my-api-db

Wrangler prints a snippet like this โ€” paste it into your wrangler.toml:

[[d1_databases]]
binding = "DB"
database_name = "my-api-db"
database_id = "abc123-..."

[[kv_namespaces]]
binding = "CACHE"
id = "your-kv-id"

Then run the migrations and deploy:

npm run db:migrate:prod
npm run deploy

Wrangler returns a URL like https://my-api.your-subdomain.workers.dev. Your REST API is now live in 300+ Cloudflare data centers, with no servers to manage.

Step 4: Make Your First Request

Start with the OpenAPI root to confirm everything is wired up:

curl https://my-api.your-subdomain.workers.dev/api/

You'll get the full OpenAPI 3.0 spec back as JSON. Now list your (empty) collection:

curl https://my-api.your-subdomain.workers.dev/api/collections/blog_posts/content
{
  "data": [],
  "meta": {
    "count": 0,
    "timestamp": "2026-05-04T12:00:00.000Z",
    "filter": { "where": { "and": [...] }, "limit": 50 },
    "cache": { "hit": false, "source": "database" },
    "timing": { "total": 12, "execution": 8, "unit": "ms" }
  }
}

The meta block is your friend: it tells you the cache status, timing, and the parsed filter SonicJS used to build the SQL.

Step 5: Create, Read, Update, Delete

Writes require an authenticated request. Grab a JWT by logging in as the admin user that the bootstrap created (or use magic links / OAuth if you've enabled them):

TOKEN=$(curl -s -X POST https://my-api.your-subdomain.workers.dev/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"admin@example.com","password":"admin123"}' | jq -r .token)

Create (POST)

curl -X POST https://my-api.your-subdomain.workers.dev/api/content \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "collectionId": "blog_posts",
    "title": "Hello, edge",
    "slug": "hello-edge",
    "status": "published",
    "data": {
      "author": "Jane",
      "excerpt": "First post on the edge.",
      "content": "<p>Welcome.</p>",
      "publishedAt": "2026-05-04T12:00:00Z"
    }
  }'

The response is a 201 Created with the full content row, including the auto-generated UUID:

{
  "data": {
    "id": "9f1c2b1a-...",
    "title": "Hello, edge",
    "slug": "hello-edge",
    "status": "published",
    "collectionId": "blog_posts",
    "data": { "author": "Jane", ... },
    "created_at": 1730732400000,
    "updated_at": 1730732400000
  }
}

Read (GET)

# By ID
curl https://my-api.your-subdomain.workers.dev/api/content/9f1c2b1a-...

# Whole collection
curl https://my-api.your-subdomain.workers.dev/api/collections/blog_posts/content

Update (PUT)

curl -X PUT https://my-api.your-subdomain.workers.dev/api/content/9f1c2b1a-... \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"title": "Hello, edge โ€” revised"}'

PUT requests are partial updates โ€” send only the fields that changed. SonicJS merges them and bumps updated_at.

Delete (DELETE)

curl -X DELETE https://my-api.your-subdomain.workers.dev/api/content/9f1c2b1a-... \
  -H "Authorization: Bearer $TOKEN"

Returns { "success": true } and invalidates every cache key that referenced the item.

Step 6: Filtering, Sorting, Pagination

This is where the auto-generated API really earns its keep. Three query-string conventions cover 95% of real-world reads:

Simple Filters

The two most common filters are exposed as plain query params:

# Status filter
curl "https://my-api.../api/collections/blog_posts/content?status=published"

# Pagination
curl "https://my-api.../api/collections/blog_posts/content?limit=10&offset=20"

limit is capped at 1000 โ€” request more and SonicJS clamps it silently.

Bracket-Syntax Filters

For anything beyond status, use filter[field][operator]=value:

# Find posts whose title contains "edge"
curl "https://my-api.../api/collections/blog_posts/content?filter[title][contains]=edge"

# Posts created after a timestamp
curl "https://my-api.../api/collections/blog_posts/content?filter[created_at][greater_than]=1730000000000"

# Posts whose slug starts with "hello-"
curl "https://my-api.../api/collections/blog_posts/content?filter[slug][starts_with]=hello-"

# Multiple statuses at once
curl "https://my-api.../api/collections/blog_posts/content?filter[status][in]=published,archived"

The full operator list, all 16 of them: equals, not_equals, greater_than, greater_than_equal, less_than, less_than_equal, like, contains, starts_with, ends_with, in, not_in, all, exists, near, within, intersects.

JSON where Clauses

For boolean logic that doesn't fit query strings, pass a JSON where blob:

curl -G "https://my-api.../api/collections/blog_posts/content" \
  --data-urlencode 'where={"or":[{"field":"status","operator":"equals","value":"published"},{"field":"status","operator":"equals","value":"archived"}]}'

AND and OR groups can nest, and conditions are parameterized โ€” SonicJS uses the same QueryFilterBuilder internally to defend against SQL injection.

Sorting

# Sort by published date descending
curl -G "https://my-api.../api/collections/blog_posts/content" \
  --data-urlencode 'sort=[{"field":"publishedAt","order":"desc"}]'

# Multi-column sort
curl -G "https://my-api.../api/collections/blog_posts/content" \
  --data-urlencode 'sort=[{"field":"status","order":"asc"},{"field":"created_at","order":"desc"}]'

Cache Headers and Performance

Every read response carries cache metadata in the headers and the meta block:

X-Cache-Status: HIT
X-Cache-Source: kv
X-Cache-TTL: 287
X-Response-Time: 4ms

X-Cache-Source is one of memory, kv, or database. The first request after a write hits the database and warms KV; subsequent reads anywhere in the world get sub-millisecond responses.

For a deep dive into how the cache layers interact, see the caching strategy guide. For the database tuning details โ€” D1 settings, indexes, and the read-vs-write tradeoff โ€” read up on D1 and the SonicJS database layer.

Securing Writes

Every mutating endpoint (POST, PUT, DELETE) is gated by SonicJS's built-in requireAuth() and requireRole() middleware. The defaults:

  • Allowed roles: admin, editor, author
  • Auth source: Authorization: Bearer <jwt> header or the auth_token cookie
  • JWT TTL: 30 days, with a 7-day refresh window

If you want public write access for a specific endpoint (uncommon, but useful for things like contact-form submissions), build a custom Hono route that bypasses the role check and writes to the same D1 table.

A Real-World Read Pattern

Here's how a frontend might fetch the latest 10 published posts, with cache headers respected and pagination wired up:

async function getRecentPosts(page = 0, perPage = 10) {
  const params = new URLSearchParams({
    status: 'published',
    limit: String(perPage),
    offset: String(page * perPage),
    sort: JSON.stringify([{ field: 'publishedAt', order: 'desc' }]),
  })

  const res = await fetch(
    `https://my-api.example.com/api/collections/blog_posts/content?${params}`,
    { headers: { 'Accept': 'application/json' } }
  )

  const json = await res.json()
  return {
    posts: json.data,
    cacheHit: res.headers.get('X-Cache-Status') === 'HIT',
    timing: json.meta.timing,
  }
}

That's it. Globally distributed reads, automatic caching, and you write zero backend code per route.

Next Steps

You've got a working REST API. Where to go from here:

Key Takeaways

  • One TypeScript collection definition gives you a full REST API โ€” list, read, create, update, delete.
  • SonicJS supports 16 filter operators, multi-column sort, and limit + offset pagination on every collection endpoint.
  • Reads are cached in memory + Cloudflare KV + D1, with cache headers exposed on every response.
  • Writes are gated by JWT auth and role-based middleware โ€” no extra glue code required.
  • Deployment is a single npm run deploy to Cloudflare's global edge.

Have questions or want to share what you're shipping? Join us on Discord or GitHub.

Happy building!

#rest-api#tutorial#cloudflare-workers#typescript#collections#edge

Share this article

Related Articles

Isometric illustration of SonicJS content collections architecture with stacked content cards, field shapes, and glowing connections to a central edge database
Tutorials

Creating Custom Collections in SonicJS

Build production-ready content models in SonicJS with TypeScript-first collections, 30+ field types, references, validation, and auto-generated REST endpoints.

Isometric illustration of files flowing from a client device into Cloudflare R2 storage cylinders over glowing blue trajectories
Tutorials

File Uploads with SonicJS and Cloudflare R2

Upload, validate, and serve images, video, and documents with SonicJS and Cloudflare R2 โ€” multipart uploads, MIME checks, signed URLs, and image transforms.