npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@decoupla/sdk

v0.1.5

Published

Type-safe TypeScript client for the Decoupla content management API

Readme

@decoupla/sdk

A type-safe TypeScript client for the Decoupla headless CMS with integrated CLI tools for schema management and synchronization.

  • Full Type Safety - Compile-time validation of content types, fields, and queries
  • 🚀 Zero Runtime Overhead - Thin client wrapper around the Decoupla API
  • 🔄 Automatic Schema Sync - CLI tool keeps your local definitions in sync with the backend
  • 🏗️ Content Type Definitions - Define schema once, use everywhere with full IDE support
  • 🔍 Type-Safe Filtering - Filter operations validated per field type at compile time
  • 📦 Bun & npm Compatible - Works with Bun and npm/yarn

Quick Start

1. Installation

Install the package from npm (recommended) or with Bun/Yarn. The package name is @decoupla/sdk and includes both the TypeScript client and the CLI binary.

# npm
npm install @decoupla/sdk

# yarn
yarn add @decoupla/sdk

# bun
bun add @decoupla/sdk

Notes:

  • The package exports TypeScript types; if you're using TypeScript the types are included.
  • The CLI (decoupla) is available via npx decoupla ... (npm) or bunx decoupla ... (Bun).

After installing, continue to define your content types and configuration (see the "Content Type Definition" section below.

2. Define Content Types

Define your content types with defineContentType (see the "Content Type Definition" section). Keep your content type definitions exported from a central module (for example src/content-types.ts) so both your application code and decoupla.config.ts can import the same typed definitions.

3. Sync Your Schema

Run the CLI to sync your local definitions with the backend. You can use npx (or bunx if you're on Bun) to run the installed binary:

# Preview changes (dry run)
npx decoupla sync --dry

# Apply changes
npx decoupla sync

# Verbose output
npx decoupla sync --verbose

This will create or update all content types defined in your decoupla.config.ts.

4. Use the Client

import { createClient } from '@decoupla/sdk';

const client = createClient({
  workspace: process.env.DECOUPLA_WORKSPACE!,
  apiToken: process.env.DECOUPLA_API_TOKEN!,
});

// Query entries with type safety
const posts = await client.getEntries(BlogPost, {
  filters: {
    IsPublished: { eq: true },
    ViewCount: { gte: 100 },
  },
  sort: [['ViewCount', 'DESC']],
  limit: 10,
});

// NOTE:
// The `published` option passed to `createEntry`/`updateEntry` is a server-side
// command that tells the backend whether the entry should be published immediately.
// Create an entry as draft (unpublished)

const newPostMeta = await client.createEntry(BlogPost, {
  Title: 'My First Post',
  Content: 'Hello, World!',
}, false);

// Create an entry and request preloaded relations in the response
const newPostWithPreload = await client.createEntry(BlogPost, {
  Title: 'Post with Preload',
  Content: 'This post requests preload',
  Author: 'author-id-123',
}, { published: true, preload: ['Author'] });

// Note: when you pass `preload` the client will return the full normalized entry
// inside a `{ data: ... }` payload so you can access `newPostWithPreload.data.author` directly.

// Update an entry — provide an options object for publishing and preload behavior
const updateResult = await client.updateEntry(BlogPost, newPostMeta.id, {
  Title: 'My First Post (published)'
}, { published: true, preload: ['Author'] });

Configuration

Environment Variables

Set these environment variables or pass them directly to functions:

DECOUPLA_WORKSPACE=your-workspace-id
DECOUPLA_API_TOKEN=your-api-token

Content Type Definition

Use defineContentType to define your schema:

const MyType = defineContentType({
  name: 'my_type',              // Required: slug name (snake_case)
  displayName: 'My Type',       // Optional: human-readable name
  description: 'My description', // Optional: describe the type
  fields: {
    // Field definitions here
  },
});

Note: `name` is the machine-facing slug used to identify the content type in the API and CLI. It will be normalized to snake_case by the config loader (e.g. "BlogPost" -> "blog_post", "My Type" -> "my_type"). `displayName` is optional and intended as a human-readable label shown in UIs and logs.

Important: We strongly recommend using a decoupla.config.ts file to store your workspace, apiToken, and contentTypes. The CLI reads this file when you run decoupla sync, and keeping a central config ensures the CLI and your application share the same type definitions and settings.

decoupla.config.ts and contentTypes

Your project should export a configuration file (usually decoupla.config.ts) that the CLI reads. The important piece is the contentTypes array — this is where you list the content type definitions created with defineContentType.

Example decoupla.config.ts:

import { defineConfig } from 'decoupla.js';
import { Author, BlogPost, Category } from './content-types'; // your defineContentType exports

export default defineConfig({
  workspace: process.env.DECOUPLA_WORKSPACE!,
  apiToken: process.env.DECOUPLA_API_TOKEN!,
  contentTypes: [Author, BlogPost, Category],
});

Notes:

  • The CLI (decoupla sync) reads the exported contentTypes array and uses it to compare and synchronize your local schema with the remote workspace.
  • Each item in contentTypes should be the result of defineContentType(...) (the library stores enough metadata to produce API requests and type-safe helpers).
  • Keep the file next to your content-type definitions (for example src/content-types.ts) and export each content type so both the CLI and your application code can import the same definitions.

Using content type definitions in your application code:

import { createClient } from '@decoupla/sdk';
import { BlogPost } from './content-types'; // same defineContentType exported above

const client = createClient({ workspace: '...', apiToken: '...' });

const post = await client.getEntry(BlogPost, 'post-id', { preload: ['Author'] });

Field Types

Decoupla supports the following field types:

Primitive Types

// Text
{ type: 'string', required: true }    // Short text
{ type: 'text', required: false }     // Long text
{ type: 'slug', required: false }     // URL-friendly slug

// Numbers
{ type: 'int', required: true }       // Integer
{ type: 'float', required: false }    // Decimal number

// Boolean
{ type: 'boolean', required: false }  // true/false

// Dates & Times
{ type: 'date', required: false }     // ISO date (YYYY-MM-DD)
{ type: 'time', required: false }     // ISO time (HH:MM:SS)
{ type: 'datetime', required: false } // ISO datetime

// Media
{ type: 'image', required: false }    // Image object
{ type: 'video', required: false }    // Video object

// Other
{ type: 'json', required: false }     // Arbitrary JSON

Array Types

{ type: 'string[]', required: false }
{ type: 'int[]', required: false }
{ type: 'float[]', required: false }
{ type: 'boolean[]', required: false }
{ type: 'date[]', required: false }
{ type: 'image[]', required: false }
{ type: 'video[]', required: false }

References

// Single reference
{ type: 'reference', references: [Author] }

// Multiple references (polymorphic)
{ type: 'reference', references: [Author, Reviewer, Editor] }

// Array of references
{ type: 'reference[]', references: [Comment] }

Field Options

{
  type: 'string',
  required: true,              // Field must be provided
  isLabel: true,               // Use as display name
  options: ['active', 'inactive'],  // Restrict values (string/string[] only)
}

CLI Commands

decoupla sync

Synchronize your local schema with the backend.

# Dry run - preview changes
npx decoupla sync --dry   # npm
bunx decoupla sync --dry  # bun

# Apply changes with verbose output
npx decoupla sync --verbose   # npm
bunx decoupla sync --verbose  # bun

# Apply changes silently
npx decoupla sync   # npm
bunx decoupla sync  # bun

The sync command will:

  • Create missing content types
  • Add new fields
  • Update field properties (required, type, references)
  • Remove fields that are no longer defined

decoupla validate

Validate your schema definitions:

bunx decoupla validate  # bun
npx decoupla validate   # npm

decoupla help

Show available commands:

bunx decoupla help  # bun
npx decoupla help   # npm

Client API

createClient(config)

Initialize the API client:

const client = createClient({
  workspace: 'my-workspace',
  apiToken: 'secret-token',
});

Returns

{
  getEntry: (contentTypeDef, entryId, options?) => Promise<{ data: Entry }>
  getEntries: (contentTypeDef, options?) => Promise<{ data: Entry[] }>
  // createEntry/updateEntry accept an options object { published?: boolean; preload?: PreloadSpec }.
  // When `preload` is provided the client returns the full normalized entry in `{ data: ... }`.
  createEntry: (contentTypeDef, fieldValues, publishedOrOptions?) => Promise<NormalizedEntryMetadata | { data: Entry }>
  updateEntry: (contentTypeDef, entryId, fieldValues, publishedOrOptions?) => Promise<NormalizedEntryMetadata | { data: Entry }>
  upload: (file, filename?) => Promise<UploadResult>
  inspect: () => Promise<InspectResponse>
  sync: (contentTypes) => Promise<SyncResult>
  syncWithFields: (contentTypes, options?) => Promise<SyncResult>
  deleteContentType: (contentTypeName) => Promise<void>
}

API Methods

getEntry(contentTypeDef, entryId, options?)

Fetch a single entry by ID:

const post = await client.getEntry(BlogPost, 'post-id-123', {
  preload: ['author'], // Preload references
});

console.log(post.data.title); // Type-safe field access

Options:

{
  preload?: string | string[]; // Fields to preload
}

Note: you can now explicitly select which dataset you want to read using the contentView option. Acceptable values are 'live' (published content) and 'preview' (draft/unpublished data). The default is 'live'.

The client maps contentView to the server api_type parameter. Use contentView: 'preview' to request draft/unpublished data and contentView: 'live' for published data.

Example:

const postPreview = await client.getEntry(BlogPost, 'post-id-123', {
  contentView: 'preview',
  preload: [['Child', ['Child']]]
});

Preload supports a nested-array grammar for multi-level reference preloads. Example: to preload a reference field Child and then preload its Child field, use:

// Nested-array preload grammar: [['Child', ['Child']]]
const post = await client.getEntry(BlogPost, 'post-id-123', {
  preload: [['Child', ['Child']]] // no `as const` needed with TypeScript 5+ (const generics)
});

Both getEntry and getEntries accept the same nested-array preload form and the client supports TypeScript 5 const-generic inference so inline literals do not require as const.

getEntries(contentTypeDef, options?)

Query entries with filters, sorting, and pagination:

const posts = await client.getEntries(BlogPost, {
  filters: {
    IsPublished: { eq: true },
    ViewCount: { gte: 100 },
  },
  sort: [['ViewCount', 'DESC']],
  limit: 20,
  offset: 0,
  preload: ['author'],
});

console.log(posts.data); // Array of entries

Options:

{
  filters?: TypeSafeFilters;     // Query filters (type-checked)
  sort?: [string, 'ASC' | 'DESC'][]; // Sort by fields
  limit?: number;                 // Max results
  offset?: number;                // Pagination offset
  preload?: string | string[];    // Preload references
}

Filter Operations

Filters are type-safe based on field type:

// String fields
{ Title: { eq: 'My Post' } }
{ Title: { contains: 'blog' } }
{ Title: { starts_with: 'The' } }
{ Title: { in: ['Title1', 'Title2'] } }

// Numeric fields
{ ViewCount: { gte: 100 } }
{ ViewCount: { between: [10, 100] } }
{ ViewCount: { in: [10, 20, 30] } }

// Boolean fields
{ IsPublished: { eq: true } }

// Date fields
{ CreatedAt: { gte: '2024-01-01' } }
{ CreatedAt: { between: ['2024-01-01', '2024-12-31'] } }

// Null checks
{ Email: { is_null: true } }
{ Email: { is_not_null: true } }

// Array fields - list operators
{ Tags: { any: { eq: 'featured' } } }
{ Tags: { every: { eq: 'active' } } }
{ Tags: { none: { eq: 'archived' } } }

// Reference fields
{ Author: { eq: 'author-id-123' } }
{ Author: { in: ['author-1', 'author-2'] } }

// Polymorphic references - filter by specific type
{ Content: { Author: { id: { eq: 'author-123' } } } }

Logical Operators

Combine filters with and and or:

const results = await client.getEntries(BlogPost, {
  filters: {
    and: [
      { IsPublished: { eq: true } },
      {
        or: [
          { ViewCount: { gte: 1000 } },
          { Featured: { eq: true } },
        ],
      },
    ],
  },
});

createEntry(contentTypeDef, fieldValues, published?)

Create a new entry:

// Boolean `published` positional arg
const meta = await client.createEntry(BlogPost, {
  Title: 'My First Post',
  Content: 'Hello, World!',
}, false);

// Or use the options object to pass `published` and `preload`.
// When `preload` is provided the client returns the full normalized entry
// inside `{ data: ... }` so you can read relations directly.
const created = await client.createEntry(BlogPost, {
  Title: 'Post with relations',
  Content: 'Post body',
  Author: 'author-id-123',
}, { published: true, preload: ['Author'] });

// Access metadata or full data depending on call:
console.log(meta.id);               // Entry metadata (no preload)
console.log(created.data.author);   // Preloaded relation (if requested)

Returns:

{
  id: string;
  modelId: string;
  state: string;
  lastVersion: number;
  lastPublishedVersion: number | null;
  createdAt: string;
  updatedAt: string;
}

updateEntry(contentTypeDef, entryId, fieldValues, published?)

Update an existing entry:

// Backwards-compatible boolean `published` positional arg
const updatedMeta = await client.updateEntry(BlogPost, 'post-id-123', {
  ViewCount: 150,
}, false);

// Or use options object and request preload in the response
const updated = await client.updateEntry(BlogPost, 'post-id-123', {
  ViewCount: 150,
}, { published: true, preload: ['Author'] });
// If preload was requested updated will be { data: { ...full entry... } }

upload(file, filename?)

Upload an image or video:

// From File input
const input = document.querySelector('input[type="file"]');
const file = input.files[0];
const uploaded = await client.upload(file);

// From Blob
const blob = new Blob(['data'], { type: 'image/jpeg' });
const uploaded = await client.upload(blob, 'image.jpg');

// Returns
{
  id: string;
  url: string;
  type: 'image' | 'video';
  width?: number;
  height?: number;
  format?: string;
}

inspect()

Get the current schema from the backend:

const schema = await client.inspect();

console.log(schema.data.content_types); // All content types

sync(contentTypes)

Programmatically sync content types:

const result = await client.sync([Author, BlogPost], {
  dryRun: true,
  verbose: true,
});

console.log(result.actions); // List of sync actions

syncWithFields(contentTypes, options?)

Full sync including field updates:

const result = await client.syncWithFields(
  [Author, BlogPost],
  {
    dryRun: false,
    createMissing: true,
    createMissingFields: true,
    updateFields: true,
  }
);

Examples

Blog with Authors and Comments

// decoupla.config.ts
import { defineContentType, defineConfig } from '@decoupla/sdk';

const Author = defineContentType({
  name: 'author',
  displayName: 'Author',
  fields: {
    Name: { type: 'string', required: true, isLabel: true },
    Email: { type: 'string', required: true },
    Bio: { type: 'text', required: false },
  },
});

const Comment = defineContentType({
  name: 'comment',
  displayName: 'Comment',
  fields: {
    Content: { type: 'string', required: true, isLabel: true },
    AuthorName: { type: 'string', required: true },
    AuthorEmail: { type: 'string', required: true },
  },
});

const BlogPost = defineContentType({
  name: 'blog_post',
  displayName: 'Blog Post',
  fields: {
    Title: { type: 'string', required: true, isLabel: true },
    Slug: { type: 'slug', required: true },
    Content: { type: 'text', required: true },
    FeaturedImage: { type: 'image', required: false },
    Author: {
      type: 'reference',
      required: true,
      references: [Author],
    },
    Comments: {
      type: 'reference[]',
      required: false,
      references: [Comment],
    },
    IsPublished: { type: 'boolean', required: false },
    PublishedAt: { type: 'datetime', required: false },
    Tags: { type: 'string[]', required: false },
    ViewCount: { type: 'int', required: false },
  },
});

export default defineConfig({
  workspace: process.env.DECOUPLA_WORKSPACE!,
  apiToken: process.env.DECOUPLA_API_TOKEN!,
  contentTypes: [Author, Comment, BlogPost],
});
// app.ts
import { createClient } from '@decoupla/sdk';
import config from './decoupla.config';

const client = createClient(config);

// Get published posts sorted by views
const topPosts = await client.getEntries(BlogPost, {
  filters: {
    IsPublished: { eq: true },
  },
  sort: [['ViewCount', 'DESC']],
  limit: 5,
  preload: ['author', 'comments'],
});

// Get posts by a specific author
const authorPosts = await client.getEntries(BlogPost, {
  filters: {
    Author: { eq: 'author-id-123' },
    IsPublished: { eq: true },
  },
});

// Get posts with specific tags
const taggedPosts = await client.getEntries(BlogPost, {
  filters: {
    Tags: { any: { eq: 'featured' } },
  },
});

// Create a new post
const newPost = await client.createEntry(BlogPost, {
  Title: 'My First Post',
  Slug: 'my-first-post',
  Content: '# Welcome\n\nThis is my first post!',
  Author: 'author-id-123',
  IsPublished: false,
});

// Update the post (publish it)
await client.updateEntry(BlogPost, newPost.id, {
  IsPublished: true,
  PublishedAt: new Date().toISOString(),
});

E-Commerce with Products

const Category = defineContentType({
  name: 'category',
  displayName: 'Product Category',
  fields: {
    Name: { type: 'string', required: true, isLabel: true },
    Slug: { type: 'slug', required: true },
    Description: { type: 'text', required: false },
  },
});

const Product = defineContentType({
  name: 'product',
  displayName: 'Product',
  fields: {
    Name: { type: 'string', required: true, isLabel: true },
    Slug: { type: 'slug', required: true },
    Description: { type: 'text', required: true },
    Price: { type: 'float', required: true },
    StockQuantity: { type: 'int', required: true },
    Images: { type: 'image[]', required: false },
    Category: {
      type: 'reference',
      required: true,
      references: [Category],
    },
    IsAvailable: { type: 'boolean', required: true },
    Rating: { type: 'float', required: false },
  },
});

// Get available products
const available = await client.getEntries(Product, {
  filters: { IsAvailable: { eq: true } },
  sort: [['Rating', 'DESC']],
});

// Get products by category
const categoryProducts = await client.getEntries(Product, {
  filters: {
    Category: { eq: 'category-id-123' },
  },
});

// Get products in price range
const priceFiltered = await client.getEntries(Product, {
  filters: {
    Price: { between: [10, 100] },
  },
});

Best Practices

1. Organize Content Types

Keep your content types in separate files for large projects:

// types/author.ts
export const Author = defineContentType({
  name: 'author',
  fields: { /* ... */ },
});

// types/index.ts
export * from './author';
export * from './blog-post';

// decoupla.config.ts
import { Author, BlogPost } from './types';
export default defineConfig({
  // ...
  contentTypes: [Author, BlogPost],
});

2. Use Environment Variables

Never hardcode credentials:

export default defineConfig({
  workspace: process.env.DECOUPLA_WORKSPACE!,
  apiToken: process.env.DECOUPLA_API_TOKEN!,
  contentTypes: [/* ... */],
});

3. Leverage Type Safety

Let TypeScript catch errors at compile time:

// ✅ Type-safe - TypeScript checks field names and filter operations
const posts = await client.getEntries(BlogPost, {
  filters: {
    IsPublished: { eq: true },
    ViewCount: { gte: 100 },
  },
});

// ❌ TypeScript error - field doesn't exist
// const posts = await client.getEntries(BlogPost, {
//   filters: { NonExistent: { eq: true } },
// });

// ❌ TypeScript error - invalid operation for field type
// const posts = await client.getEntries(BlogPost, {
//   filters: { Title: { gte: 100 } }, // gte is for numbers, not strings
// });

4. Use Preload for References

Fetch related entries efficiently:

const posts = await client.getEntries(BlogPost, {
  preload: ['author', 'comments'], // Fetch references in one call
});

// author and comments are now populated
for (const post of posts.data) {
  console.log(post.author); // Fully populated
}

5. Version Control Your Config

Commit decoupla.config.ts to track schema changes:

git add decoupla.config.ts
git commit -m "Add featured image to blog posts"

Troubleshooting

"Config file not found"

Make sure you have a decoupla.config.ts (or .js) in your project root.

"Invalid API token"

Verify your DECOUPLA_API_TOKEN environment variable is set correctly.

Type errors on field values

Field names in TypeScript are camelCase, but the config uses their original case. Both work:

// In config
BlogPost = defineContentType({
  fields: {
    IsPublished: { type: 'boolean' },
  },
});

// In code - use the content type definition
client.createEntry(BlogPost, {
  isPublished: true, // camelCase field names
});

Filter type mismatch

TypeScript enforces correct filter operations per field type:

// ❌ Error - gte is for numbers
{ Title: { gte: 'hello' } }

// ✅ Correct
{ Title: { eq: 'hello' } }
{ ViewCount: { gte: 100 } }