Post

Headless Drupal with Next.js and GraphQL Integration Guide

A complete guide to integrating GraphQL into a headless Drupal + Next.js setup, from local development to dynamic routing.

Overview

In a headless Drupal + Next.js setup, GraphQL serves as the API layer that lets your Next.js app fetch Drupal content using a single endpoint (/graphql) with strongly-typed queries, instead of Drupal’s REST endpoints or JSON:API.

Architecture Components

Drupal side:

  • Content modeling + editorial UI
  • GraphQL endpoint (public + authenticated)
  • Schema definition and permissions

Next.js side:

  • Server-side GraphQL fetching
  • ISR / revalidation for content
  • Preview mode for drafts

Optional:

  • Webhooks from Drupal → Next.js revalidation
  • Persisted queries
  • CDN in front of Next.js

Why GraphQL for Headless Drupal?

Pros

  • Ask for exactly the fields you need (no over-fetching)
  • Great for component-driven pages (paragraphs/layout builder)
  • One endpoint, strongly typed schema
  • Self-documenting API

Tradeoffs

  • Must design and maintain the schema
  • Caching at the edge can be trickier than JSON:API
  • Badly-designed queries can cause N+1 problems without limits

Local Development Setup

1. Install Local Drupal with DDEV

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Create project folder
mkdir my-drupal-project
cd my-drupal-project

# Initialize DDEV
ddev config --project-type=drupal10

# Create Drupal project
ddev composer create drupal/recommended-project

# Start DDEV
ddev start

# Install Drupal through browser
# Visit: https://yourproject.ddev.site

2. Install GraphQL Modules

1
2
3
4
5
6
7
8
# Install GraphQL Compose (recommended for headless)
ddev composer require drupal/graphql_compose

# Enable modules
ddev drush en graphql graphql_compose -y

# Clear cache
ddev drush cr

3. Configure GraphQL Server

  1. Navigate to Configuration → Web services → GraphQL
  2. Create or edit the GraphQL server
  3. Set endpoint path: /graphql
  4. Select schema: GraphQL Compose (not “Example”)
  5. Save configuration

4. Set Permissions

Go to People → Permissions and enable:

  • Execute GraphQL arbitrary GraphQL requests for Anonymous user (dev only)
  • Execute GraphQL arbitrary GraphQL requests for Authenticated user

Testing Drupal GraphQL Endpoint

Test with curl

1
2
3
curl -i -X POST "https://drupaltest.ddev.site/graphql" \
  -H "Content-Type: application/json" \
  --data '{"query":"{ __typename }"}'

Expected response:

1
2
3
4
5
{
  "data": {
    "__typename": "Query"
  }
}

Common Errors

403 Access Denied:

1
{"message":"The 'execute graphql arbitrary graphql requests' permission is required."}

Fix: Enable the permission in Drupal (see step 4 above)

405 Method Not Allowed: The endpoint expects POST, not GET. Use curl with -X POST or fetch with method: "POST".

Next.js Setup

1. Create Next.js Project

1
2
npx create-next-app@latest drupal-next
cd drupal-next

2. Configure Environment Variables

Create .env.local:

1
DRUPAL_GRAPHQL_URL=http://drupaltest.ddev.site/graphql

Note: Use HTTP instead of HTTPS to avoid local TLS certificate issues.

3. Basic Test Page

Create app/page.tsx:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export default async function Home() {
  const res = await fetch(process.env.DRUPAL_GRAPHQL_URL!, {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      query: "{ __typename }"
    }),
    cache: "no-store",
  });

  const json = await res.json();

  return (
    <main style={{ padding: 24 }}>
      <h1>Drupal GraphQL Test</h1>
      <p>Status: {res.status}</p>
      <pre>{JSON.stringify(json, null, 2)}</pre>
    </main>
  );
}

Run the dev server:

1
npm run dev

Visit http://localhost:3000 - you should see the GraphQL response.

Troubleshooting Common Issues

Issue: TLS Certificate Error

