Decoupled Drupal separates the content management backend from the presentation layer. Drupal handles content modelling, editorial workflows, and permissions; Next.js handles rendering, routing, and performance. The bridge between them is Drupal's JSON:API module, which ships in core and exposes every entity type as a standardised REST API with zero configuration. This guide covers everything you need to go from a fresh Drupal install to a working Next.js frontend that statically generates content at build time and revalidates on-demand.
Drupal Backend Setup
Enable Required Modules
drush en jsonapi jsonapi_extras serialization basic_auth --yesjsonapi is in core. jsonapi_extras (contrib) adds resource type configuration, aliasing, and field exclusions — useful for production APIs.
JSON:API URL Structure
GET /jsonapi/{entity_type}/{bundle}
GET /jsonapi/{entity_type}/{bundle}/{uuid}
Examples:
GET /jsonapi/node/article
GET /jsonapi/node/article/a1b2c3d4-...
GET /jsonapi/taxonomy_term/tags
GET /jsonapi/media/image/{uuid}
GET /jsonapi/file/file/{uuid}Filtering, Sorting, and Pagination
# Published articles only, newest first
curl "https://cms.example.com/jsonapi/node/article\
?filter[status][value]=1\
&sort=-created\
&page[limit]=10\
&page[offset]=0"
# Articles with a specific tag (relationship filter)
curl "https://cms.example.com/jsonapi/node/article\
?filter[field_tags.name][value]=Drupal\
&filter[status][value]=1"
# Include related entities (author, tags, image)
curl "https://cms.example.com/jsonapi/node/article\
?include=uid,field_tags,field_image.field_media_image\
&filter[status][value]=1"CORS Configuration for Decoupled Setup
Add to web/sites/default/services.yml:
parameters:
cors.config:
enabled: true
allowedHeaders: ['*']
allowedMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']
allowedOrigins:
- 'https://www.example.com'
- 'http://localhost:3000'
exposedHeaders: false
maxAge: false
supportsCredentials: falsePreview Mode: Draft Content Access
For Next.js draft preview, create a Drupal user with "Bypass content access control" permission and generate a long-lived token. Using the simple_oauth module is recommended for production:
composer require drupal/simple_oauth
drush en simple_oauth --yesNext.js Frontend Setup
npx create-next-app@latest frontend --typescript --app
cd frontend
npm installEnvironment Variables
# .env.local
DRUPAL_BASE_URL=https://cms.example.com
DRUPAL_PREVIEW_SECRET=your-long-random-secret
# For authenticated requests (optional — public content needs no auth)
DRUPAL_CLIENT_ID=your-oauth-client-id
DRUPAL_CLIENT_SECRET=your-oauth-client-secretJSON:API Client Utility
// lib/drupal.ts
const BASE_URL = process.env.DRUPAL_BASE_URL!;
export interface JsonApiResponse<T> {
data: T;
included?: JsonApiResource[];
meta?: { count: number };
links?: { next?: { href: string } };
}
export interface JsonApiResource {
id: string;
type: string;
attributes: Record<string, unknown>;
relationships?: Record<string, { data: JsonApiRelData | JsonApiRelData[] | null }>;
}
export interface JsonApiRelData {
id: string;
type: string;
}
export async function fetchJsonApi<T>(
path: string,
params: Record<string, string> = {},
options: RequestInit = {}
): Promise<JsonApiResponse<T>> {
const url = new URL(`${BASE_URL}${path}`);
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
const res = await fetch(url.toString(), {
headers: {
Accept: 'application/vnd.api+json',
...options.headers,
},
...options,
});
if (!res.ok) {
throw new Error(`JSON:API error: ${res.status} ${res.statusText} — ${url}`);
}
return res.json() as Promise<JsonApiResponse<T>>;
}
// Helper: find an included resource by type + id
export function findIncluded(
included: JsonApiResource[] | undefined,
type: string,
id: string
): JsonApiResource | undefined {
return included?.find((r) => r.type === type && r.id === id);
}Fetching and Displaying Articles
// lib/articles.ts
import { fetchJsonApi, JsonApiResource, findIncluded } from './drupal';
export interface Article {
id: string;
title: string;
slug: string;
body: string;
summary: string;
created: string;
author: string;
tags: string[];
imageUrl: string | null;
imageAlt: string;
}
function mapArticle(
resource: JsonApiResource,
included?: JsonApiResource[]
): Article {
const attr = resource.attributes;
// Resolve author
const authorRel = resource.relationships?.uid?.data as { id: string; type: string } | null;
const authorEntity = authorRel ? findIncluded(included, 'user--user', authorRel.id) : null;
const author = (authorEntity?.attributes?.name as string) ?? 'Unknown';
// Resolve tags
const tagRels = resource.relationships?.field_tags?.data as { id: string; type: string }[] ?? [];
const tags = tagRels
.map((rel) => findIncluded(included, 'taxonomy_term--tags', rel.id))
.filter(Boolean)
.map((t) => t!.attributes.name as string);
// Resolve image
const imageRel = resource.relationships?.field_image?.data as { id: string; type: string } | null;
const mediaEntity = imageRel ? findIncluded(included, 'media--image', imageRel.id) : null;
const fileRel = mediaEntity?.relationships?.field_media_image?.data as { id: string; type: string } | null;
const fileEntity = fileRel ? findIncluded(included, 'file--file', fileRel.id) : null;
const rawUrl = fileEntity?.attributes?.uri as { url: string } | null;
const imageUrl = rawUrl ? `${process.env.DRUPAL_BASE_URL}${rawUrl.url}` : null;
return {
id: resource.id,
title: attr.title as string,
slug: (attr.path as { alias: string })?.alias ?? `/node/${resource.id}`,
body: (attr.body as { processed: string })?.processed ?? '',
summary: (attr.body as { summary: string })?.summary ?? '',
created: attr.created as string,
author,
tags,
imageUrl,
imageAlt: (attr.field_image_alt as string) ?? (attr.title as string),
};
}
export async function getArticles(limit = 10, offset = 0): Promise<Article[]> {
const response = await fetchJsonApi<JsonApiResource[]>(
'/jsonapi/node/article',
{
'filter[status][value]': '1',
'sort': '-created',
'page[limit]': String(limit),
'page[offset]': String(offset),
'include': 'uid,field_tags,field_image.field_media_image',
'fields[node--article]': 'title,body,path,created,uid,field_tags,field_image',
'fields[user--user]': 'name',
'fields[taxonomy_term--tags]': 'name',
'fields[media--image]': 'field_media_image',
'fields[file--file]': 'uri',
},
{ next: { revalidate: 60 } } // ISR: revalidate every 60 seconds
);
return response.data.map((r) => mapArticle(r, response.included));
}
// Note: Drupal's JSON:API does not support filtering by path alias directly.
// Install the decoupled_router module (drupal/decoupled_router) to resolve
// aliases to UUIDs, then fetch by UUID. Alternatively, fetch by node ID:
export async function getArticleByNid(nid: number): Promise<Article | null> {
const response = await fetchJsonApi<JsonApiResource[]>(
'/jsonapi/node/article',
{
'filter[drupal_internal__nid]': String(nid),
'filter[status][value]': '1',
'include': 'uid,field_tags,field_image.field_media_image',
},
{ next: { revalidate: 60 } }
);
if (!response.data.length) return null;
return mapArticle(response.data[0], response.included);
}
// With decoupled_router installed, resolve a path alias first:
export async function getArticleBySlug(slug: string): Promise<Article | null> {
const BASE_URL = process.env.DRUPAL_BASE_URL!;
// Step 1: resolve alias → entity UUID via decoupled_router
const routerRes = await fetch(`${BASE_URL}/router/translate-path?path=${slug}`);
if (!routerRes.ok) return null;
const routerData = await routerRes.json();
const uuid: string | undefined = routerData?.entity?.uuid;
if (!uuid) return null;
// Step 2: fetch the entity by UUID
const response = await fetchJsonApi<JsonApiResource>(
`/jsonapi/node/article/${uuid}`,
{ 'include': 'uid,field_tags,field_image.field_media_image' },
{ next: { revalidate: 60 } }
);
return mapArticle(response.data as unknown as JsonApiResource, response.included);
}
export async function getAllArticleSlugs(): Promise<string[]> {
const response = await fetchJsonApi<JsonApiResource[]>(
'/jsonapi/node/article',
{
'filter[status][value]': '1',
'fields[node--article]': 'path',
'page[limit]': '1000',
}
);
return response.data
.map((r) => (r.attributes.path as { alias: string })?.alias)
.filter(Boolean);
}Next.js App Router Pages
// app/articles/page.tsx
import { getArticles } from '@/lib/articles';
import Link from 'next/link';
import Image from 'next/image';
export const revalidate = 60; // ISR: page re-generates every 60s
export default async function ArticlesPage() {
const articles = await getArticles(12);
return (
<main>
<h1>Articles</h1>
<ul className="article-grid">
{articles.map((article) => (
<li key={article.id}>
{article.imageUrl && (
<Image
src={article.imageUrl}
alt={article.imageAlt}
width={800}
height={450}
priority={false}
/>
)}
<h2><Link href={article.slug}>{article.title}</Link></h2>
<p>{article.summary}</p>
<p>By {article.author} · {new Date(article.created).toLocaleDateString('da-DK')}</p>
</li>
))}
</ul>
</main>
);
}// app/[...slug]/page.tsx
import { getArticleBySlug, getAllArticleSlugs } from '@/lib/articles';
import { notFound } from 'next/navigation';
export const revalidate = 60;
export async function generateStaticParams() {
const slugs = await getAllArticleSlugs();
// Strip leading slash; Next.js expects an array of path segments
return slugs.map((slug) => ({ slug: slug.replace(/^\//, '').split('/') }));
}
interface PageProps {
params: { slug: string[] };
}
export default async function ArticlePage({ params }: PageProps) {
const slug = '/' + params.slug.join('/');
const article = await getArticleBySlug(slug);
if (!article) notFound();
return (
<article>
<h1>{article.title}</h1>
<p>
By {article.author} ·{' '}
{new Date(article.created).toLocaleDateString('da-DK')}
</p>
{article.tags.length > 0 && (
<ul className="tags">
{article.tags.map((tag) => <li key={tag}>{tag}</li>)}
</ul>
)}
<div dangerouslySetInnerHTML={{ __html: article.body }} />
</article>
);
}On-Demand Revalidation (Webhooks)
Configure Drupal to POST to a Next.js API route whenever content is published. Install the drupal/http_client_manager or use a simple hook:
<?php
// my_module.module
use Drupal\node\NodeInterface;
/**
* Implements hook_node_insert().
*/
function my_module_node_insert(NodeInterface $node): void {
my_module_invalidate_next_cache($node);
}
/**
* Implements hook_node_update().
*/
function my_module_node_update(NodeInterface $node): void {
my_module_invalidate_next_cache($node);
}
function my_module_invalidate_next_cache(NodeInterface $node): void {
$client = \Drupal::httpClient();
$secret = \Drupal::config('my_module.settings')->get('nextjs_revalidate_secret');
$path = $node->toUrl('canonical')->toString();
try {
$client->post('https://www.example.com/api/revalidate', [
'json' => ['path' => $path, 'secret' => $secret],
'timeout' => 5,
]);
}
catch (\Throwable) {
// Non-fatal — Next.js will revalidate on next request anyway
}
}// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const { path, secret } = await request.json();
if (secret !== process.env.DRUPAL_PREVIEW_SECRET) {
return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
}
revalidatePath(path);
return NextResponse.json({ revalidated: true, path });
}next.config.js: Image Domains and Rewrites
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'cms.example.com',
pathname: '/sites/default/files/**',
},
],
},
async rewrites() {
return [
// Proxy Drupal's sitemap through Next.js domain
{
source: '/sitemap.xml',
destination: `${process.env.DRUPAL_BASE_URL}/sitemap.xml`,
},
];
},
};
module.exports = nextConfig;Summary Checklist
- Enable
jsonapi(core),jsonapi_extras(contrib), andserialization; configure CORS inservices.yml - Always filter by
status=1to exclude unpublished content from public API responses - Use
?include=to fetch related entities (authors, tags, images) in a single request — avoids N+1 fetches - Use
?fields[type--bundle]=field1,field2sparse fieldsets to reduce response size - Write a typed
fetchJsonApiutility that handles errors, headers, and Next.jsfetchcaching options - Use
generateStaticParamsto pre-render article pages at build time; setrevalidatefor ISR - Write a Drupal webhook (
hook_node_update) that calls Next.jsrevalidatePathon content publish - Use
next/imagewithremotePatternspointing at your Drupal files domain - For preview/draft content, use a separate authenticated Drupal user with OAuth and pass the token server-side only
- Install
simple_oauthon Drupal for production OAuth2 token-based authentication to protected endpoints