Typed Client

decoupled-client is a type-safe TypeScript client for Drupal. It auto-generates interfaces from your GraphQL schema so you get full IDE autocomplete and build-time error checking — without writing a single line of GraphQL.

Quick Start

1. Install

npm install decoupled-client

2. Generate typed client from your Drupal schema

npx decoupled-cli@latest schema sync

This introspects your Drupal space and generates schema/client.ts with:

  • TypeScript interfaces for every content type and paragraph
  • Pre-built GraphQL queries for listing and fetching
  • A createTypedClient() factory with full type safety

3. Use in your app

import { createClient } from 'decoupled-client'
import { createTypedClient } from './schema/client'

const base = createClient({
  baseUrl: process.env.NEXT_PUBLIC_DRUPAL_BASE_URL!,
  clientId: process.env.DRUPAL_CLIENT_ID!,
  clientSecret: process.env.DRUPAL_CLIENT_SECRET!,
})

const client = createTypedClient(base)

// Full autocomplete — types match your actual Drupal schema
const articles = await client.getEntries('NodeArticle', { first: 10 })
const page = await client.getEntryByPath('/about')

The Developer Experience

Here's what it actually looks like to work with Drupal content using the typed client. No guessing, no docs lookup, no runtime surprises.

Deep field access with full autocomplete

import { getClient } from '@/lib/drupal-client'
import type { NodeLandingPage, ParagraphHero, ParagraphCardGroup, ParagraphCard } from '@/schema/client'

const client = getClient()

// Fetch a landing page — typed as NodeLandingPage
const page = await client.getEntryByPath('/services') as NodeLandingPage

// page.title → string ✓ (IDE shows this)
// page.sections → ParagraphUnion[] ✓ (union of all paragraph types)

// Narrow a section to a specific paragraph type
const hero = page.sections?.find(
  (s): s is ParagraphHero => s.__typename === 'ParagraphHero'
)

// Now hero is fully typed — every field autocompletes:
hero.title          // string ✓
hero.subtitle       // Text ✓ — that's { value: string }, NOT a plain string
hero.subtitle.value // string ✓ — this is how you actually render it
hero.layout         // string ✓ — "centered" | "left-aligned"
hero.backgroundColor // string ✓
hero.primaryCtaText  // string ✓
hero.primaryCtaUrl   // string ✓

// Get the card group section
const cardGroup = page.sections?.find(
  (s): s is ParagraphCardGroup => s.__typename === 'ParagraphCardGroup'
)

// Access nested child paragraphs — also typed
const cards = cardGroup.cards as ParagraphCard[]
cards[0].title       // string ✓
cards[0].description // Text ✓ — { value: string }
cards[0].icon        // string ✓ — Lucide icon name
cards[0].linkUrl     // string ✓

The type system catches real bugs

// ✗ BUILD ERROR: Property 'subtitle' is not a string — it's { value: string }
<h2>{hero.subtitle}</h2>
// TypeScript: Type 'Text' is not assignable to type 'ReactNode'

// ✓ CORRECT: Access the .value property
<h2>{hero.subtitle.value}</h2>

// ✗ BUILD ERROR: 'headerImage' doesn't exist on ParagraphHero
hero.headerImage
// TypeScript: Property 'headerImage' does not exist on type 'ParagraphHero'
// Did you mean 'backgroundImage'?

// ✗ BUILD ERROR: Rendering Text object directly shows [object Object]
<p>{card.description}</p>
// TypeScript catches this at build time, not in production

Without the typed client, all three of these would compile fine and break silently at runtime — showing [object Object] or crashing with undefined errors that only appear when a real user visits the page.

Adding a new content type

The entire flow takes about 30 seconds:

# 1. Create an Article content type in Drupal (via admin or MCP)

# 2. Sync — one command regenerates everything
npx decoupled-cli schema sync

# 3. Import and use — full autocomplete immediately
import type { NodeArticle } from '@/schema/client'

const articles = await client.getEntries('NodeArticle', { first: 10 })

