GraphQL-API & SDK

GraphQL-Lesezugriff auf die Delivery-Daten mit einem Schema, das aus den Inhaltstypen entsteht, der Schema-Endpunkt für Codegenerierung und das TypeScript-SDK @entrydeck/sdk für Nuxt, Next und Node — mit Beispielen auf Deutsch und Englisch.

Stand: 19.10.2026

Auf dieser Seite

Neben der REST-Delivery-API gibt es zwei weitere Wege zu den veröffentlichten Inhalten: eine GraphQL-API unter demselben Pfad und das SDK @entrydeck/sdk, das beide Wege in einem typisierten Client bündelt. Beide liefern dieselben Antwortformen wie REST — ein Frontend kann sie beliebig mischen.

GraphQL-Endpunkt

Methode und Pfad Zweck
POST /api/v1/delivery/graphql GraphQL-Queries (nur lesend)
GET /api/v1/delivery/schema Feldschemata aller aktiven Inhaltstypen als JSON — Grundlage für Codegenerierung
GET /api/v1/delivery/schema?format=sdl das generierte GraphQL-Schema als SDL (Text)
GET /api/v1/delivery/graphql GraphiQL-Playground — nur wenn GRAPHQL_PLAYGROUND=1 gesetzt ist

Authentifizierung und Website-Kontext sind identisch mit REST: x-api-key (plus optional x-site-id) oder Plattform-Schlüssel mit x-site-host. Der Body ist das übliche GraphQL-JSON: { "query": "…", "variables": { … }, "operationName": "…" }.

bash
curl https://entrydeck.app/api/v1/delivery/graphql \
  -H "x-api-key: $ENTRYDECK_API_KEY" -H "content-type: application/json" \
  -d '{"query":"{ site { name defaultLocale } navigation(handle: \"main\") { items { label href } } }"}'

Das Schema entsteht aus den Inhaltstypen

Für jeden Inhaltstyp gibt es einen Objekttyp (Slug in PascalCase: beitrag → Beitrag, team-mitglied → TeamMitglied) und zwei Queries: beitrag(slug, locale, expand) für einen Eintrag und beitrags(locale, limit, offset, page, filter, sort, order, featured, expand) für die Liste. Der Plural folgt einfachen englischen Regeln und ist damit vorhersagbar. Jeder Objekttyp trägt die Basisfelder eines Eintrags (id, slug, locale, title, seo, data, createdAt, updatedAt, website) plus die Felder des Inhaltstyps, typisiert:

Feldtyp GraphQL
text, textarea, slug, email, phone, url, date, datetime, select, radio String ([String] bei Mehrfachauswahl)
richtext String (HTML)
number Float
boolean Boolean
list [String]
media Media { id url thumbnailUrl width height alt title mimeType focalX focalY variants … }
relation der Zieltyp (relatedType) oder das Interface ContentEntry
json JSON
blocks JSON (Rohwert) plus <feld>Blocks: eine Union typisierter Blöcke, wenn der Inhaltstyp blocks: [{ type, label, fields }] definiert

Feldnamen werden GraphQL-tauglich gemacht (preis-brutto → preisBrutto). Ein Datenfeld, das wie ein Basisfeld heißt (etwa title), bleibt über data erreichbar. Fehlt locale, gilt die Standardsprache der Website. Nicht gefundene Seiten oder Einträge liefern null statt eines Fehlers; Fehler des Dienstes tragen extensions.code und extensions.status (z. B. NOT_FOUND/404 für einen unbekannten Inhaltstyp).

Beispiel-Queries

graphql
query Startseite($locale: String) {
  site { name defaultLocale theme }
  page(path: "/", locale: $locale, expand: "all") {
    page { title seo blocks { blockKey configJson } }
    breadcrumbs { title fullPath }
  }
  navigation(handle: "main", locale: $locale) {
    items { label href children { label href } }
  }
}
graphql
query Beitraege {
  beitrags(limit: 10, sort: publishedAt, order: desc, filter: { kategorie: "news" }) {
    total
    items {
      slug title
      teaser
      bild { url width height alt }
      autor { title slug }
      inhaltBlocks {
        __typename
        ... on BeitragInhaltTextBlock { html }
        ... on BeitragInhaltHeroBlock { headline image { url } }
      }
    }
  }
  beitrag(slug: "hallo-welt") { title body data }
}
graphql
{
  search(q: "Ausbildung mit Sehbehinderung", limit: 5) { mode items { slug title excerpt score } }
  sitemap { baseUrl pages { url updatedAt } content { url updatedAt } }
  pagesTree { title fullPath children { title fullPath } }
  contentTypes { slug graphqlType fields }
}

