Headless Drupal with React: A Practical Starter
A concrete guide to setting up headless Drupal with React — using Next.js as the frontend, JSON:API as the contract, and preview that actually works for editors.
Headless Drupal with React is the most common decoupled Drupal stack we ship. Drupal handles content; React (via Next.js) handles the frontend. This guide walks through the concrete setup — not the theory (see Drupal and React: The Complete Guide for that) but the specific configuration, modules, and code you need to ship a working site.
The stack
- Backend: Drupal 10 or 11 with
jsonapi,jsonapi_extras,next,pathauto,redirect, andmetatag. - Frontend: Next.js 15 App Router, TypeScript, Tailwind CSS.
- Preview: The
nextcontrib module provides the Drupal side of preview tunneling; Next.js Draft Mode handles the frontend side. - Deploy: Drupal on your infra of choice (Pantheon, Platform.sh, Acquia Cloud, or self-hosted Docker); Next.js on Vercel.
- Dev: DDEV or Lando for Drupal locally;
next devfor the frontend.
Step 1: Set up Drupal for JSON:API
Install Drupal 11 (or 10) and enable core modules:
drush en jsonapi jsonapi_extras path pathauto redirect metatag
Out of the box, JSON:API exposes every content entity in your site at /jsonapi/<entity_type>/<bundle>. For a node of type article, that’s /jsonapi/node/article. The module returns JSON:API spec-compliant responses with relationships, attributes, and pagination.
Key configuration:
jsonapi_extraslets you selectively expose or hide entities and fields. Hide anything that shouldn’t be public (admin-only content types, internal user fields, etc.).- CORS. Edit
sites/default/services.ymlto allow requests from your Next.js domain:
cors.config:
enabled: true
allowedHeaders: ['*']
allowedMethods: ['GET', 'POST', 'OPTIONS']
allowedOrigins: ['https://yoursite.com', 'http://localhost:3000']
- Pathauto + Redirect. Generate clean URLs for your content (pathauto) and store redirects for any URL that moves (redirect). We need both because Drupal will return the path alias in JSON:API responses, and the frontend will route on that.
Step 2: Install the Next.js contrib module
composer require drupal/next
drush en next
The next module handles three things:
- Preview tunneling. When an editor clicks “Preview” on a node, Drupal issues a preview JWT and redirects the editor to the Next.js frontend with a token in the URL.
- Revalidation. When content is saved in Drupal, the module pings Next.js to trigger on-demand ISR revalidation of the affected pages.
- Site configuration. Stores the Next.js site URL, preview secret, and revalidation endpoint in Drupal config.
In the Drupal admin, go to Configuration → Next.js → Sites and create a new site entry with your Next.js URL and a preview secret.
Step 3: Set up the Next.js frontend
Create a new Next.js app with the App Router:
npx create-next-app@latest --typescript --tailwind --app
Install a JSON:API client. We usually use jsona (parses JSON:API into plain objects) or write a thin custom wrapper around fetch:
npm install jsona
Environment variables (.env.local):
NEXT_PUBLIC_DRUPAL_BASE_URL=https://cms.yoursite.com
DRUPAL_PREVIEW_SECRET=your-preview-secret-from-drupal
DRUPAL_REVALIDATE_SECRET=your-revalidate-secret
A minimal data fetcher (lib/drupal.ts):
import Jsona from 'jsona';
const dataFormatter = new Jsona();
const BASE = process.env.NEXT_PUBLIC_DRUPAL_BASE_URL!;
export async function fetchNode(type: string, slug: string) {
const url = `${BASE}/jsonapi/node/${type}?filter[path.alias]=/${slug}&include=field_media.field_media_image`;
const res = await fetch(url, { next: { tags: [`node:${type}:${slug}`] } });
if (!res.ok) return null;
const data = await res.json();
return dataFormatter.deserialize(data.data[0]);
}
Notice next: { tags: [...] } — this is Next.js 15’s cache tagging. When Drupal saves a node, the revalidation webhook will call revalidateTag('node:article:my-slug') and Next.js will re-fetch that specific content. No full site rebuild needed.
Step 4: Build a page
A simple catch-all route (app/[...slug]/page.tsx):
import { fetchNode } from '@/lib/drupal';
import { notFound } from 'next/navigation';
export default async function Page({ params }: { params: { slug: string[] } }) {
const slug = params.slug.join('/');
const node = await fetchNode('article', slug);
if (!node) notFound();
return (
<article>
<h1>{node.title}</h1>
<div dangerouslySetInnerHTML={{ __html: node.body.processed }} />
</article>
);
}
This is the skeleton. In a real project you’ll handle multiple content types, resolve paths to specific routes, render component-based content (Paragraphs or similar), and manage metadata — but the core shape is this simple.
Step 5: Wire up preview
Preview is where most teams get stuck. Here’s the flow:
- Editor clicks “Preview” on a draft node in Drupal.
- Drupal (via the
nextmodule) generates a JWT containing the node ID and a preview secret, then redirects tohttps://yoursite.com/api/draft?secret=...&slug=/article/my-slug. - Next.js has a route at
/api/draft/route.tsthat validates the secret, callsdraftMode().enable(), and redirects to the actual node URL. - Next.js renders the requested page. Because draft mode is enabled, your data fetcher fetches the draft version from Drupal instead of the published version.
- Editor sees the draft rendered on the real frontend and can close preview via a cookie.
The draft route (app/api/draft/route.ts):
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
const slug = searchParams.get('slug');
if (secret !== process.env.DRUPAL_PREVIEW_SECRET) {
return new Response('Invalid secret', { status: 401 });
}
(await draftMode()).enable();
redirect(slug ?? '/');
}
And in your data fetcher, check draft mode:
import { draftMode } from 'next/headers';
export async function fetchNode(type: string, slug: string) {
const { isEnabled: isDraft } = await draftMode();
const resourceVersion = isDraft ? 'rel:working-copy' : 'rel:latest-version';
const url = `${BASE}/jsonapi/node/${type}?filter[path.alias]=/${slug}&resourceVersion=${resourceVersion}`;
// ... rest of fetch
}
The resourceVersion query parameter is what tells JSON:API whether to return the published or draft revision.
Step 6: Wire up revalidation
When a node is saved in Drupal, the next module POSTs to /api/revalidate on your Next.js app. Your route handler tells Next.js to drop its cache for that node.
app/api/revalidate/route.ts:
import { revalidateTag } from 'next/cache';
export async function POST(request: Request) {
const body = await request.json();
const secret = request.headers.get('x-revalidate-secret');
if (secret !== process.env.DRUPAL_REVALIDATE_SECRET) {
return new Response('Invalid secret', { status: 401 });
}
const tag = `node:${body.entityType}:${body.path}`;
revalidateTag(tag);
return Response.json({ revalidated: true });
}
Now when an editor saves a node in Drupal, the next request to that page fetches fresh content — within seconds of publishing.
What else you’ll need
This is the skeleton. A real production site also needs:
- 404 handling when a URL doesn’t exist in Drupal.
- Redirect handling — check
/redirectendpoint for 301s before rendering 404. - Metadata — map Drupal’s
metatagmodule output into Next.jsgenerateMetadata. - Sitemap — generate a Next.js sitemap that fetches from Drupal’s sitemap or walks the content collection.
- robots.txt —
app/robots.tsin Next.js. - Asset proxying — rewrite Drupal file URLs so images load from your CDN, not from the Drupal backend.
- Authentication — if you have logged-in users, pick OAuth2 through Drupal or a separate auth provider.
Each of these is another page of setup. The starter above gets you to a working content fetch with preview; the extras take another 1-2 weeks for a first production deployment.
Common gotchas
includeparameter. JSON:API requires explicitincludeto fetch related entities (media, references). Forget this and you’ll wonder why your images are missing URLs.- File URLs are relative.
jsonapireturns file URIs likepublic://images/hero.jpg, not full HTTPS URLs. You needjsonapi_extrasor a processing step to convert them. - CORS preflights. If you’re calling Drupal from the browser (not just from the Next.js server), preflight OPTIONS requests need to be allowed. Double-check your
services.yml. - Draft mode cookies across preview links. Next.js draft mode uses a cookie. If editors open preview in a new tab, the cookie may not carry. Test this flow carefully.
TL;DR
Headless Drupal with React (Next.js) is a well-understood stack: Drupal serves JSON:API, Next.js fetches and renders, the next module handles preview and revalidation. The first-time setup takes 1-2 weeks to get right, and another 1-2 weeks to handle the production essentials (metadata, 404s, redirects, sitemap, assets). After that, adding content types and routes is cheap.
We build production headless Drupal sites — if you want help getting your first one past the “demo works but preview is broken” stage, let’s talk.
Related reading: