You want Drupal's content modeling and editorial muscle, but you also want a blistering-fast React frontend, app-like transitions, and the freedom to ship the same content to a website today and a mobile app tomorrow. That's the headless promise. This guide is the whole story: how to turn Drupal 11 into a secure, API-first backend and pair it with a Next.js 16 App Router frontend that handles login, editing, media, and complex content blocks — then how to add a second frontend that shares the same identity.
It's built from a real Drupal 11 project running locally on DDEV and wired for Pantheon. Every command here was run. Every error code is one we actually saw.
Should you even go headless?
Before the build, the honest question. Headless Drupal solves real problems for specific teams and creates new ones for others. Go decoupled when you need omnichannel delivery, a modern JS frontend, app-like UX, or a clean separation between editorial and presentation. Stay traditional when a single website is the whole product and your team lives in Drupal's theme layer.
Rule of thumb: match the architecture to an operational need, not to a trend. If your content has to reach more than one "head" — web, mobile, kiosk, an AI agent — headless earns its keep. If it doesn't, you're adding moving parts for no payoff.
Here's the trade-off at a glance.
| Dimension | Traditional Drupal | Headless Drupal 11 + Next.js |
|---|---|---|
| Frontend | Twig theme, server-rendered | React/Next.js, App Router, edge-cached |
| Content reach | One website | Web + apps + IoT (content as data) |
| Performance ceiling | Good with caching | App-like, fine-grained cache control |
| Auth complexity | Built-in sessions | You wire OAuth2 + JWT yourself |
| Editorial preview | Native | Requires Draft Mode plumbing |
| Team skills | PHP/Twig | PHP and TypeScript/React |
| Operational surface | One app to run | Multiple apps, CORS, tokens, revalidation |
If you're still weighing the decision, our breakdown of the benefits of using Drupal as a headless CMS covers the case in more depth.
The stack
| Tool | Version | Role |
|---|---|---|
| Drupal | 11 (PHP 8.3) | Content + identity backend |
| DDEV | latest | Local environment |
| Next.js | 16.2.9 (App Router, Cache Components) | Frontend |
| React | 19.2 (React Compiler) | UI |
| next-drupal | ^2.0 | JSON:API client |
| Auth.js (next-auth) | ^4.24 | Session + login |
| Tiptap | 3.26 | Rich editor |
| Bun | 1.3+ | Runtime + package manager |
Securing Drupal 11 as an API backend
Enable JSON:API
Drupal ships JSON:API in core — enable it and you instantly get every content type, field, and relationship as a RESTful endpoint with filtering, sorting, and pagination built in.
ddev drush en jsonapi serialization -y
ddev drush crAdd the two contrib modules that make it production-friendly:
ddev composer require drupal/jsonapi_extras:^3.0 drupal/decoupled_router:^2.0
ddev drush en jsonapi_extras decoupled_router -y
ddev drush crdecoupled_router returns fully qualified URLs when resolving entity paths — exactly what a Next.js frontend needs to translate a slug into a node.
Configure CORS
Copy the services file and add a CORS block under parameters in web/sites/default/services.yml:
cors.config:
enabled: true
allowedHeaders: ['*']
allowedMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS']
allowedOrigins: ['*']
maxAge: 86400
supportsCredentials: falseProduction note:
allowedOrigins: ['*']is fine for local dev. In production, lock it to your frontend domain. An open CORS policy on an authenticated API is a real security exposure, not a style nit.
Secure it with OAuth2
Install Simple OAuth and the password-grant helper:
ddev composer require drupal/simple_oauth:^6.0 drupal/consumers:^1.0
ddev composer require drupal/simple_oauth_password_grant:^2.1
ddev drush en simple_oauth consumers simple_oauth_password_grant -y
ddev drush crMake the OAuth key paths environment-aware so the same config works on DDEV and Pantheon. Add this to settings.php:
if (isset($_ENV['PANTHEON_ENVIRONMENT'])) {
$key_path = '/code/private/keys';
} else {
$key_path = '/var/www/html/private/keys';
}
if (file_exists($key_path . '/public.key') && file_exists($key_path . '/private.key')) {
$config['simple_oauth.settings']['public_key'] = $key_path . '/public.key';
$config['simple_oauth.settings']['private_key'] = $key_path . '/private.key';
}Then create an OAuth Consumer (/admin/config/services/consumer/add) with the Resource Owner Password Credentials grant. Keep one thing in mind from the start: consumers are content entities, not config — they live in the database and are not exported to config/sync, so you recreate them on each environment rather than expecting a config import to bring them along.
Test the token endpoint directly:
curl -s -X POST "https://cms.app.ddev.site/oauth/token" \\
-d "grant_type=password" \\
-d "client_id=nextjs-app" \\
-d "client_secret=your-secret" \\
-d "username=your-user" \\
-d "password=your-pass" \\
-d "scope="A clean response gives you a Bearer token with a short expires_in. That token is the key to every authenticated write. When you list collections by hand, add --globoff to your curl so the shell doesn't try to expand the brackets in page[limit].
User registration and Google login
Self-service signup uses Drupal's REST endpoint, called without a token — only anonymous users can register themselves, so sending an Authorization header here returns a 403:
curl -s -X POST "https://cms.app.ddev.site/user/register?_format=json" \\
-H "Content-Type: application/json" \\
-d '{
"name": [{"value": "testuser"}],
"mail": [{"value": "testuser@example.com"}],
"pass": {"value": "TestPass123!"}
}'One setting decides the request shape: if "Require email verification" is on, omit pass and Drupal emails a login link; if it's off, include pass and the user is active immediately. Send a password while verification is on and you'll get a 422 — so match the body to the setting and the call just works.
For social login, Drupal 11 uses Social Auth Google ^4.0 (not 2.x, which targets older Drupal). It registers /user/login/google and runs the OAuth handshake server-side.
Heads up: Google's console UI changed in 2025 — the old "OAuth consent screen" is now the Google Auth Platform with Branding, Audience, and Clients sections. Copy the redirect URI straight from Drupal's read-only field; it must match character-for-character, including
https://and the trailing path, or Google answers withredirect_uri_mismatch.
Building the Next.js frontend (Studio)
Scaffold the editorial frontend — we'll call it Studio — with Bun, then add the two libraries that talk to Drupal:
bun create next-app@latest studio # TypeScript, Biome, React Compiler, Tailwind 4, src/, App Router
cd studio
bun add next-drupal next-authAuth.js uses a Credentials provider that exchanges username/password for a Drupal Bearer token at /oauth/token, then stores the token and its expiry in a JWT session:
// src/auth.ts (authorize, trimmed)
const res = await fetch(`${drupalBaseUrl}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "password",
client_id: drupalClientId,
client_secret: drupalClientSecret,
username,
password,
scope: drupalOAuthScope,
}),
});
if (!res.ok) return null;
const token = await res.json();
return {
id: username,
name: username,
accessToken: token.access_token,
accessTokenExpires: Date.now() + (Number(token.expires_in) || 300) * 1000,
};Reads happen by path. A catch-all route resolves any slug to a Drupal node with translatePath, wrapped in Next.js Cache Components so it can be revalidated on demand:
// src/lib/drupal-node-by-path.ts
export async function getCachedNodeByPath<T extends DrupalNode>(path: string) {
"use cache";
cacheLife("hours");
cacheTag(DRUPAL_ARTICLES_TAG, drupalPathTag(path));
const translated = await drupal.translatePath(path);
if (!translated?.jsonapi?.resourceName || !translated.entity?.uuid) return null;
cacheTag(drupalNodeTag(translated.entity.uuid));
return drupal.getResource<T>(translated.jsonapi.resourceName, translated.entity.uuid);
}When content changes in Drupal, the Next.js module POSTs to /api/revalidate with a shared secret, and the route revalidates by tag. If you're tuning the backend that feeds it, our Drupal 11 optimization guide for DDEV and Pantheon covers local-to-production scaling.
Security reminder: DDEV serves HTTPS with a locally-trusted certificate, and Node won't trust it by default. Point Node at the mkcert CA in your
devscript (NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem") rather than disabling TLS verification — otherwise server-sidefetchto Drupal fails withUNABLE_TO_VERIFY_LEAF_SIGNATURE.
Need a Drupal Expert?
Echo Flow provides Canadian businesses with enterprise-grade Drupal engineering.
Real access control, not just "has a token"
Having a valid token isn't the same as having permission to write. Studio probes JSON:API write access by POSTing an empty article and reading the status — a permitted editor gets 422 (your payload is invalid but you may write), a forbidden user gets 403. No content is created either way.
async function probeDrupalArticleCreateAccess(accessToken: string) {
const res = await fetch(`${drupalBaseUrl}/jsonapi/node/article`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/vnd.api+json",
},
body: JSON.stringify({ data: { type: "node--article" } }),
cache: "no-store",
});
return res.status === 422; // 422 = allowed, 403 = forbidden
}The same layer adds frontend-direct Google sign-in (the user never sees the Drupal URL), silent token refresh via the refresh_token grant, and Draft Mode preview for unpublished revisions.
A Notion-style editor that stays headless
This is where the project earns its keep. The plain <textarea> becomes a Tiptap editor with a / slash menu and Markdown shortcuts. Crucially, there's no new save API — the editor writes into a hidden input the existing server action already reads.
The body then graduates from Markdown to HTML so it can carry <figure> + <figcaption> + alignment + a Drupal media reference. Images upload as real, reusable media--image entities, and a single shared .rich-content stylesheet renders both the editor and the published page so the two can never drift.
The payoff is composable content: Paragraphs-level blocks — cards, callouts, columns — that an editor drops anywhere by pressing /, all driven by one registry.
The registry is the source of truth — one entry defines a block's props, its shared View (used by both editor and page), and an optional in-editor Form:
export type BlockDefinition<P = Record<string, unknown>> = {
type: string;
title: string;
icon: ReactNode;
keywords?: string[];
defaultProps: P;
View: (args: { props: P }) => ReactNode; // shared edit + page render
Form?: (args: { props: P; onChange: (next: P) => void }) => ReactNode;
};Add a new block — a chart, a pricing card, columns — and the slash menu, in-editor preview, serialization, and published rendering all pick it up automatically. One generic Tiptap node serializes each block to a tidy marker the page renderer maps back to a React component:
<div data-block="card" data-props='{"title":"Pricing","body":"…","cta":"/signup"}'></div>The architecture decision that matters: keep block structure in the Next.js app and persist it as lightweight meta in the Drupal body. It's DRY (one component renders everywhere) and keeps Drupal a simple store. Reach for Drupal Paragraphs or entity references only when a block must be shared or queried server-side across many nodes.
A second frontend on one identity provider (Reel)
The final move proves the model scales: a second frontend — a video hub we'll call Reel — reuses the same Drupal identity provider. Name it right and the design falls out. This is two OAuth 2.0 clients sharing one authorization server, not microservices and not SSO.
The hard-won lessons here are the most valuable in the whole build:
- We tried authorization-code + PKCE (the standards-correct path) and reverted to the password grant. Port drift, DDEV TLS trust, confidential-client auth, and the lack of an in-app form turned into death by a thousand cuts. Studio's password-grant login already worked and could be mirrored in an afternoon — a deliberate, documented trade of standards-purity for parity and speed.
- Same-origin cookie clash. Two frontends on
localhost:3000clobber each other's session cookie, throwingJWT_SESSION_ERROR. The fix is to namespace Reel's cookies (for examplereel.session-token) so the two never collide. subis the integer uid, not the UUID. Simple OAuth's/oauth/userinforeturns the internal uid; JSON:API addresses users by UUID. Resolve it once at sign-in.- Per-user (bring-your-own) Cloudinary. Each user supplies their own
CLOUDINARY_URL, encrypted app-side with AES-256-GCM before it ever reaches Drupal — so Drupal only stores ciphertext, made owner-only via Field Permissions. Pass credentials per call; never mutate a globalcloudinary.config(), which is unsafe under concurrency.
The interesting work was the integration seams, not the features — cookie namespacing, the uid-vs-UUID trap, the DDEV certificate, and the token-revoking save. Those are the parts worth remembering.
Where to take it next
You now have a secure Drupal 11 API, an editorial frontend with real auth and a Notion-style block editor, and a second frontend on the same identity provider. Natural next steps:
- Lock CORS origins and swap the dev
administratoruser for a scopedAPI Clientrole. - Move secret encryption from a single env key to KMS envelope encryption (the
v1:ciphertext prefix is already there for it). - Add schema validation (e.g. Zod) for block props.
- Graduate the rare shared or queryable block to Drupal Paragraphs.
Headless isn't free — you trade operational simplicity for reach and frontend freedom. But when you genuinely need more than one head, this stack delivers a fast, secure, editor-friendly system that treats your content as what it really is: pure, portable data. If you want to push the Drupal side further, see our practical guide to Drupal 11.2 custom entity development.
Frequently asked questions
Is headless Drupal 11 worth it?
It depends on reach. If your content has to serve more than one front end — a website plus a mobile app, a kiosk, or an AI agent — the decoupled model pays for itself in flexibility and frontend speed. If a single website is the whole product, traditional Drupal is simpler and cheaper to run.
Do I have to use the OAuth password grant?
No, but it's the pragmatic choice for a trusted, first-party frontend. Authorization-code + PKCE is the standards-correct pattern and gives you single sign-on across apps for free; it's just more moving parts to wire up against a local backend. The password grant gets you a working in-app login fast, with a clear upgrade path to PKCE later.
Why does my registration request return 403?
You're sending an Authorization header. Only anonymous users can self-register, so the registration call must go out with no Bearer token attached.
Why won't Node trust my DDEV HTTPS backend?
DDEV uses a locally-trusted mkcert certificate that Node doesn't recognize, which surfaces as UNABLE_TO_VERIFY_LEAF_SIGNATURE. Point Node at the mkcert root CA with NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem" in your dev script instead of turning off TLS verification.
Why do two frontends on localhost break each other's login?
They share the same default session-cookie names on one origin, and neither app can decrypt the other's session, so you get JWT_SESSION_ERROR. Give each app its own cookie names (for example reel.session-token) and they coexist cleanly.
My OAuth consumer disappeared after importing config — why?
Consumers are content entities, not configuration, so they aren't part of config/sync and a config import won't recreate them. Add the consumer again on each environment.
Can I add my own content blocks to the editor?
Yes. Add one entry to the block registry — its props, a shared View, and an optional in-editor Form — and the slash menu, live preview, serialization, and published rendering all pick it up automatically.