The @mdcms/sdk package provides a type-safe TypeScript client for querying content from the MDCMS API. It handles authentication headers, response parsing, pagination, and error handling.
Installation
Client Setup
Create a client instance with your server URL, API key, project, and environment:
import { createClient } from "@mdcms/sdk";
const client = createClient({
serverUrl: "https://cms.example.com",
apiKey: process.env.MDCMS_API_KEY!,
project: "marketing-site",
environment: "production",
});
Configuration Options
| Option | Type | Required | Description |
|---|
serverUrl | string | Yes | The base URL of your MDCMS server (e.g., https://cms.example.com). |
apiKey | string | Yes | API key with the appropriate scopes (prefixed with mdcms_key_). |
project | string | Yes | Project slug. Sets the X-MDCMS-Project header on every request. |
environment | string | Yes | Environment name. Sets the X-MDCMS-Environment header on every request. |
fetch | function | No | Custom fetch implementation. Useful for testing, caching, or request instrumentation. Defaults to the global fetch. |
Querying — get()
The get() method retrieves a single document by ID, slug, or path. It returns the full ContentDocumentResponse object.
By ID
const post = await client.get("BlogPost", {
id: "550e8400-e29b-41d4-a716-446655440000",
});
By Slug
const post = await client.get("BlogPost", {
slug: "hello-world",
});
With Locale and References
const post = await client.get("BlogPost", {
slug: "hello-world",
locale: "fr",
resolve: ["author"],
draft: false,
});
get() Options
| Option | Type | Description |
|---|
id | string | Document UUID. Mutually exclusive with slug and path. |
slug | string | Slug frontmatter value. Mutually exclusive with id and path. |
path | string | Document path. Mutually exclusive with id and slug. |
locale | string | BCP 47 locale tag. |
resolve | string[] | Reference field names to resolve (one level deep). |
draft | boolean | When true, return the draft version instead of the published version. |
When using slug or path, the query must match exactly one document. If
multiple documents match (e.g., same slug in different locales without
specifying locale), the SDK throws an MdcmsClientError with code
AMBIGUOUS_RESULT.
Querying — list()
The list() method retrieves a paginated list of documents matching the given filters.
const posts = await client.list("BlogPost", {
locale: "en",
published: true,
sort: "updatedAt",
order: "desc",
limit: 10,
});
// Access results
for (const post of posts.data) {
console.log(post.frontmatter.title);
}
// Access pagination metadata
console.log(posts.pagination.total); // 42
console.log(posts.pagination.hasMore); // true
list() Options
| Option | Type | Description |
|---|
locale | string | BCP 47 locale tag. |
published | boolean | Filter by publication status. |
isDeleted | boolean | Include soft-deleted documents. |
hasUnpublishedChanges | boolean | Filter to documents with pending draft changes. |
draft | boolean | Return draft content instead of published content. |
resolve | string[] | Reference field names to resolve. |
limit | number | Results per page (default 20, max 100). |
offset | number | Number of results to skip. |
sort | string | Sort field: createdAt, updatedAt, or path. |
order | string | Sort direction: asc or desc. |
Return Type
{
data: ContentDocumentResponse[];
pagination: {
total: number;
limit: number;
offset: number;
hasMore: boolean;
};
}
Response Type
Both get() and list() return ContentDocumentResponse objects with the following fields:
| Field | Type | Description |
|---|
documentId | string | Stable UUID identifier. |
translationGroupId | string | UUID linking locale variants of the same content. |
project | string | Project slug. |
environment | string | Environment name. |
path | string | Filesystem-like path (e.g., content/blog/hello-world). |
type | string | Content type name (e.g., BlogPost). |
locale | string | BCP 47 locale tag, or __mdcms_default__ for non-localized types. |
format | string | md or mdx. |
isDeleted | boolean | Whether the document is soft-deleted. |
hasUnpublishedChanges | boolean | Whether the draft differs from the published version. |
version | number | Current version number (incremented on each publish). |
publishedVersion | number | null | Version number of the published snapshot, or null if never published. |
draftRevision | number | Incrementing counter for draft saves. Used for optimistic concurrency. |
frontmatter | object | Structured data matching the content type schema. References are UUIDs unless resolved. |
body | string | Markdown or MDX content. |
resolveErrors | object | Map of field paths to resolve error objects. Empty when no resolve errors. |
createdBy | string | User or API key ID that created the document. |
createdAt | string | ISO 8601 creation timestamp. |
updatedBy | string | User or API key ID that last updated the document. |
updatedAt | string | ISO 8601 last-update timestamp. |
Error Handling
The SDK provides two error classes for distinguishing between server errors and client-side issues:
import { MdcmsApiError, MdcmsClientError } from "@mdcms/sdk";
try {
const post = await client.get("BlogPost", { slug: "missing" });
} catch (error) {
if (error instanceof MdcmsApiError) {
// Server returned an error response
console.error(error.statusCode); // 404
console.error(error.code); // "NOT_FOUND"
console.error(error.message); // "Document not found"
console.error(error.requestId); // "req_abc123"
console.error(error.timestamp); // "2026-01-15T09:30:00.000Z"
console.error(error.details); // {}
}
if (error instanceof MdcmsClientError) {
// Client-side error (network, parsing, ambiguity)
console.error(error.code); // e.g., "NOT_FOUND"
console.error(error.message); // Human-readable description
}
}
MdcmsApiError
Thrown when the server returns a non-2xx response. Properties:
| Property | Type | Description |
|---|
statusCode | number | HTTP status code (e.g., 404, 409, 500). |
code | string | Machine-readable error code (e.g., NOT_FOUND, SCHEMA_HASH_MISMATCH). |
message | string | Human-readable error description. |
requestId | string | Server request ID for debugging. |
timestamp | string | ISO 8601 error timestamp. |
details | object | Additional context specific to the error. |
MdcmsClientError
Thrown for client-side issues that prevent a valid response. Error codes:
| Code | Description |
|---|
INVALID_RESPONSE | The server returned a response that could not be parsed as valid JSON. |
NETWORK_ERROR | The request failed due to a network issue (DNS failure, connection refused, timeout). |
NOT_FOUND | A get() call matched zero documents. |
AMBIGUOUS_RESULT | A get() call by slug or path matched multiple documents. Add a locale parameter to disambiguate. |
Next.js Integration
The SDK is designed to work seamlessly with Next.js App Router for both static generation and server-side rendering.
Client Setup
Create a shared client instance:
// lib/mdcms.ts
import { createClient } from "@mdcms/sdk";
export const cms = createClient({
serverUrl: process.env.MDCMS_SERVER_URL!,
apiKey: process.env.MDCMS_API_KEY!,
project: process.env.MDCMS_PROJECT!,
environment: process.env.MDCMS_ENVIRONMENT!,
});
Blog Page Example
// app/blog/[slug]/page.tsx
import { cms } from "@/lib/mdcms";
import { notFound } from "next/navigation";
import { MdcmsApiError } from "@mdcms/sdk";
export async function generateStaticParams() {
const posts = await cms.list("BlogPost", { published: true });
return posts.data.map((post) => ({
slug: post.frontmatter.slug as string,
}));
}
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
try {
const post = await cms.get("BlogPost", {
slug,
resolve: ["author"],
});
return (
<article>
<h1>{post.frontmatter.title as string}</h1>
<div>{post.body}</div>
</article>
);
} catch (error) {
if (error instanceof MdcmsApiError && error.statusCode === 404) {
notFound();
}
throw error;
}
}
Blog Index Example
// app/blog/page.tsx
import { cms } from "@/lib/mdcms";
import Link from "next/link";
export default async function BlogIndex() {
const posts = await cms.list("BlogPost", {
published: true,
sort: "updatedAt",
order: "desc",
limit: 20,
});
return (
<div>
<h1>Blog</h1>
<ul>
{posts.data.map((post) => (
<li key={post.documentId}>
<Link href={`/blog/${post.frontmatter.slug}`}>
{post.frontmatter.title as string}
</Link>
</li>
))}
</ul>
</div>
);
}
Draft Preview
Use the draft: true option to fetch unpublished content for preview routes. Protect the preview route with a secret to prevent unauthorized access.
// app/api/preview/route.ts
import { cms } from "@/lib/mdcms";
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get("secret");
const slug = searchParams.get("slug");
const type = searchParams.get("type");
if (secret !== process.env.MDCMS_PREVIEW_SECRET) {
return new Response("Invalid secret", { status: 401 });
}
if (!slug || !type) {
return new Response("Missing slug or type", { status: 400 });
}
// Verify the document exists
const post = await cms.get(type, { slug, draft: true });
(await draftMode()).enable();
redirect(`/blog/${post.frontmatter.slug}`);
}
Then in your page component, check draft mode:
// app/blog/[slug]/page.tsx
import { cms } from "@/lib/mdcms";
import { draftMode } from "next/headers";
import { notFound } from "next/navigation";
import { MdcmsApiError } from "@mdcms/sdk";
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const { isEnabled: isDraft } = await draftMode();
try {
const post = await cms.get("BlogPost", {
slug,
resolve: ["author"],
draft: isDraft,
});
return (
<article>
{isDraft && (
<div style={{ background: "#fef3cd", padding: "8px 16px" }}>
Preview Mode -- This is a draft.
</div>
)}
<h1>{post.frontmatter.title as string}</h1>
<div>{post.body}</div>
</article>
);
} catch (error) {
if (error instanceof MdcmsApiError && error.statusCode === 404) {
notFound();
}
throw error;
}
}
The API key used for draft preview must have both content:read and
content:read:draft scopes. Use a separate API key for preview routes with
limited scope rather than reusing a production key.