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/:collectionGETOptionalList items with filter/sort/pagination
/api/:collection/:idGETOptionalGet one item by ID
/api/:collectionPOSTRequiredCreate an item
/api/:collection/:idPUTRequiredUpdate an item
/api/:collection/: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/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

npx create-sonicjs@latest 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/blog_posts. 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
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/blog_posts
{
  "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@sonicjs.com","password":"sonicjs!"}' | jq -r .token)

Create (POST)

curl -X POST https://my-api.your-subdomain.workers.dev/api/blog_posts \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "title": "Hello, edge",
    "slug": "hello-edge",
    "status": "published",
    "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",
    "author": "Jane",
    "created_at": 1730732400000,
    "updated_at": 1730732400000
  }
}

Read (GET)

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

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

Update (PUT)

curl -X PUT https://my-api.your-subdomain.workers.dev/api/blog_posts/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/blog_posts/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/blog_posts?status=published"

# Pagination
curl "https://my-api.../api/blog_posts?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/blog_posts?filter[title][contains]=edge"

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

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

# Multiple statuses at once
curl "https://my-api.../api/blog_posts?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/blog_posts" \
  --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/blog_posts" \
  --data-urlencode 'sort=[{"field":"publishedAt","order":"desc"}]'

# Multi-column sort
curl -G "https://my-api.../api/blog_posts" \
  --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/blog_posts?${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.