Atlas CMS

SDK

Typed delivery + management client for Atlas CMS — read, write, publish, and delete content with full type safety.

TypeScript SDK

@latellu/atlas-sdk provides two clients for Atlas:

  • Delivery client — read entries, pages, and media from the Public API (/api/v1/public/*).
  • Management client — create, update, publish, and delete content via the Management API (/api/v1/manage/*).

Paired with the generated types from @latellu/atlas-cli, your content types, slugs, and entry data are all type-checked at compile time.

Install

Terminal
npm install @latellu/atlas-sdk
# Generate typed interfaces for your workspace (recommended):
npx @latellu/atlas-cli generate --api-key=atlas_live_abc123xyz --output=./src

Get your API key from the Atlas dashboardSettings → API Keys.

Requires Node 18+ (uses global fetch). For older runtimes, pass fetchImpl.


Delivery client

Read entries, pages, and media with full type safety.

Create a client

src/atlas.ts
import { createClient } from '@latellu/atlas-sdk';
import type { AtlasContentTypes } from './atlas.types'; // from @latellu/atlas-cli

export const atlas = createClient<AtlasContentTypes>({
  url: 'https://api.atlas.latellu.com',
  apiKey: process.env.ATLAS_API_KEY!,
});

createClient<TSchema>(config) returns a client whose entries() method is constrained to the content-type slugs in TSchema. Without the type parameter, slugs accept any string and data is unknown.

Config options:

OptionTypeRequiredDescription
urlstringYesAtlas backend base URL. The SDK adds /api/v1/public automatically.
apiKeystringYesWorkspace API key (atlas_live_...).
fetchImpltypeof fetchNoCustom fetch for tests or older runtimes.

Entries

atlas.entries(type) returns a resource scoped to one content type.

List entries:

const { items, total, page, pageSize } = await atlas
  .entries('article')
  .list({ locale: 'en', page: 1, limit: 20, sort: 'published_at:desc' });
OptionTypeDescription
localestringLocale to resolve content into.
pagenumber1-based page number.
limitnumberEntries per page.
sortstringSort expression, e.g. "created_at:desc".

Get one entry:

const post = await atlas.entries('article').get('hello-world', { locale: 'en' });
// post is AtlasEntry<T> or null (on 404)

When locale is passed, the matching translation is merged over the base data — translated fields win, untranslated fields fall back.

Entry shape:

FieldTypeDescription
idstringEntry id.
slugstringURL slug.
statusstringpublished, draft, or archived.
published_atstring | nullISO timestamp.
dataTParsed entry data, typed as your content type.

Pages

// Lightweight list (no blocks) — good for sitemaps or nav
const { items } = await atlas.pages.list({ locale: 'en' });

// Full page with SEO + blocks resolved for the locale
const home = await atlas.pages.get('home', { locale: 'en' });
  • pages.list(options?)ListResult<AtlasPageSummary> (id, slug, status — no blocks).
  • pages.get(slug, options?)AtlasPage | null with SEO and blocks resolved for the locale, blocks sorted by position.

Media

const asset = await atlas.media.get(mediaId);
// asset is MediaAsset or null (on 404)

Raw requester

For delivery endpoints not yet wrapped by a resource:

const { data, meta } = await atlas.raw.get<MyShape>('/some/public/path', { locale: 'en' });

Path is relative to /api/v1/public.


Management client

Create, update, publish, and delete content from scripts, CI jobs, or AI agents.

Authenticates with a management key (atlas_mgmt_...). See Authentication to mint one.

Wrong key class fails fast

Passing an atlas_live_ (delivery) key throws ManagementConfigError immediately.

Promise surface (default)

import { createManagementClient, AtlasError } from '@latellu/atlas-sdk/management';

const client = createManagementClient({
  url: 'https://api.atlas.latellu.com',
  token: process.env.ATLAS_MGMT_KEY!,
});

Effect-native surface

For codebases built on Effect:

Basic Effect usage
import { makeManagementClient } from '@latellu/atlas-sdk/management/effect';
import { Effect } from 'effect';

const client = makeManagementClient({
  url: 'https://api.atlas.latellu.com',
  token: process.env.ATLAS_MGMT_KEY!,
});

// Every method returns Effect<T, AtlasError>
const program = client.entries('article').publish('hello-world');

// Run at the boundary
await Effect.runPromise(program);

Delivery client stays Effect-free

Importing @latellu/atlas-sdk or @latellu/atlas-sdk/management never pulls Effect into your bundle. Only .../management/effect depends on Effect.

Composing multiple operations

Sequential operations with Effect
import { makeManagementClient } from '@latellu/atlas-sdk/management/effect';
import { Effect, Console } from 'effect';

const client = makeManagementClient({
  url: 'https://api.atlas.latellu.com',
  token: process.env.ATLAS_MGMT_KEY!,
});

// Compose multiple operations into a single program
const program = Effect.gen(function* () {
  // Create an entry
  const entry = yield* client.entries('article').create({
    slug: 'hello-world',
    data: { title: 'Hello, world' },
  });
  yield* Console.log(`Created: ${entry.id}`);

  // Publish it
  yield* client.entries('article').publish('hello-world');
  yield* Console.log('Published');

  // Update it
  yield* client.entries('article').update('hello-world', {
    data: { title: 'Updated title' },
  });
  yield* Console.log('Updated');

  return entry;
});

await Effect.runPromise(program);

Error handling with Effect

Typed error handling
import { makeManagementClient, AtlasError } from '@latellu/atlas-sdk/management/effect';
import { Effect, Console } from 'effect';

const client = makeManagementClient({
  url: 'https://api.atlas.latellu.com',
  token: process.env.ATLAS_MGMT_KEY!,
});

// catchTag for specific error handling
const program = client.entries('article').create({
  slug: '',
  data: {},
}).pipe(
  Effect.catchTag('AtlasError', (err) => {
    if (err.status === 400 && err.errors) {
      // Validation errors
      return Console.error('Validation failed:', err.errors);
    }
    if (err.status === 429) {
      // Rate limit — SDK already retried, this is after retries exhausted
      return Console.error('Rate limited — try again later');
    }
    return Console.error(`Failed: ${err.status} ${err.message}`);
  }),
);

await Effect.runPromise(program);

Retry policies

The SDK automatically retries 429 responses with exponential backoff (200ms, 400ms, 800ms — max 3 retries), including on media uploads (client.media.upload). For custom retry logic:

Custom retry with Effect
import { makeManagementClient, AtlasError } from '@latellu/atlas-sdk/management/effect';
import { Effect, Schedule, Duration } from 'effect';

const client = makeManagementClient({
  url: 'https://api.atlas.latellu.com',
  token: process.env.ATLAS_MGMT_KEY!,
});

// Custom retry: retry on 500 errors with linear backoff
const program = client.entries('article').create({
  slug: 'hello-world',
  data: { title: 'Hello' },
}).pipe(
  Effect.retry({
    schedule: Schedule.exponential(Duration.millis(100)).pipe(
      Schedule.compose(Schedule.recurs(5)),
    ),
    while: (err: AtlasError) => err.status === 500,
  }),
);

await Effect.runPromise(program);

Parallel operations

Parallel writes with Effect
import { makeManagementClient } from '@latellu/atlas-sdk/management/effect';
import { Effect } from 'effect';

const client = makeManagementClient({
  url: 'https://api.atlas.latellu.com',
  token: process.env.ATLAS_MGMT_KEY!,
});

// Publish multiple entries in parallel
const slugs = ['post-1', 'post-2', 'post-3'];

const program = Effect.all(
  slugs.map((slug) => client.entries('article').publish(slug)),
  { concurrency: 3 }, // Run 3 at a time
);

await Effect.runPromise(program);

Entries

Full lifecycle: create → publish → update → unpublish → delete.

const created = await client.entries('article').create(
  { slug: 'hello-world', data: { title: 'Hello, world' } },
  { idempotencyKey: crypto.randomUUID() }
);

await client.entries('article').publish('hello-world');
await client.entries('article').update('hello-world', { data: { title: 'Updated' } });
await client.entries('article').unpublish('hello-world');
await client.entries('article').delete('hello-world');

All entry methods:

MethodDescription
.create(input, opts?)Create a draft entry
.update(idOrSlug, input, opts?)Update an entry
.publish(idOrSlug, opts?)Publish (visible to delivery API)
.unpublish(idOrSlug, opts?)Unpublish
.archive(idOrSlug, opts?)Archive
.schedule(idOrSlug, publishAt, opts?)Schedule future publish
.duplicate(idOrSlug, opts?)Clone as new draft
.delete(idOrSlug, opts?)Delete
.bulk(operations, opts?)Batch operations

Pages

const page = await client.pages.create({
  slug: 'about',
  seo: { title: 'About us' },
  blocks: [{ type: 'hero', data: { heading: 'About us' } }],
});

await client.pages.publish('about');
await client.pages.blocksReorder('about', ['block-1', 'block-2']);

All page methods: .create, .update, .delete, .publish, .unpublish, .archive, .schedule, .blocksReorder.

Media

const file = await fetch('https://example.com/cover.jpg').then((r) => r.blob());
const asset = await client.media.upload(file, { alt: 'Cover image' });
await client.media.delete(asset.id);

Idempotency

All write methods accept { idempotencyKey?: string } to prevent duplicates on retry. The client also auto-retries 429 responses with exponential backoff.


Type generation

@latellu/atlas-cli generates TypeScript interfaces from your workspace schema.

Generate

Get your API key from the Atlas dashboardSettings → API Keys.

npx @latellu/atlas-cli generate --api-key=atlas_live_abc123xyz --output=./src/types
FlagEnvDefaultDescription
--api-keyATLAS_API_KEY— (required)Workspace API key.
--urlATLAS_API_URLhttps://api.atlas.latellu.comAtlas backend URL.
--outputATLAS_OUTPUT./src/atlas.types.tsOutput file or directory.

What it generates

atlas.types.ts
// Generated by @latellu/atlas-cli — DO NOT EDIT BY HAND.
// Workspace: my-blog

export type Locale = "en" | "id";

/** Article (content type: "article") */
export interface Article {
  title: string; // localizable
  category?: "News" | "Event";
}

export interface AtlasContentTypes {
  "article": Article;
}
  • One interface per content type (PascalCase from slug).
  • select fields as string-literal unions.
  • Locale union from workspace locales.
  • AtlasContentTypes registry consumed by createClient<AtlasContentTypes>().

Keep types in sync

Regenerate after schema changes (add/rename/remove content types or fields):

package.json
{
  "scripts": {
    "atlas:types": "atlas generate --output=./src/types"
  }
}

Commit atlas.types.ts to your repo for reproducible builds.


Error handling

Delivery client

  • get() methods return null for 404 — normal value, not an exception.
  • Everything else (network failures, auth errors, rate limits, validation) throws AtlasError.
Delivery error handling
import { createClient, AtlasError } from '@latellu/atlas-sdk';

const atlas = createClient({ url: '...', apiKey: 'atlas_live_xxx' });

// get() returns null for 404 — no exception
const entry = await atlas.entries('article').get('nonexistent');
if (entry === null) {
  console.log('Not found — handle gracefully');
}

// list() throws on failure
try {
  await atlas.entries('article').list();
} catch (err) {
  if (err instanceof AtlasError) {
    console.error(err.status);  // 401, 403, 429, 500, etc.
    console.error(err.code);    // "UNAUTHORIZED", "FORBIDDEN", etc.
    console.error(err.message); // Human-readable description
  }
}

Management client (Promise surface)

Same AtlasError as delivery, with additional .errors array for validation failures:

Management error handling (Promise)
import { createManagementClient, AtlasError } from '@latellu/atlas-sdk/management';

const client = createManagementClient({ url: '...', token: 'atlas_mgmt_xxx' });

try {
  await client.entries('article').create({
    slug: '', // invalid — empty slug
    data: {},
  });
} catch (err) {
  if (err instanceof AtlasError) {
    console.error(err.status);  // 400, 401, 403, etc.
    console.error(err.code);    // "VALIDATION_ERROR", etc.
    console.error(err.message); // "slug is required"
    console.error(err.errors);  // [{ field: "slug", message: "slug is required" }]
  }
}

Management client (Effect surface)

With Effect, errors are typed as the second type parameter of Effect<A, E>:

Management error handling (Effect)
import { makeManagementClient, AtlasError } from '@latellu/atlas-sdk/management/effect';
import { Effect, Console } from 'effect';

const client = makeManagementClient({ url: '...', token: 'atlas_mgmt_xxx' });

// Type-safe error handling with Effect.catchTag
const program = client.entries('article').create({
  slug: 'hello-world',
  data: { title: 'Hello' },
}).pipe(
  Effect.catchTag('AtlasError', (err) =>
    Console.error(`Failed: ${err.status} ${err.message}`)
  ),
);

await Effect.runPromise(program);

AtlasError properties

PropertyTypeDescription
messagestringHuman-readable error description.
statusnumber | undefinedHTTP status code. Absent for network errors.
codestring | undefinedMachine-readable code (e.g. UNAUTHORIZED, FORBIDDEN).
errors{ field: string; message: string }[] | undefinedValidation errors (management client only).

ManagementConfigError

Thrown immediately (before any request) when the management client is misconfigured:

import { createManagementClient, ManagementConfigError } from '@latellu/atlas-sdk/management';

try {
  // Wrong key prefix — throws immediately
  createManagementClient({ url: '...', token: 'atlas_live_xxx' });
} catch (err) {
  if (err instanceof ManagementConfigError) {
    console.error(err.message); // "management client requires an atlas_mgmt_ token"
  }
}

Common error scenarios

ScenarioStatusCodeHow to handle
Missing API key401UNAUTHORIZEDCheck X-API-Key header is set.
Wrong key class (live key on manage endpoint)401UNAUTHORIZEDUse atlas_mgmt_ key for management.
Key lacks scope403FORBIDDENCheck key scopes in dashboard.
Entry not found404NOT_FOUNDget() returns null; others throw.
Rate limit exceeded429SDK auto-retries with backoff.
Validation error400VALIDATION_ERRORCheck err.errors for field-level details.

See Errors for the full list of API status codes.


Current limitations

Draft preview not wrapped yet

The delivery client reads published content only. Previewing drafts requires a preview token from the admin side, which the SDK does not mint yet. Use GET /public/preview directly.

  • Localizable fields are typed as their base value. The runtime locale merge still applies, but types don't distinguish per-locale shapes yet.

On this page