Error:

1
2
3
TypeError: fetch failed
[cause]: Error: unable to verify the first certificate
code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'

Solution 1: Use HTTP (recommended for local dev)

1
2
# .env.local
DRUPAL_GRAPHQL_URL=http://drupaltest.ddev.site/graphql

Solution 2: Trust DDEV’s Certificate

1
2
3
4
5
# Find DDEV's root CA
find ~/.ddev -name "*.pem" | head

# Tell Node to trust it
export NODE_EXTRA_CA_CERTS="$HOME/.ddev/global_certs/rootCA.pem"

Solution 3: Disable TLS Verification (dev only)

1
NODE_TLS_REJECT_UNAUTHORIZED=0 npm run dev

Issue: DNS Resolution (ENOTFOUND)

Error:

1
getaddrinfo ENOTFOUND drupaltest.ddev.site

Fix:

1
2
3
4
5
6
7
# Check DDEV is running
ddev describe

# Test DNS
ping drupaltest.ddev.site

# Use exact URL from ddev describe

Issue: Fetch Failed in Next.js

Test Node fetch directly:

1
node -e "fetch('http://drupaltest.ddev.site/graphql',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({query:'{__typename}'})}).then(r=>r.text()).then(console.log).catch(e=>console.error(e))"

Issue: route() Returns UnsupportedType

Error:

1
2
3
4
5
6
7
8
9
10
{
  "data": {
    "route": {
      "__typename": "RouteInternal",
      "entity": {
        "__typename": "UnsupportedType"
      }
    }
  }
}

Problem:

The route() query works, but returns UnsupportedType instead of your content type (Article, Page, etc.).

Cause:

You haven’t enabled “Enable loading by route” for the content type in GraphQL Compose settings.

Fix:

  1. Go to Structure → Content types → Article → Edit
  2. Find GraphQL Compose section
  3. Enable these checkboxes:
    • ✅ Enable GraphQL
    • ✅ Enable single query
    • ✅ Enable edge query
    • Enable loading by route ← Critical!
  4. Save
  5. Repeat for all content types you want to expose via route()
  6. Clear cache: ddev drush cr

Verify the fix:

1
2
3
curl -s -X POST "http://drupaltest.ddev.site/graphql" \
  -H "Content-Type: application/json" \
  --data '{"query":"{ routeEntityUnion: __type(name:\"RouteEntityUnion\"){ possibleTypes { name } } }"}'

Should return NodeArticle, NodePage, etc. instead of just UnsupportedType.

Why this happens:

GraphQL Compose requires explicit opt-in for routing security. Without “Enable loading by route” checked, Compose safely returns UnsupportedType to prevent accidental exposure of content.

GraphQL Compose Configuration

Enable GraphQL Compose Features

The base GraphQL module only provides the engine. GraphQL Compose adds the actual schema.

Enable additional modules:

1
2
3
4
5
6
7
8
# Install if not already installed
ddev composer require drupal/graphql_compose

# Enable core + feature modules
ddev drush en graphql_compose -y

# Clear cache
ddev drush cr

Switch GraphQL Server to Compose Schema

  1. Configuration → Web services → GraphQL → Servers
  2. Edit your /graphql server
  3. Change Schema dropdown to: GraphQL Compose
  4. Save and clear cache

Verify Schema Has Content

Run introspection query:

1
2
3
4
5
6
7
8
9
{
  __schema {
    queryType {
      fields {
        name
      }
    }
  }
}

Before Compose: You’ll only see article or nothing After Compose: You should see node, route, info, etc.

Fetching Content from Drupal

Schema Introspection

Discover what your schema exposes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const res = await fetch(process.env.DRUPAL_GRAPHQL_URL!, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    query: `{
      __schema {
        queryType {
          fields {
            name
            args {
              name
              type { kind name }
            }
          }
        }
      }
    }`
  }),
  cache: "no-store",
});

Fetch Article by ID

