Drupal as Headless CMS (Decoupled Drupal)
Headless Drupal is an architecture where Drupal acts only as a backend and content API, while the frontend (Next.js, Nuxt, mobile app) connects via JSON:API or GraphQL. This provides complete frontend flexibility but complicates architecture.
Architectural Variants
Fully decoupled — Drupal is API only, frontend is completely separate project. Drupal and frontend deploy independently. No Drupal themes, no Twig.
Progressively decoupled — some pages render traditionally via Drupal, interactive blocks (cart, forms, real-time data) are separate React/Vue components. Easier transition from monolith.
Required Modules
composer require drupal/jsonapi_extras drupal/simple_oauth \
drupal/decoupled_router drupal/subrequests drupal/consumers \
drupal/next drupal/preview_url_generator
drush en jsonapi jsonapi_extras simple_oauth decoupled_router \
subrequests consumers next -y
decoupled_router — resolves URL aliases (/about) to UUID + bundle via API, needed for frontend routing.
consumers — frontend client management with role assignment.
next — official Next.js integration module.
JSON:API Setup for Headless
# Remove unnecessary fields from response (JSON:API Extras)
drush config:set jsonapi_extras.settings default_disabled_fields \
"revision_log,revision_uid,revision_timestamp,menu_link"
JSON:API Extras configuration for content type:
# config/install/jsonapi_extras.jsonapi_resource_config.node--article.yml
id: node--article
resourceType: node--article
resourceFields:
title:
fieldName: title
publicName: title
disabled: false
body:
fieldName: body
publicName: content
disabled: false
field_hero_image:
fieldName: field_hero_image
publicName: hero_image
disabled: false
revision_timestamp:
fieldName: revision_timestamp
disabled: true # hide
Decoupled Router: Path Resolution
# Resolve URL alias to content type and UUID
curl "https://drupal.site.com/router/translate-path?path=/about-us&_format=json"
# Response:
{
"resolved": "/node/42",
"isHomePath": false,
"entity": {
"canonical": "https://drupal.site.com/about-us",
"type": "node",
"bundle": "page",
"id": "42",
"uuid": "a1b2c3d4-..."
}
}
Next.js Integration
// lib/drupal.ts
import { DrupalClient } from "next-drupal";
export const drupal = new DrupalClient(
process.env.NEXT_PUBLIC_DRUPAL_BASE_URL!,
{
auth: {
clientId: process.env.DRUPAL_CLIENT_ID!,
clientSecret: process.env.DRUPAL_CLIENT_SECRET!,
},
}
);
// app/[...slug]/page.tsx
import { drupal } from "@/lib/drupal";
import { DrupalNode } from "next-drupal";
export async function generateStaticParams() {
return await drupal.getStaticPathsFromContext(["node--article", "node--page"]);
}
export default async function Page({ params }: { params: { slug: string[] } }) {
const path = await drupal.translatePathFromContext({ params });
if (!path) notFound();
const node = await drupal.getResourceFromContext<DrupalNode>(path, {
params: {
include: "field_hero_image,field_tags",
fields: {
"node--article": "title,body,field_hero_image,field_tags,created",
},
},
});
return <Article node={node} />;
}
Preview / Draft Mode
// app/api/preview/route.ts
import { drupal } from "@/lib/drupal";
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const path = await drupal.getResourceCollectionPathSegments(
["node--article"],
{
params: { "filter[status][value]": "0" }, // drafts
}
);
draftMode().enable();
redirect(searchParams.get("slug") || "/");
}
On-demand ISR (Incremental Static Regeneration)
When publishing content in Drupal — automatically update Next.js cache:
// Drupal: hook on node save
function mymodule_node_update(NodeInterface $node): void {
$next_base_url = \Drupal::config('next.settings')->get('site_base_url');
$revalidate_secret = \Drupal::config('next.settings')->get('revalidate_secret');
\Drupal::httpClient()->post(
"$next_base_url/api/revalidate",
['json' => ['path' => $node->toUrl()->toString(), 'secret' => $revalidate_secret]]
);
}
GraphQL Alternative
composer require drupal/graphql
drush en graphql -y
GraphQL 4 for Drupal uses schema-first approach with custom resolvers. More flexible than JSON:API but requires more development.
CORS Configuration
# services.yml
parameters:
cors.config:
enabled: true
allowedHeaders: ['*']
allowedMethods: ['*']
allowedOrigins:
- 'https://frontend.yourdomain.com'
- 'http://localhost:3000'
exposedHeaders: false
maxAge: false
supportsCredentials: true
Timeline
Basic headless setup with JSON:API + Next.js — 5–7 days. Full project with Preview mode, On-demand ISR, multilingual — 2–3 weeks.







