Skip to main content
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

npm install @mdcms/sdk

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

OptionTypeRequiredDescription
serverUrlstringYesThe base URL of your MDCMS server (e.g., https://cms.example.com).
apiKeystringYesAPI key with the appropriate scopes (prefixed with mdcms_key_).
projectstringYesProject slug. Sets the X-MDCMS-Project header on every request.
environmentstringYesEnvironment name. Sets the X-MDCMS-Environment header on every request.
fetchfunctionNoCustom 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

OptionTypeDescription
idstringDocument UUID. Mutually exclusive with slug and path.
slugstringSlug frontmatter value. Mutually exclusive with id and path.
pathstringDocument path. Mutually exclusive with id and slug.
localestringBCP 47 locale tag.
resolvestring[]Reference field names to resolve (one level deep).
draftbooleanWhen 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

OptionTypeDescription
localestringBCP 47 locale tag.
publishedbooleanFilter by publication status.
isDeletedbooleanInclude soft-deleted documents.
hasUnpublishedChangesbooleanFilter to documents with pending draft changes.
draftbooleanReturn draft content instead of published content.
resolvestring[]Reference field names to resolve.
limitnumberResults per page (default 20, max 100).
offsetnumberNumber of results to skip.
sortstringSort field: createdAt, updatedAt, or path.
orderstringSort 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:
FieldTypeDescription
documentIdstringStable UUID identifier.
translationGroupIdstringUUID linking locale variants of the same content.
projectstringProject slug.
environmentstringEnvironment name.
pathstringFilesystem-like path (e.g., content/blog/hello-world).
typestringContent type name (e.g., BlogPost).
localestringBCP 47 locale tag, or __mdcms_default__ for non-localized types.
formatstringmd or mdx.
isDeletedbooleanWhether the document is soft-deleted.
hasUnpublishedChangesbooleanWhether the draft differs from the published version.
versionnumberCurrent version number (incremented on each publish).
publishedVersionnumber | nullVersion number of the published snapshot, or null if never published.
draftRevisionnumberIncrementing counter for draft saves. Used for optimistic concurrency.
frontmatterobjectStructured data matching the content type schema. References are UUIDs unless resolved.
bodystringMarkdown or MDX content.
resolveErrorsobjectMap of field paths to resolve error objects. Empty when no resolve errors.
createdBystringUser or API key ID that created the document.
createdAtstringISO 8601 creation timestamp.
updatedBystringUser or API key ID that last updated the document.
updatedAtstringISO 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:
PropertyTypeDescription
statusCodenumberHTTP status code (e.g., 404, 409, 500).
codestringMachine-readable error code (e.g., NOT_FOUND, SCHEMA_HASH_MISMATCH).
messagestringHuman-readable error description.
requestIdstringServer request ID for debugging.
timestampstringISO 8601 error timestamp.
detailsobjectAdditional context specific to the error.

MdcmsClientError

Thrown for client-side issues that prevent a valid response. Error codes:
CodeDescription
INVALID_RESPONSEThe server returned a response that could not be parsed as valid JSON.
NETWORK_ERRORThe request failed due to a network issue (DNS failure, connection refused, timeout).
NOT_FOUNDA get() call matched zero documents.
AMBIGUOUS_RESULTA 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.