@decoupla/sdk
v0.1.5
Published
Type-safe TypeScript client for the Decoupla content management API
Maintainers
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/sdkNotes:
- The package exports TypeScript types; if you're using TypeScript the types are included.
- The CLI (
decoupla) is available vianpx decoupla ...(npm) orbunx 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 --verboseThis 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-tokenContent 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 exportedcontentTypesarray and uses it to compare and synchronize your local schema with the remote workspace. - Each item in
contentTypesshould be the result ofdefineContentType(...)(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 JSONArray 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 # bunThe 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 # npmdecoupla help
Show available commands:
bunx decoupla help # bun
npx decoupla help # npmClient 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 accessOptions:
{
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 entriesOptions:
{
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 typessync(contentTypes)
Programmatically sync content types:
const result = await client.sync([Author, BlogPost], {
dryRun: true,
verbose: true,
});
console.log(result.actions); // List of sync actionssyncWithFields(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 } }