Skip to content

Documentation

Local SDK vs HTTP/GraphQL API

Choose between the high-performance internal Local SDK and the external REST/GraphQL API in SveltyCMS.

8/23/2026
9 min read Edit on GitHub

Both access patterns are derived from a single, unified OpenAPI 3.1.0 contract, ensuring 1:1 parity and maximum flexibility for any setup:

  • External HTTP / GraphQL API — The secure, public-facing interface with the full middleware stack (auth, rate limiting, CORS, etc.). Now featuring automated population and real-time subscriptions.
  • Internal Local SDK — A zero-overhead, server-side-only API for maximum performance in your SvelteKit backend code.

Recommendation: In all server-side code (+page.server.ts, actions, hooks, etc.), prefer the Local SDK unless you have a specific reason to test the full HTTP stack.

graph TB subgraph Client["Client Tier (Slowest)"] CB[Browser / App] end subgraph Network["Network Tier (Medium)"] HTTP[HTTP / GraphQL / REST] Auth[Auth / JWT / CORS] Firewall[WAF / Rate Limit] end subgraph Server["Server Tier (Fastest)"] direction TB subgraph Logic["CMS Core Logic"] MC[modifyRequest] VB[Valibot Validation] IS[Intelligent Storage Tier] CH[Cache Hooks] EB[Event Bus] end subgraph API["Access Layers (Harmonized)"] EX[External API / GraphQL] SDK[Local SDK / LocalCMS] end end subgraph Data["Data Tier"] DB[(Database)] RD[(Redis Cache)] end CB -->|150ms+| HTTP HTTP --> Auth Auth --> Firewall Firewall -->|JSON Parsed| EX EX --> Logic Server_Load[+page.server.ts / Actions] -->|0.1ms| SDK SDK -->|Native Calls| Logic Logic --> DB Logic --> RD

When to Use Which

Context Recommended Latency Reason
Client-side (.svelte, +page.ts) HTTP / GraphQL API 50–150 ms Browser environment; requires auth, CORS, and network.
Server-side (.server.ts, actions) Local SDK 2–15 µs Direct function calls; zero serialization or network overhead.
Mobile / External App GraphQL 80–300+ ms Optimized data fetching with nested population.
Plugins / Background Jobs Local SDK 2–15 µs Full access to core logic and security context.
Tip

Rule of thumb: If your code runs server-side (files ending in .server.ts or inside +server.ts routes), use the Local SDK. Use GraphQL for external integrations that require complex nested data in a single request.


Why the Local SDK Exists

The Local SDK (src/services/sdk/index.ts — the LocalCMS class) is a high-performance facade that:

  1. Eliminates overhead: No network stack, JSON parsing, or serialization.
  2. Bypasses external security: Safely skips WAF and rate-limiting because it runs with server privileges.
  3. Calls Core Logic: Executes the exact same modifyRequest pipeline as the HTTP layer.
  4. Maintains Consistency: Automatically triggers cache invalidation and real-time events (SSE).
  5. Unified Context: Respects the same tenant isolation and permissions as the external API.

What is not faster than LocalCMS (2026)

gRPC / protobuf / HTTP/3 beat JSON-over-HTTP between processes. They do not beat an in-process function call. Spotify’s published backend work (gRPC as the default service-to-service protocol, proxyless xDS mesh) is for thousands of Kubernetes services talking across the network — not for a server talking to itself. In this CMS the analog is:

Hop What to use Why
Browser → this Node process SvelteKit remote functions (query / command) that call LocalCMS One typed RPC; still one HTTP to the server, then zero extra hops
.server.ts / remotes / jobs → DB LocalCMS Same process; JSON + WAF + auth stack never run
Browser → /api/... REST / GraphQL Public contract, mobile apps, webhooks

A .remote.ts that event.fetch("/api/...") re-enters the HTTP pipeline (auth, WAF, JSON) and throws away the LocalCMS advantage. Remotes must call LocalCMS the same way +page.server.ts does (getRequestLocalCMS()). User profile, settings groups, collection save/delete remotes, and dashboard health follow that path.

HTTP/3 and QUIC still matter for clients on lossy networks; they do not replace LocalCMS on the server.

Measured Local SDK vs HTTP (SQLite, self-measured 2026-08)

Same machine, same harness (bun test tests/benchmarks/). The Local SDK is two to three orders of magnitude faster than the HTTP cold path because the HTTP middleware stack (auth, RBAC, security headers, serialization) is the dominant cost — not the database.

Operation Local SDK (in-process) HTTP cold HTTP turbo (response cache)
find by _id 64–127k RPS · 8-16 µs 174 RPS · 5.6 ms 408 RPS · 2.3 ms
findById (warm L1) 259–431k RPS · 2-3 µs
create (full pipeline) 2,000 RPS · 500 µs
update (full pipeline) 2,500 RPS · 400 µs
create (detached) 4,900 RPS · 200 µs
update (detached) 4,400 RPS · 230 µs
Note

Two write columns are reported deliberately and no environment flags are involved — the benchmark measures exactly what production runs. (full pipeline) = the SDK call with all post-write side effects executing (outbox event INSERT, cache-pattern invalidation, workflow init, pubsub). (detached) = the same call with the documented skipSideEffects: true option (used by bulk seed / high-throughput import paths). The ambient BENCHMARK env toggles were removed from the write path — benchmarks no longer measure a code path production never runs.

Tip

Adapter-level INSERT on the same machine is ~14k RPS (raw INSERT…RETURNING). The findMany-by-_id ultra path (routes list calls with a pure _id filter through the raw prepared findById) removed the previous ~10× list-path penalty.