If schema has article(id: Int!):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const res = await fetch(process.env.DRUPAL_GRAPHQL_URL!, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    query: `
      query ($id: Int!) {
        article(id: $id) {
          id
          title
        }
      }
    `,
    variables: { id: 1 }
  }),
  cache: "no-store",
});

const json = await res.json();

Fetch Node by UUID

If schema has node(uuid: String!):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const res = await fetch(process.env.DRUPAL_GRAPHQL_URL!, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    query: `
      query ($uuid: String!) {
        node(uuid: $uuid) {
          __typename
          ... on NodeArticle {
            title
            body {
              processed
            }
          }
        }
      }
    `,
    variables: { uuid: "YOUR-UUID-HERE" }
  }),
  cache: "no-store",
});

Get UUID with Drush:

1
ddev drush php:eval '$n=\Drupal\node\Entity\Node::load(1); echo $n->uuid().PHP_EOL;'

Creating a GraphQL Proxy Route

Create app/api/graphql/route.ts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { NextResponse } from "next/server";

export async function POST(req: Request) {
  const body = await req.json();

  const upstream = await fetch(process.env.DRUPAL_GRAPHQL_URL!, {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify(body),
    cache: "no-store",
  });

  const text = await upstream.text();

  return new NextResponse(text, {
    status: upstream.status,
    headers: {
      "Content-Type": "application/json"
    },
  });
}

Benefits:

  • Server-to-server calls (no CORS issues)
  • Centralizes Drupal API calls
  • Easy to add authentication later
  • Keeps sensitive URLs/tokens server-side

GraphQL Helper Function

Create lib/drupal.ts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
type GraphQLResponse<T> = {
  data?: T;
  errors?: any;
};

export async function gql<T>(
  query: string,
  variables?: Record<string, any>
): Promise<T> {
  const res = await fetch(process.env.DRUPAL_GRAPHQL_URL!, {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({ query, variables }),
    cache: "no-store",
  });

  const json = await res.json() as GraphQLResponse<T>;

  if (!res.ok || json.errors) {
    throw new Error(JSON.stringify(json.errors ?? json, null, 2));
  }

  return json.data as T;
}

Usage:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { gql } from "@/lib/drupal";

type ArticleData = {
  article: {
    id: number;
    title: string;
  };
};

const data = await gql<ArticleData>(
  `query ($id: Int!) {
    article(id: $id) {
      id
      title
    }
  }`,
  { id: 1 }
);

Dynamic Routing by Path

Enable Route-by-Path in Drupal

Enable additional GraphQL Compose modules:

1
2
ddev drush en graphql_compose_routes -y
ddev drush cr

Verify route field exists:

1
2
3
4
5
6
7
8
9
{
  __schema {
    queryType {
      fields {
        name
      }
    }
  }
}

Should include route in the list.

CRITICAL: Enable “Enable loading by route” for Content Types

After enabling graphql_compose_routes, you must enable routing for each content type:

  1. Go to Structure → Content types → Article → Edit
  2. Scroll to GraphQL Compose section
  3. Check these boxes:
    • Enable GraphQL
    • Enable single query
    • Enable edge query
    • Enable loading by routeTHIS IS CRITICAL
  4. Save
  5. Repeat for Basic page and any other content types
  6. Clear cache: ddev drush cr

Why this matters:

Without “Enable loading by route” checked, the route() query will return UnsupportedType instead of your content. GraphQL Compose requires each content type to explicitly opt-in to routing for security.

Verify Content Types Are Available

Test that your content types are now available in the route union:

1
2
3
curl -s -X POST "http://drupaltest.ddev.site/graphql" \
  -H "Content-Type: application/json" \
  --data '{"query":"{ routeEntityUnion: __type(name:\"RouteEntityUnion\"){ possibleTypes { name } } }"}'

Expected response:

1
2
3
4
5
6
7
8
9
10
{
  "data": {
    "routeEntityUnion": {
      "possibleTypes": [
        { "name": "NodeArticle" },
        { "name": "NodePage" }
      ]
    }
  }
}

