Skip to main content
← Blog

Decoupled Drupal: Fast JSON:API with Next.js ISR

Isometric 3D schematic of a Decoupled Drupal backend routing JSON:API data nodes into a high-speed edge cache layer.
TL;DR
Decoupled Drupal combines enterprise content modeling with modern frontend performance when paired with Next.js Incremental Static Regeneration. By querying JSON:API with sparse fieldsets and triggering on-demand cache revalidation via webhooks, teams achieve sub-second response times without stale content.

Traditional monolithic CMS rendering often struggles to match the instant interactivity and edge performance modern web users expect. Heavy server-side theme layer execution and unoptimized database queries can introduce latency spikes during unexpected traffic surges. By decoupling Drupal into a headless content hub serving JSON:API to Next.js with Incremental Static Regeneration, engineering teams gain sub-second page loads alongside enterprise editorial workflows.

The Architecture of Decoupled Drupal

In a headless architecture, Drupal functions as the centralized content repository and editorial interface. Next.js runs as an independent frontend application deployed to a global edge network, serving statically pre-rendered HTML while fetching updates on demand.

Drupal core includes JSON:API out of the box, providing a standardized, specification-compliant RESTful interface for all content types, taxonomies, and media entities without requiring custom endpoints.

Key Advantages of Pairing JSON:API with Next.js ISR

Transitioning from monolithic templates to a decoupled model addresses several long-standing performance bottlenecks:

  • Edge-Cached HTML: Pages are pre-rendered at build time and cached across edge nodes, delivering instant time-to-first-byte (TTFB) to global readers.
  • Sparse Fieldsets: JSON:API lets frontend clients specify exact fields needed via URL query parameters, trimming response payloads by over 70%.
  • On-Demand Revalidation: Instead of rebuilding the entire site on each editorial change, Drupal dispatches webhooks to purge only affected paths in Next.js.
  • Security Isolation: Your Drupal administrative dashboard and database remain tucked behind private subnets, completely shielded from direct public traffic.

Implementing Sparse JSON:API Queries in TypeScript

When fetching articles from Drupal's JSON:API, querying the bare collection endpoint returns massive entity graphs packed with revision data and internal properties. We construct explicit query parameters using sparse fieldsets and relationship inclusions to retrieve only what the UI needs.

The TypeScript service below fetches blog articles, handles non-200 responses cleanly, and transforms raw JSON:API resource objects into strongly-typed frontend models.

import { z } from 'zod';

// Define a strict schema for the incoming Drupal JSON:API article payload.
const DrupalArticleSchema = z.object({
  id: z.string().uuid(),
  type: z.literal('node--blog_post'),
  attributes: z.object({
    title: z.string(),
    field_meta_description: z.string().nullable().optional(),
    field_tldr: z.string().nullable().optional(),
    created: z.string(),
    path: z.object({
      alias: z.string().nullable().optional(),
    }),
  }),
  relationships: z.object({
    field_category: z.object({
      data: z.object({ id: z.string(), type: z.string() }).nullable().optional(),
    }),
  }),
});

export type DrupalArticle = z.infer;

interface FetchArticlesOptions {
  limit?: number;
  drupalBaseUrl?: string;
}

/**
 * Fetch published blog posts from Drupal using sparse fieldsets.
 * 
 * We restrict attributes to title, created date, path, and category ID.
 * This keeps network payloads lightweight and speeds up edge worker execution.
 */