Example: Local SDK Structure

The cms (LocalCMS) object provides a unified, typed interface to all system capabilities:

// Content Management
await cms.collections.find("posts", { limit: 10 });
await cms.collections.create("posts", { title: "New" });
await cms.media.list({ folderId: "uploads" });

// Identity & Access
await cms.auth.listUsers({ tenantId });
await cms.websiteTokens.create({
  name: "API Key",
  permissions: ["content:read"],
  user,
  tenantId,
});

// System & Infrastructure
await cms.system.settings.getAll();
await cms.system.getHealth();
await cms.widgets.list();

// Collection Builder organizational tree (gui-save, manifest, SSE)
await cms.contentStructure.saveGuiStructure(
  [{ type: "move", node: { path: "/collection/posts", parentId: "cat-1" } }],
  { tenantId },
);

// Direct adapter access (escape hatch)
const raw = await cms.db.crud.findMany("posts", {}, { tenantId });

Collections Namespace Modules (2026-08 refactor)

src/services/sdk/namespaces/collections-namespace.ts was consolidated from a ~2,100-line monolith into a thin orchestrator (~1,240 lines) with each concern living exactly once in its own module under src/services/sdk/namespaces/collections/:

Module Responsibility
schema-store.ts Schema LRU cache (lowercased keys), hot-path flags, model cache (WeakMap, never on schema)
read-pipeline.ts Tenant+publication query building, find cache keys (FNV-1a), L1→L2 read-through cache
write-pipeline.ts Single-pass field prep → hooks → numeric gate → assertWriteAllowed; widget pipeline
post-write.ts Post-write side effects: outbox batch, tick-debounced invalidation (collection + API patterns only — not cms:content_structure), workflow/pub-sub/hooks
request-cache.ts L1 request cache (bounded LRU) + scoped keyspace index for eviction
lazy-services.ts Memoized dynamic imports (workflow, response-cache, pub-sub, outbox, token engine, …)

Key hot-path changes:

  • One-pass write field prepprepareCollectionFields() in src/content/content-utils.ts replaces the former three-walk chain (sanitizeCollectionFieldsstripNullRowsvalidateFieldConstraints) with a single schema walk and at most one shallow clone.
  • Shared hook orchestrationapplySchemaHookPipeline() (with an optional createError factory so write paths still throw AppError(400, "FIELD_VALIDATION_ERROR")) now serves both create and update instead of duplicated inline pipelines.
  • Physical table namescollectionTableName() in src/databases/core/collection-name.ts is the single source for collection_<id> derivation (namespace + API handlers).
  • Per-item dynamic import removed from list() (token engine now a memoized singleton); findById schema-cache key casing fixed; findStreaming now shares the schema cache.
  • Widget pipelinemodifyRequest caches active widgets on the fields array; DateTime uses a static toISOString import (no per-field dynamic import).
  • Cross-chunk singletonsresponseCache, webhook, and automation services bind to globalThis so Rolldown chunks share one instance.

Security invariants (unchanged)

The refactor is behavior-preserving by design. The write path still enforces, in order: field sanitization (stored-XSS prevention) → constraint/numeric validation → schema hooks → assertWriteAllowed for non-admin writers → persist → detached side effects. Read paths still clamp publication visibility (resolvePublicationFilter + status bound into the DB query), keep the per-filter cache suffix (a cached “all” document can never reach a clamped caller), and always inject tenantId. Post-write side effects (outbox, workflow, pub-sub, plugin hooks) remain best-effort and never surface to the caller.

Example: Data Loading in +page.server.ts

import { LocalCMS } from "@src/services/sdk";
import { getDb } from "@src/databases/db";
import type { PageServerLoad } from "./$types";

export const load: PageServerLoad = async ({ locals }) => {
  const cms = new LocalCMS(getDb()!);
  const posts = await cms.collections.find("posts", {
    limit: 10,
    tenantId: locals.tenantId,
  });
  return { posts };
};

Example: Bulk Operations

// Collections namespace supports bulkCreate, bulkUpdate, bulkDelete
const result = await cms.collections.bulkCreate("products", largeDataArray, {
  tenantId,
});

Technical Details

Local SDK Guarantees

  • Widget/request modification pipeline (modifyRequest) runs identically to HTTP requests.
  • Cache hooks, invalidation, and version bumping happen automatically.
  • Real-time updates (SSE / WebSocket) are triggered for connected clients.
  • Multi-tenancy, user context, and permissions are applied consistently.
  • Media Intelligent Storage: Shared deduplication and loop protection logic for all uploads via cms.media.upload().
  • Bulk operations: cms.collections.bulkCreate(), bulkUpdate(), bulkDelete() for high-throughput tasks.
  • Direct adapter access: cms.db provides escape-hatch access to the raw IDBAdapter when the SDK namespace doesn’t cover a needed operation.

When You Might Still Use HTTP from Server Code

  • Calling a completely external microservice or third-party API.
  • Deliberately exercising the full external middleware (e.g., for testing or logging).
  • Cross-origin or cross-instance communication.

Best Practices

  1. Default to Local SDK in every .server.ts file, server actions, and hooks.
  2. Avoid fetch('/api/...') inside server code — it adds unnecessary latency and stack depth.
  3. Instantiate via new LocalCMS(adapter) from @src/services/sdk for clean, typed access. The instance is lightweight and safe to create per-request.
  4. Use cms.db when you need raw adapter access — e.g., cms.db.system.websiteTokens.getByToken().

---

## Related

- [API Reference](/docs/development/index)
- [Getting Started](/docs/getting-started)
architectureperformanceapisdkssr
Was this page helpful?