If you only see UnsupportedType, go back and enable “Enable loading by route” for your content types.

Create Catch-All Route (Complete Working Version)

Create app/[...slug]/page.tsx:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
import { notFound, redirect } from "next/navigation";

export default async function Page({
  params
}: {
  params: { slug?: string[] }
}) {
  // Await params in Next.js 15+
  const { slug } = await params;
  const path = "/" + (slug?.join("/") ?? "");

  const res = await fetch(process.env.DRUPAL_GRAPHQL_URL!, {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      query: `
        query ($path: String!) {
          route(path: $path) {
            __typename
            ... on RouteInternal {
              entity {
                __typename
                ... on NodeArticle {
                  title
                  body { processed }
                }
                ... on NodePage {
                  title
                  body { processed }
                }
              }
            }
            ... on RouteExternal {
              url
            }
          }
        }
      `,
      variables: { path }
    }),
    cache: "no-store",
  });

  const json = await res.json();

  // Handle GraphQL errors
  if (json.errors?.length) {
    return (
      <main style={{ padding: 24 }}>
        <h1>GraphQL error</h1>
        <pre>{JSON.stringify(json, null, 2)}</pre>
      </main>
    );
  }

  const route = json.data?.route;

  // Handle 404
  if (!route) notFound();

  // Handle external redirects
  if (route.__typename === "RouteExternal") {
    redirect(route.url);
  }

  const entity = route.entity;

  // Handle missing entity
  if (!entity) notFound();

  const bodyHtml = entity.body?.processed ?? "";

  return (
    <main style={{ padding: 24, maxWidth: 860 }}>
      <h1>{entity.title}</h1>
      {bodyHtml ? (
        <div dangerouslySetInnerHTML={{ __html: bodyHtml }} />
      ) : (
        <p>No body content.</p>
      )}
    </main>
  );
}

Key differences from basic version:

  • Uses RouteInternal and RouteExternal (correct union types)
  • Handles both NodeArticle and NodePage
  • Properly handles external URL redirects
  • Uses Next.js notFound() and redirect() helpers
  • Includes GraphQL error handling

Optional: Make Catch-All Optional

To allow the homepage to use app/page.tsx instead of the catch-all:

Rename app/[...slug]/ to app/[[...slug]]/ (double brackets):

1
app/[[...slug]]/page.tsx

This makes the slug parameter optional, so:

  • / → uses app/page.tsx
  • /anything-else → uses app/[[...slug]]/page.tsx

Test Your Routes

  1. Visit an article by path: http://localhost:3000/my-article
  2. Visit by node ID: http://localhost:3000/node/2
  3. Test 404: http://localhost:3000/non-existent-page

All should work correctly once “Enable loading by route” is checked.

Next.js 15+ Params Note

In Next.js 15 and later, params must be awaited:

1
2
3
4
5
6
// Next.js 15+
export default async function Page({ params }: { params: { slug?: string[] } }) {
  const { slug } = await params;  // ← Must await
  const path = "/" + (slug?.join("/") ?? "");
  // ...
}

Earlier versions can access params directly:

1
2
3
4
5
// Next.js 13-14
export default async function Page({ params }: { params: { slug?: string[] } }) {
  const path = "/" + (params.slug?.join("/") ?? "");  // No await needed
  // ...
}

If you get a TypeScript error about params, you’re likely on Next.js 15+.

Variable Handling

Correct Structure

1
2
3
4
5
6
7
8
9
10
11
// ✅ Correct
body: JSON.stringify({
  query: `
    query ($id: Int!) {
      article(id: $id) {
        title
      }
    }
  `,
  variables: { id: 1 }
})

Common Mistakes

1
2
3
4
5
6
7
8
9
10
11
// ❌ Wrong - variables inside query string
body: JSON.stringify({
  query: `
    query ($id: Int!) {
      article(id: $id) {
        title
      }
    },
    "variables": { "id": 1 }
  `
})

Error Messages

Missing Variable:

1
2
3
4
5
{
  "errors": [{
    "message": "Variable \"$id\" of required type \"Int!\" was not provided."
  }]
}

Fix: Add variables object to the request body.

Deployment Considerations

Local Drupal + Vercel Next.js

Problem: Vercel can’t reach localhost on your machine.

Solutions:

  1. Use ngrok/Cloudflare Tunnel to expose local Drupal
  2. Deploy Drupal to a staging server
  3. Develop locally first, then deploy both

Environment Variables for Vercel

In Vercel project settings, add:

1
DRUPAL_GRAPHQL_URL=https://your-drupal-site.com/graphql

CORS Configuration

If your Next.js app makes client-side GraphQL calls, configure CORS in Drupal:

  1. Install cors module or configure in services.yml
  2. Allow origins:
    • http://localhost:3000 (local)
    • https://your-vercel-app.vercel.app (production)
  3. Allow headers: Content-Type, Authorization

Caching Strategy

Drupal Side

  • Enable internal render/entity caching
  • Use Drupal’s GraphQL cache metadata
  • Add CDN headers

Next.js Side

App Router:

1
2
3
const res = await fetch(url, {
  next: { revalidate: 60 } // ISR: revalidate every 60 seconds
});

Or disable cache:

1
2
3
const res = await fetch(url, {
  cache: 'no-store' // Always fetch fresh data
});

On-Demand Revalidation

Use webhooks from Drupal to trigger Next.js revalidation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const secret = request.nextUrl.searchParams.get('secret');

  if (secret !== process.env.REVALIDATION_SECRET) {
    return NextResponse.json({ message: 'Invalid secret' }, { status: 401 });
  }

  const { path } = await request.json();

  revalidatePath(path);

  return NextResponse.json({ revalidated: true, now: Date.now() });
}

Error Handling

GraphQL Errors

1
2
3
4
5
6
7
8
9
10
11
const json = await res.json();

if (json.errors) {
  console.error('GraphQL Errors:', json.errors);

  json.errors.forEach((error: any) => {
    console.error('Message:', error.message);
    console.error('Path:', error.path);
    console.error('Extensions:', error.extensions);
  });
}

Network Errors

1
2
3
4
5
6
7
8
9
10
11
12
try {
  const res = await fetch(url, options);

  if (!res.ok) {
    throw new Error(`HTTP ${res.status}: ${res.statusText}`);
  }

  return await res.json();
} catch (error) {
  console.error('Fetch error:', error);
  throw error;
}

Common GraphQL Queries

List All Articles

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
query {
  nodeQuery(
    filter: {
      conditions: [
        { field: "type", value: "article" }
        { field: "status", value: "1" }
      ]
    }
    sort: { field: "created", direction: DESC }
    limit: 10
  ) {
    entities {
      ... on NodeArticle {
        nid
        title
        created
      }
    }
  }
}

Fetch with Body and Image

1
2
3
4
5
6
7
8
9
10
11
12
13
14
query ($uuid: String!) {
  node(uuid: $uuid) {
    ... on NodeArticle {
      title
      body {
        processed
      }
      field_image {
        url
        alt
      }
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
query {
  menu(name: "main") {
    items {
      title
      url
      children {
        title
        url
      }
    }
  }
}

Next Steps

Once you have basic GraphQL integration working:

  1. Add authentication for preview mode and private content
  2. Implement ISR/caching strategy
  3. Add image optimization with Next.js Image component
  4. Handle paragraphs/components for complex page layouts
  5. Set up webhooks for on-demand revalidation
  6. Add TypeScript types generated from your GraphQL schema

Resources

Summary

You now have a complete headless Drupal + Next.js setup with GraphQL:

✅ Local Drupal with DDEV ✅ GraphQL Compose configured ✅ Next.js fetching content ✅ Dynamic routing by path ✅ Error handling ✅ Development workflow

This foundation supports building modern, fast, scalable headless Drupal applications with Next.js.

This post is licensed under CC BY 4.0 by the author.