// Every field from your Drupal schema is available with autocomplete:
articles[0].title        // string
articles[0].body         // TextSummary — { value: string; summary?: string }
articles[0].body.value   // string — the actual HTML content
articles[0].body.summary // string | undefined — the teaser
articles[0].category     // string
articles[0].author       // string
articles[0].image        // Image — { url, alt, width, height }
articles[0].image.url    // string — ready for <img src={}>

Why Not Just Use GraphQL?

You still can — GraphQL is the underlying protocol. The typed client wraps it so you don't have to write queries by hand.

Hand-written GraphQL Typed Client
Field names Guess from docs, hope they're right IDE autocomplete from schema
Text fields Is it string or { value: string }? TypeScript tells you
Missing fields Runtime crash in production Build-time error in your IDE
New content type Write query, add types, test Run schema sync, import type
Schema changes Manually update queries and types Re-run schema sync

API

createClient(config)

Create a base client with OAuth authentication.

const client = createClient({
  baseUrl: 'https://your-space.decoupled.website',
  clientId: 'your-client-id',
  clientSecret: 'your-client-secret',
  // Optional: custom fetch for Next.js ISR tags
  fetch: (url, init) => globalThis.fetch(url, { ...init, next: { tags: ['drupal'] } }),
})

createTypedClient(baseClient)

Wrap the base client with type-safe methods. Generated by schema sync.

import { createTypedClient } from './schema/client'
const typed = createTypedClient(base)

client.getEntries(type, options?)

Fetch a list of content items by type.

const articles = await client.getEntries('NodeArticle', {
  first: 10,       // Limit
  after: cursor,   // Pagination cursor
  sortKey: 'CREATED_AT',
  reverse: true,   // Newest first
})

Returns a typed array — NodeArticle[] with all fields from your schema.

client.getEntry(type, id)

Fetch a single content item by UUID.

const article = await client.getEntry('NodeArticle', 'uuid-here')
// article.title → string
// article.body → { value: string; summary?: string }
// article.image → { url, alt, width, height } | undefined

client.getEntryByPath(path)

Resolve a Drupal path to a content node.

const page = await client.getEntryByPath('/about')
// Returns the typed content node, or null if not found

client.raw(query, variables?)

Escape hatch — run a raw GraphQL query when you need full control.

const data = await client.raw(`
  query {
    nodeArticles(first: 5) {
      nodes { title path }
    }
  }
`)

Generated Files

Running npx decoupled-cli schema sync generates four files:

File Purpose
schema/client.ts Typed interfaces, queries, and createTypedClient() factory
schema/types.ts Standalone type definitions (no queries)
schema/schema.graphql Full GraphQL SDL
schema/introspection.json Raw introspection result for tooling

Re-run schema sync after any content model change in Drupal.

Type Examples

The generated types match your Drupal schema exactly:

// Node types extend DrupalNode (id, title, path, created, changed)
interface NodeArticle extends DrupalNode {
  __typename: 'NodeArticle'
  body?: TextSummary        // { value: string; summary?: string }
  category?: string
  author?: string
  image?: Image             // { url, alt, width, height }
}

// Paragraph types extend DrupalParagraph (id)
interface ParagraphHero extends DrupalParagraph {
  __typename: 'ParagraphHero'
  title?: string
  subtitle?: Text           // { value: string }
  backgroundColor?: string
  primaryCtaText?: string
  primaryCtaUrl?: string
}

The Text vs string distinction is critical — Drupal text fields return { value: string } objects, not plain strings. The typed client makes this visible at compile time instead of causing [object Object] in your UI at runtime.

Framework Support

Framework Usage
Next.js Server components with ISR tags via custom fetch
React + Vite Works with React Query or direct async calls
Remix Works in loaders
Any Node.js Standalone — just needs fetch

Error Handling

import { DecoupledError, AuthError, NotFoundError } from 'decoupled-client'

try {
  const page = await client.getEntryByPath('/about')
} catch (error) {
  if (error instanceof AuthError) {
    // OAuth credentials are wrong
  } else if (error instanceof DecoupledError) {
    // GraphQL query error — check error.graphqlErrors
  }
}

Next Steps