Generische Queries für dynamische Frontends: entry(type: "beitrag", slug: "…") und entries(type: "beitrag", filter: { … }) liefern das Interface ContentEntry mit data als JSON.

Grenzen und Caching

  • Tiefe höchstens 8 Ebenen, geschätzte Komplexität höchstens 2000, Timeout 10 s — einstellbar über GRAPHQL_MAX_DEPTH, GRAPHQL_MAX_COMPLEXITY, GRAPHQL_TIMEOUT_MS. Zu tiefe oder zu breite Queries werden mit 400 und QUERY_TOO_DEEP bzw. QUERY_TOO_COMPLEX abgelehnt.
  • Introspektion (__schema) ist nur mit GRAPHQL_PLAYGROUND=1 erlaubt. Für Codegenerierung ohne Playground genügt GET /delivery/schema?format=sdl.
  • Das Schema wird je Mandant gecacht. Nach 60 Sekunden (GRAPHQL_SCHEMA_TTL_MS) wird ein Hash der Inhaltstypen verglichen; nur bei Änderung wird neu gebaut. Die Antwort trägt den Hash im Header X-Schema-Hash.
  • Medien und Relationen werden je Request gebündelt geladen (keine N+1-Abfragen).

SDK @entrydeck/sdk

Ein TypeScript-Client ohne Abhängigkeiten außer fetch — für Node 18+, Next, Nuxt, Astro, SvelteKit und Worker. Der API-Key gehört auf den Server.

bash
npm install @entrydeck/sdk
ts
import { createClient, EntrydeckError } from '@entrydeck/sdk'

const cms = createClient({
  baseUrl: 'https://entrydeck.app',          // oder …/api/v1
  apiKey: process.env.ENTRYDECK_API_KEY!,
  siteId: process.env.ENTRYDECK_SITE_ID,     // optional
  locale: 'de',                              // Standardsprache aller Aufrufe
})

const site = await cms.site()
const home = await cms.page('/', { expand: 'all' })
const nav  = await cms.navigation('main')
const news = await cms.entries<{ title: string; teaser?: string }>('beitrag', {
  limit: 10, sort: 'publishedAt', order: 'desc', filter: { kategorie: 'news' },
})
const post = await cms.entry('beitrag', 'hallo-welt', { expand: 'media' })
const hits = await cms.search('Ausbildung', { limit: 5 })
const map  = await cms.sitemap()
const ok   = await cms.submitForm('kontakt', { email: 'a@b.de', message: 'Hallo' })
const { data } = await cms.graphql('{ site { name } }')
Methode Endpunkt
site() GET /delivery/site
page(path, { locale?, expand? }) GET /delivery/page
pagesTree() GET /delivery/pages/tree
navigation(handle) GET /delivery/navigation/{handle}
entries<T>(type, { limit, offset, page, filter, sort, order, featured, expand }) GET /delivery/content/{typ}
entry<T>(type, slug, { expand? }) GET /delivery/content/{typ}/{slug}
search(q, { limit?, mode? }) GET /delivery/search
sitemap() GET /delivery/sitemap
form(slug), submitForm(slug, data) GET /delivery/form/{slug}, POST /forms/{id}/submissions
preview(token) GET /delivery/preview/page
schema() GET /delivery/schema
graphql<T>(query, variables?) POST /delivery/graphql

Alle Methoden reichen fetch-Optionen unverändert durch — next: { revalidate: 60 }, cache: 'no-store', signal —, damit ISR und Framework-Caches ohne Umwege funktionieren. Fehler kommen als EntrydeckError mit status (0 = Netzwerk), code, body, graphqlErrors und bei abgelehnten Formularen fieldErrors.

Next.js (App Router, ISR)

ts
// lib/cms.ts
export const cms = createClient({
  baseUrl: process.env.ENTRYDECK_URL!,
  apiKey: process.env.ENTRYDECK_API_KEY!,
  locale: 'de',
  requestInit: { next: { revalidate: 60, tags: ['cms'] } },
})

