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
- Getting Started — Create your first space
- CLI Reference — Schema sync options and setup command
- MCP Tools — Manage content with AI assistants
- AI Builder Integration — Connect Lovable, Bolt.new, and more