export async function getPublishedArticles(
  options: FetchArticlesOptions = {}
): Promise {
  const baseUrl = options.drupalBaseUrl || process.env.DRUPAL_BASE_URL || 'https://www.madunicow.com';
  const limit = options.limit || 10;

  // Build JSON:API URL with filter, sparse fieldset, and sorting.
  const searchParams = new URLSearchParams({
    'filter[status]': '1',
    'sort': '-created',
    'page[limit]': limit.toString(),
    'fields[node--blog_post]': 'title,field_meta_description,field_tldr,created,path,field_category',
  });

  const endpoint = `${baseUrl.replace(/\/$/, '')}/jsonapi/node/blog_post?${searchParams.toString()}`;

  try {
    const response = await fetch(endpoint, {
      headers: {
        'Accept': 'application/vnd.api+json',
        'User-Agent': 'MadUnicow-Decoupled-Client/1.0',
      },
      // Next.js cache configuration: cache for 1 hour as fallback if webhooks fail
      next: { revalidate: 3600, tags: ['drupal-articles'] },
    });

    if (!response.ok) {
      throw new Error(`Drupal JSON:API returned status ${response.status}: ${response.statusText}`);
    }

    const payload = await response.json();
    
    // Validate returned resource records against our Zod schema.
    const parsed = z.array(DrupalArticleSchema).safeParse(payload.data);
    if (!parsed.success) {
      console.error('Validation failed for Drupal JSON:API response:', parsed.error.format());
      throw new Error('Received malformed entity data from Drupal backend.');
    }

    return parsed.data;
  } catch (error) {
    // Log the underlying cause so observability platforms capture network or DNS issues.
    console.error('Failed to retrieve articles from Drupal JSON:API:', error);
    throw error;
  }
}

Setting Up On-Demand Webhook Revalidation

Time-based revalidation works well for low-frequency changes, but editorial teams need instant feedback when publishing breaking news or correcting typos. We configure an on-demand revalidation handler in Next.js that validates a shared secret key before purging the cache tag.

The Route Handler below verifies the incoming secret header, parses the changed entity alias from Drupal, and calls revalidatePath to refresh the edge page instantly.

import { NextRequest, NextResponse } from 'next/server';
import { revalidatePath, revalidateTag } from 'next/cache';

/**
 * Handle cache invalidation webhooks dispatched by Drupal when nodes change.
 */
export async function POST(request: NextRequest): Promise {
  // Extract and verify the shared webhook secret to reject unauthorized callers.
  const webhookSecret = request.headers.get('x-drupal-webhook-token');
  const expectedSecret = process.env.DRUPAL_REVALIDATE_SECRET;

  if (!expectedSecret || webhookSecret !== expectedSecret) {
    return NextResponse.json(
      { success: false, message: 'Invalid or missing authorization token.' },
      { status: 401 }
    );
  }

  try {
    const body = await request.json();
    const { path: slug, operation, bundle } = body;

    // Purge the global list cache tag whenever content is published or deleted.
    revalidateTag('drupal-articles');

    // If an alias or slug is provided, revalidate that specific post path.
    if (slug && typeof slug === 'string') {
      const sanitizedSlug = slug.startsWith('/') ? slug : `/${slug}`;
      revalidatePath(sanitizedSlug);
      
      return NextResponse.json({
        success: true,
        revalidated: true,
        path: sanitizedSlug,
        operation: operation || 'update',
      });
    }

    return NextResponse.json({
      success: true,
      revalidatedTag: 'drupal-articles',
      message: 'Global article collection revalidated.',
    });
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Unknown error';
    return NextResponse.json(
      { success: false, error: message },
      { status: 500 }
    );
  }
}

Testing and Production Deployment Strategy

Before launching a decoupled Drupal architecture, verify that CORS policies in services.yml permit requests only from your authorized frontend domains. Monitor the cache hit ratio on your edge provider to confirm that revalidation webhooks execute reliably without causing origin stampedes.

Establishing automated synthetic tests ensures both your Drupal JSON:API endpoints and Next.js revalidation routes continue operating smoothly through upcoming core upgrades.

Quick takeaways
  • Sparse Fieldsets Cut Payloads: Request only necessary entity attributes via JSON:API to eliminate bloated responses.
  • On-Demand ISR Revalidation: Trigger route revalidation in Next.js using Drupal entity hooks instead of blunt time-based polling.
  • Authentication Separation: Keep consumer read queries public and anonymous while securing mutations with OAuth2 bearer tokens.
Your turn

Are you currently running decoupled Drupal with edge ISR, or does your team still rely on full server-side rendering for complex content types?

Share this
Add new comment

Modern Application Development with staying power — the ambition of a unicorn, the reliability of a cash cow. WordPress plugins, Drupal modules, and the licensing backend behind them.

Enjoyed our projects? Let's build yours.

Book a project