Headless Drupal with Next.js: A Practical JSON:API Integration Guide

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 --yes

jsonapi 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: false

Preview 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 --yes

Next.js Frontend Setup

npx create-next-app@latest frontend --typescript --app
cd frontend
npm install

Environment 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-secret

JSON: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), and serialization; configure CORS in services.yml
  • Always filter by status=1 to 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,field2 sparse fieldsets to reduce response size
  • Write a typed fetchJsonApi utility that handles errors, headers, and Next.js fetch caching options
  • Use generateStaticParams to pre-render article pages at build time; set revalidate for ISR
  • Write a Drupal webhook (hook_node_update) that calls Next.js revalidatePath on content publish
  • Use next/image with remotePatterns pointing 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_oauth on Drupal for production OAuth2 token-based authentication to protected endpoints
Tags

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.