// app/[...slug]/page.tsx
export default async function Page({ params }: { params: { slug?: string[] } }) {
  try {
    const { page } = await cms.page('/' + (params.slug ?? []).join('/'), { expand: 'all' })
    return <Blocks blocks={page.blocks} seo={page.seo} />
  } catch (err) {
    if (err instanceof EntrydeckError && err.isNotFound) notFound()
    throw err
  }
}

Ein Webhook auf eine Route, die revalidateTag('cms') aufruft, erneuert die Seiten nach jeder Veröffentlichung.

Nuxt 3

ts
// server/utils/cms.ts — der Key bleibt auf dem Server
export const cms = createClient({
  baseUrl: useRuntimeConfig().entrydeckUrl,
  apiKey: useRuntimeConfig().entrydeckApiKey,
  locale: 'de',
})

// server/api/page.get.ts
export default defineEventHandler((event) => {
  const { route = '/' } = getQuery(event)
  return cms.page(String(route), { expand: 'all' })
})
vue
<script setup lang="ts">
const route = useRoute()
const { data } = await useFetch('/api/page', { query: { route: route.path } })
</script>

Soll useFetch direkt gegen die API gehen, liefert cms.requestFor('delivery/page', { route: '/', locale: 'de' }) die passende url samt headers.

TypeScript-Typen aus den Inhaltstypen erzeugen

bash
npx entrydeck-generate-types --base-url https://entrydeck.app --api-key $ENTRYDECK_API_KEY --out src/types/entrydeck.ts

Das Skript liest GET /delivery/schema und schreibt je Inhaltstyp ein …Data-Interface, Entry<…>-Aliasse, Block-Interfaces für blocks-Felder und die Zuordnung EntrydeckTypes (Slug → Datentyp). Optionen: --site-id, --from schema.json (offline), --sdk <modul>, --no-literals.

ts
import type { BeitragData, EntrydeckTypes } from './types/entrydeck'

const post = await cms.entry<BeitragData>('beitrag', 'hallo-welt')
post.data.title      // string | null
post.data.bild       // MediaRef — Id oder, mit expand=media, das Medienobjekt

English summary

GraphQL. POST /api/v1/delivery/graphql accepts read-only GraphQL queries with the same authentication (x-api-key + optional x-site-id, or platform key + x-site-host) and the same site context as the REST Delivery API. The schema is generated per tenant from the content types: one object type per type (beitrag → Beitrag), the queries <type>(slug, locale, expand) and <typePlural>(locale, limit, offset, page, filter, sort, order, featured, expand), plus site, page(path, locale, expand), pagesTree(locale), navigation(handle, locale), search(q, locale, limit, mode), sitemap(locale), the generic entry/entries and contentTypes. Field types map to String, Float, Boolean, [String], Media, the related type (or the ContentEntry interface) and the JSON scalar; blocks fields additionally expose a typed <field>Blocks union. Responses carry the same fields as the REST endpoints, so both can be used interchangeably. Limits: depth 8, estimated complexity 2000, timeout 10 s (GRAPHQL_MAX_DEPTH, GRAPHQL_MAX_COMPLEXITY, GRAPHQL_TIMEOUT_MS). Introspection and the GraphiQL playground at GET /delivery/graphql are only available with GRAPHQL_PLAYGROUND=1; GET /delivery/schema returns the field schemas as JSON and, with ?format=sdl, the generated schema. The schema is cached per tenant with a 60 s TTL and rebuilt only when the hash of the content types changes (X-Schema-Hash header).

SDK. npm install @entrydeck/sdk, then createClient({ baseUrl, apiKey, siteId?, locale?, fetch?, requestInit? }) gives you site(), page(path), pagesTree(), navigation(handle), entries(type, { limit, offset, filter, sort, expand }), entry(type, slug), search(q), sitemap(), form(slug), submitForm(slug, data), preview(token), schema() and graphql(query, variables). Every call passes fetch options through (next: { revalidate }, cache, signal), errors are thrown as EntrydeckError with status, code, body, graphqlErrors and fieldErrors. npx entrydeck-generate-types --base-url … --api-key … --out types.ts generates TypeScript interfaces for your content types from GET /delivery/schema. See the package README for complete Next.js and Nuxt examples.