NextAgency
All guides Decoupled Drupal

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.

9 min read · Published April 14, 2026

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, and metatag.
  • Frontend: Next.js 15 App Router, TypeScript, Tailwind CSS.
  • Preview: The next contrib 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 dev for 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_extras lets 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.yml to 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:

  1. 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.
  2. Revalidation. When content is saved in Drupal, the module pings Next.js to trigger on-demand ISR revalidation of the affected pages.
  3. 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:

  1. Editor clicks “Preview” on a draft node in Drupal.
  2. Drupal (via the next module) generates a JWT containing the node ID and a preview secret, then redirects to https://yoursite.com/api/draft?secret=...&slug=/article/my-slug.
  3. Next.js has a route at /api/draft/route.ts that validates the secret, calls draftMode().enable(), and redirects to the actual node URL.
  4. 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.
  5. 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 /redirect endpoint for 301s before rendering 404.
  • Metadata — map Drupal’s metatag module output into Next.js generateMetadata.
  • Sitemap — generate a Next.js sitemap that fetches from Drupal’s sitemap or walks the content collection.
  • robots.txtapp/robots.ts in 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

  • include parameter. JSON:API requires explicit include to fetch related entities (media, references). Forget this and you’ll wonder why your images are missing URLs.
  • File URLs are relative. jsonapi returns file URIs like public://images/hero.jpg, not full HTTPS URLs. You need jsonapi_extras or 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:

Building something decoupled?

We'll give you an honest read on whether decoupled Drupal is the right call for your project.

Start a conversation