Build a 5-Page Headless Drupal 11 + Next.js Site with Paragraphs and GraphQL
A practical step-by-step guide to building a 5-page brochure website using Drupal 11 as a headless CMS with Paragraphs for flexible layouts, GraphQL Compose as the API layer, and Next.js as the frontend. Every step is explained from first principles — including the JavaScript and GraphQL parts.
Pages we are building: Home, About, Services, Contact, Privacy Policy
Related reading:
- Headless Drupal with Next.js and GraphQL Integration Guide — covers local setup, DDEV, TLS troubleshooting, and dynamic routing fundamentals
- Drupal Paragraphs Cheatsheet — paragraphs module setup reference
1. Architecture Overview
Before touching any code, understand what each layer does:
1
2
3
4
5
6
7
8
Drupal 11 (DDEV local)
└── Paragraphs (flexible page content blocks)
└── GraphQL Compose (exposes content as a GraphQL API)
└── /graphql endpoint
└── Next.js (App Router)
└── Data fetching on the server
└── React components for rendering
└── File-based routing
Drupal’s job: content storage, editorial UI, structured data GraphQL’s job: typed API — Next.js asks for exactly what it needs Next.js’s job: routing, fetching, rendering HTML to the browser
2. Drupal Content Modeling
2.1 Plan your Paragraph types
For a brochure site, we need reusable content blocks. Create these paragraph types in Drupal:
| Paragraph Type | Fields | Used on |
|---|---|---|
hero | field_heading (text), field_subheading (text), field_cta_text (text), field_cta_url (link) | Home |
text_block | field_heading (text), field_body (long text, formatted) | All pages |
services_list | field_heading (text), field_services (entity reference → service_item paragraph) | Services |
service_item | field_title (text), field_description (long text), field_icon (text) | Services |
team_member | field_name (text), field_role (text), field_bio (long text), field_photo (image) | About |
contact_info | field_email (email), field_phone (text), field_address (long text) | Contact |
2.2 Create Paragraph types in Drupal
Go to Structure → Paragraph types → Add paragraph type for each one above.
After creating all paragraph types, create one more called page_sections — this is the top-level container:
page_sections paragraph:
field_sections— Entity reference revisions → Paragraph → Allow:hero,text_block,services_list,team_member,contact_info- Set to Unlimited items
2.3 Create the Basic Page content type field
Go to Structure → Content types → Basic page → Manage fields → Add field:
- Field type: Entity reference revisions
- Label:
Page Sections - Machine name:
field_page_sections - Reference type: Paragraph
- Allow:
page_sectionsonly (the container — not individual paragraphs directly) - Cardinality: Unlimited
2.4 Create your 5 pages in Drupal
Go to Content → Add content → Basic page and create:
- Home — path:
/home(or set as front page) - About — path:
/about - Services — path:
/services - Contact — path:
/contact - Privacy Policy — path:
/privacy-policy
Add paragraph blocks to each page via the Page Sections field.
3. GraphQL Compose Setup
3.1 Install modules
1
2
3
ddev composer require drupal/graphql drupal/graphql_compose
ddev drush en graphql graphql_compose graphql_compose_routes -y
ddev drush cr
3.2 Configure the GraphQL server
- Go to Configuration → Web services → GraphQL → Servers → Add server
- Name:
Headless API - Schema: GraphQL Compose
- Endpoint:
/graphql - Save
3.3 Enable GraphQL on your content types
For Basic page:
- Structure → Content types → Basic page → Edit
- Scroll to GraphQL Compose section
- Enable all four checkboxes:
- ✅ Enable GraphQL
- ✅ Enable single query
- ✅ Enable edge query
- ✅ Enable loading by route ← required for path-based routing
- Save →
ddev drush cr
3.4 Enable GraphQL on Paragraph types
For each paragraph type (hero, text_block, services_list, etc.):
- Structure → Paragraph types → [type] → Edit
- Enable GraphQL Compose checkbox
- Save
3.5 Expose fields via GraphQL
For each field on each content type and paragraph type:
- Go to Structure → Content types → Basic page → Manage display
- Check the GraphQL column for each field you want exposed
- Repeat for each paragraph type: Structure → Paragraph types → [type] → Manage display
3.6 Set permissions
People → Permissions:
- ✅ Execute GraphQL requests → Anonymous user (dev only — lock down in production)
3.7 Verify the schema
1
2
3
curl -s -X POST "http://drupaltest.ddev.site/graphql" \
-H "Content-Type: application/json" \
--data '{"query":"{ __schema { queryType { fields { name } } } }"}'
You should see route, node, info etc. in the list.
4. Understanding GraphQL for JavaScript Beginners
If GraphQL is new to you, read the standalone guide first: GraphQL for JavaScript Beginners — Practical Examples
It covers: making a fetch request, asking for fields, passing arguments, variables, union types, inline fragments, and how to read the response. All with plain examples before anything Drupal-specific.
Come back here once you’re comfortable with the basics.
5. Querying Paragraphs via GraphQL
Paragraphs are nested entities. A NodePage has a fieldPageSections field containing paragraph entities. Each paragraph can be a different type.
5.1 Discover your schema first
Run this in GraphiQL or curl to see what fields exist on NodePage:
1
2
3
4
5
6
7
8
9
10
11
{
__type(name: "NodePage") {
fields {
name
type {
name
kind
}
}
}
}
Check what types are in your paragraphs union:
1
2
3
4
5
6
7
{
__type(name: "ParagraphUnion") {
possibleTypes {
name
}
}
}
5.2 The full page query with paragraphs
This is the core query we’ll use for every page:
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
query GetPage($path: String!) {
route(path: $path) {
__typename
... on RouteInternal {
entity {
__typename
... on NodePage {
title
fieldPageSections {
... on ParagraphPageSections {
fieldSections {
__typename
... on ParagraphHero {
fieldHeading
fieldSubheading
fieldCtaText
fieldCtaUrl {
url
}
}
... on ParagraphTextBlock {
fieldHeading
fieldBody {
processed
}
}
... on ParagraphServicesList {
fieldHeading
fieldServices {
... on ParagraphServiceItem {
fieldTitle
fieldDescription {
processed
}
fieldIcon
}
}
}
... on ParagraphTeamMember {
fieldName
fieldRole
fieldBio {
processed
}
fieldPhoto {
url
alt
}
}
... on ParagraphContactInfo {
fieldEmail
fieldPhone
fieldAddress {
processed
}
}
}
}
}
}
}
}
}
}
Note on field names: GraphQL Compose converts Drupal machine names to camelCase. field_page_sections becomes fieldPageSections. If a field name doesn’t work, check the schema with __type.
6. Next.js Project Setup
6.1 Create the project
1
2
npx create-next-app@latest my-site --typescript --app --tailwind
cd my-site
Select: TypeScript ✅, App Router ✅, Tailwind ✅
6.2 Environment variables
Create .env.local:
1
DRUPAL_GRAPHQL_URL=http://drupaltest.ddev.site/graphql
Use HTTP not HTTPS locally to avoid TLS certificate issues. See the integration guide for TLS solutions if needed.
6.3 File structure we’re building
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
app/
layout.tsx ← Root layout: nav, footer
page.tsx ← Homepage (fetches /home from Drupal)
[[...slug]]/
page.tsx ← Catch-all route for all other pages
contact/
page.tsx ← Contact page (has a form — handled separately)
lib/
drupal.ts ← GraphQL fetch helper
queries.ts ← All our GraphQL queries
components/
Nav.tsx ← Navigation from Drupal menu
paragraphs/
Hero.tsx
TextBlock.tsx
ServicesList.tsx
TeamMember.tsx
ContactInfo.tsx
ParagraphRenderer.tsx ← Switches on paragraph type
7. Data Fetching Layer
7.1 The GraphQL helper (lib/drupal.ts)
This is a reusable function that all our pages will call. It handles the fetch, error checking, and returns typed data.
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
// lib/drupal.ts
// TypeScript: define the shape of a GraphQL response
type GraphQLResponse<T> = {
data?: T;
errors?: { message: string; path?: string[] }[];
};
// A generic function: <T> means "we'll tell you the return type when we call it"
export async function gql<T>(
query: string,
variables?: Record<string, unknown> // Optional: key-value pairs of any type
): Promise<T> {
const res = await fetch(process.env.DRUPAL_GRAPHQL_URL!, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
// next: { revalidate: 60 } ← swap cache: "no-store" for ISR in production
cache: "no-store", // Always fresh during development
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: GraphQL request failed`);
}
const json = (await res.json()) as GraphQLResponse<T>;
if (json.errors?.length) {
// Log details for debugging — errors is an array
console.error("GraphQL errors:", JSON.stringify(json.errors, null, 2));
throw new Error(json.errors[0].message);
}
if (!json.data) {
throw new Error("GraphQL returned no data");
}
return json.data;
}
Why <T>? TypeScript generics let you say “this function returns whatever type you pass in”. When we call gql<PageData>(...), TypeScript knows the result has the shape of PageData.
7.2 GraphQL queries (lib/queries.ts)
Keep all your queries in one file:
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
// lib/queries.ts
export const GET_PAGE_BY_PATH = `
query GetPage($path: String!) {
route(path: $path) {
__typename
... on RouteInternal {
entity {
__typename
... on NodePage {
title
fieldPageSections {
... on ParagraphPageSections {
fieldSections {
__typename
... on ParagraphHero {
fieldHeading
fieldSubheading
fieldCtaText
fieldCtaUrl { url }
}
... on ParagraphTextBlock {
fieldHeading
fieldBody { processed }
}
... on ParagraphServicesList {
fieldHeading
fieldServices {
... on ParagraphServiceItem {
fieldTitle
fieldDescription { processed }
fieldIcon
}
}
}
... on ParagraphTeamMember {
fieldName
fieldRole
fieldBio { processed }
fieldPhoto { url alt }
}
... on ParagraphContactInfo {
fieldEmail
fieldPhone
fieldAddress { processed }
}
}
}
}
}
}
}
... on RouteExternal {
url
}
}
}
`;
export const GET_MENU = `
query {
menu(name: "main") {
items {
title
url
children {
title
url
}
}
}
}
`;
7.3 TypeScript types (lib/types.ts)
Define the shape of your data. This makes IDE autocomplete work and catches bugs at compile time.
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
// lib/types.ts
// Each paragraph type maps to a TypeScript interface
export interface ParagraphHero {
__typename: "ParagraphHero";
fieldHeading: string;
fieldSubheading?: string;
fieldCtaText?: string;
fieldCtaUrl?: { url: string };
}
export interface ParagraphTextBlock {
__typename: "ParagraphTextBlock";
fieldHeading?: string;
fieldBody?: { processed: string };
}
export interface ParagraphServiceItem {
__typename: "ParagraphServiceItem";
fieldTitle: string;
fieldDescription?: { processed: string };
fieldIcon?: string;
}
export interface ParagraphServicesList {
__typename: "ParagraphServicesList";
fieldHeading?: string;
fieldServices: ParagraphServiceItem[];
}
export interface ParagraphTeamMember {
__typename: "ParagraphTeamMember";
fieldName: string;
fieldRole?: string;
fieldBio?: { processed: string };
fieldPhoto?: { url: string; alt: string };
}
export interface ParagraphContactInfo {
__typename: "ParagraphContactInfo";
fieldEmail?: string;
fieldPhone?: string;
fieldAddress?: { processed: string };
}
// A union type: a paragraph is ONE of these types
export type AnyParagraph =
| ParagraphHero
| ParagraphTextBlock
| ParagraphServicesList
| ParagraphTeamMember
| ParagraphContactInfo;
export interface NodePage {
__typename: "NodePage";
title: string;
fieldPageSections?: {
fieldSections: AnyParagraph[];
}[];
}
8. Routing Strategy
Understanding the two routing approaches
Option A: Named static routes — Create a file for each known page.
1
2
3
app/about/page.tsx
app/services/page.tsx
app/contact/page.tsx
Each file fetches Drupal content for its own path. Simple, explicit, no surprises.
Option B: Catch-all dynamic route — One file handles every unknown path.
1
app/[[...slug]]/page.tsx
Next.js sends every unmatched URL here. The file reads the path from the URL, asks Drupal “what’s at this path?”, and renders whatever comes back. Best for sites where pages are created by editors in Drupal.
We’ll use a combination: Named routes where we have custom logic (contact form), catch-all for everything else.
8.1 The catch-all route (app/[[...slug]]/page.tsx)
The double bracket [[...slug]] makes the slug optional, so it catches /about, /services, /privacy-policy but not / (homepage gets its own 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
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
// app/[[...slug]]/page.tsx
import { notFound, redirect } from "next/navigation";
import { gql } from "@/lib/drupal";
import { GET_PAGE_BY_PATH } from "@/lib/queries";
import { NodePage } from "@/lib/types";
import ParagraphRenderer from "@/components/paragraphs/ParagraphRenderer";
// TypeScript: describe the shape of the GraphQL response
type RouteData = {
route:
| {
__typename: "RouteInternal";
entity: NodePage | { __typename: string };
}
| {
__typename: "RouteExternal";
url: string;
}
| null;
};
// In Next.js App Router, page components receive params as a prop
// The slug is an array of path segments: /about/team → ["about", "team"]
export default async function DrupalPage({
params,
}: {
params: Promise<{ slug?: string[] }>;
}) {
// In Next.js 15+, params must be awaited
const { slug } = await params;
// Reconstruct the full path from the slug array
// slug is undefined for "/" — but [[...slug]] won't handle "/" anyway
// slug is ["about"] for "/about"
// slug is ["about", "team"] for "/about/team"
const path = "/" + (slug?.join("/") ?? "");
// Fetch from Drupal — runs on the server, never in the browser
const data = await gql<RouteData>(GET_PAGE_BY_PATH, { path });
const route = data.route;
// Drupal returned nothing for this path → show 404
if (!route) notFound();
// Drupal returned an external redirect
if (route.__typename === "RouteExternal") {
redirect(route.url);
}
// We have a RouteInternal — get the entity
const entity = route.entity;
// Safety check: if it's not a NodePage we know how to render, 404
if (entity.__typename !== "NodePage") notFound();
// TypeScript type assertion: we've confirmed it's a NodePage
const page = entity as NodePage;
// Flatten the sections — each fieldPageSections item has a fieldSections array
// We combine them all into one flat array of paragraph blocks
const sections = page.fieldPageSections?.flatMap((s) => s.fieldSections) ?? [];
return (
<main>
<h1 className="text-4xl font-bold p-8">{page.title}</h1>
{sections.map((paragraph, index) => (
// ParagraphRenderer decides which component to use based on __typename
<ParagraphRenderer key={index} paragraph={paragraph} />
))}
</main>
);
}
8.2 Homepage (app/page.tsx)
The homepage is just a named page that fetches /home from Drupal:
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
// app/page.tsx
import { gql } from "@/lib/drupal";
import { GET_PAGE_BY_PATH } from "@/lib/queries";
import { NodePage } from "@/lib/types";
import ParagraphRenderer from "@/components/paragraphs/ParagraphRenderer";
type RouteData = {
route: {
__typename: "RouteInternal";
entity: NodePage;
} | null;
};
export default async function Home() {
const data = await gql<RouteData>(GET_PAGE_BY_PATH, { path: "/home" });
const page = data.route?.entity;
if (!page) return <p>Homepage content not found in Drupal.</p>;
const sections = page.fieldPageSections?.flatMap((s) => s.fieldSections) ?? [];
return (
<main>
{sections.map((paragraph, index) => (
<ParagraphRenderer key={index} paragraph={paragraph} />
))}
</main>
);
}
9. Rendering Paragraphs as React Components
9.1 The ParagraphRenderer switch (components/paragraphs/ParagraphRenderer.tsx)
This component receives any paragraph and renders the right component for it. The __typename field (added by GraphQL automatically) tells us what type it is.
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
// components/paragraphs/ParagraphRenderer.tsx
import { AnyParagraph } from "@/lib/types";
import Hero from "./Hero";
import TextBlock from "./TextBlock";
import ServicesList from "./ServicesList";
import TeamMember from "./TeamMember";
import ContactInfo from "./ContactInfo";
// Props: this component accepts one paragraph of any type
interface Props {
paragraph: AnyParagraph;
}
export default function ParagraphRenderer({ paragraph }: Props) {
// Switch on the paragraph's type name
switch (paragraph.__typename) {
case "ParagraphHero":
return <Hero paragraph={paragraph} />;
case "ParagraphTextBlock":
return <TextBlock paragraph={paragraph} />;
case "ParagraphServicesList":
return <ServicesList paragraph={paragraph} />;
case "ParagraphTeamMember":
return <TeamMember paragraph={paragraph} />;
case "ParagraphContactInfo":
return <ContactInfo paragraph={paragraph} />;
default:
// In development, show what paragraph type we haven't handled yet
return (
<div className="bg-yellow-100 p-4 text-sm">
Unhandled paragraph type: {(paragraph as any).__typename}
</div>
);
}
}
9.2 Hero paragraph (components/paragraphs/Hero.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
// components/paragraphs/Hero.tsx
import { ParagraphHero } from "@/lib/types";
import Link from "next/link";
interface Props {
paragraph: ParagraphHero;
}
export default function Hero({ paragraph }: Props) {
return (
<section className="bg-gray-900 text-white py-24 px-8 text-center">
<h1 className="text-5xl font-bold mb-4">{paragraph.fieldHeading}</h1>
{paragraph.fieldSubheading && (
<p className="text-xl text-gray-300 mb-8 max-w-2xl mx-auto">
{paragraph.fieldSubheading}
</p>
)}
{paragraph.fieldCtaText && paragraph.fieldCtaUrl && (
<Link
href={paragraph.fieldCtaUrl.url}
className="bg-blue-500 text-white px-8 py-3 rounded-lg hover:bg-blue-600"
>
{paragraph.fieldCtaText}
</Link>
)}
</section>
);
}
9.3 Text block paragraph (components/paragraphs/TextBlock.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
// components/paragraphs/TextBlock.tsx
import { ParagraphTextBlock } from "@/lib/types";
interface Props {
paragraph: ParagraphTextBlock;
}
export default function TextBlock({ paragraph }: Props) {
return (
<section className="py-16 px-8 max-w-3xl mx-auto">
{paragraph.fieldHeading && (
<h2 className="text-3xl font-bold mb-6">{paragraph.fieldHeading}</h2>
)}
{paragraph.fieldBody?.processed && (
// processed is Drupal's HTML output — render it as HTML
// dangerouslySetInnerHTML is safe here because Drupal sanitises it
<div
className="prose prose-lg"
dangerouslySetInnerHTML={{ __html: paragraph.fieldBody.processed }}
/>
)}
</section>
);
}
9.4 Services list (components/paragraphs/ServicesList.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
// components/paragraphs/ServicesList.tsx
import { ParagraphServicesList } from "@/lib/types";
interface Props {
paragraph: ParagraphServicesList;
}
export default function ServicesList({ paragraph }: Props) {
return (
<section className="py-16 px-8 bg-gray-50">
<div className="max-w-5xl mx-auto">
{paragraph.fieldHeading && (
<h2 className="text-3xl font-bold mb-10 text-center">
{paragraph.fieldHeading}
</h2>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{paragraph.fieldServices.map((service, index) => (
<div key={index} className="bg-white p-6 rounded-lg shadow">
{service.fieldIcon && (
<div className="text-4xl mb-4">{service.fieldIcon}</div>
)}
<h3 className="text-xl font-semibold mb-3">{service.fieldTitle}</h3>
{service.fieldDescription?.processed && (
<div
className="text-gray-600 prose"
dangerouslySetInnerHTML={{
__html: service.fieldDescription.processed,
}}
/>
)}
</div>
))}
</div>
</div>
</section>
);
}
9.5 Team member (components/paragraphs/TeamMember.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
// components/paragraphs/TeamMember.tsx
import { ParagraphTeamMember } from "@/lib/types";
import Image from "next/image";
interface Props {
paragraph: ParagraphTeamMember;
}
export default function TeamMember({ paragraph }: Props) {
return (
<div className="flex gap-6 py-8 border-b">
{paragraph.fieldPhoto && (
<Image
src={paragraph.fieldPhoto.url}
alt={paragraph.fieldPhoto.alt}
width={120}
height={120}
className="rounded-full object-cover"
/>
)}
<div>
<h3 className="text-xl font-bold">{paragraph.fieldName}</h3>
{paragraph.fieldRole && (
<p className="text-blue-600 mb-2">{paragraph.fieldRole}</p>
)}
{paragraph.fieldBio?.processed && (
<div
className="text-gray-600 prose"
dangerouslySetInnerHTML={{ __html: paragraph.fieldBio.processed }}
/>
)}
</div>
</div>
);
}
9.6 Contact info (components/paragraphs/ContactInfo.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
// components/paragraphs/ContactInfo.tsx
import { ParagraphContactInfo } from "@/lib/types";
interface Props {
paragraph: ParagraphContactInfo;
}
export default function ContactInfo({ paragraph }: Props) {
return (
<div className="space-y-4 py-8">
{paragraph.fieldEmail && (
<p>
<strong>Email:</strong>{" "}
<a href={`mailto:${paragraph.fieldEmail}`} className="text-blue-600">
{paragraph.fieldEmail}
</a>
</p>
)}
{paragraph.fieldPhone && (
<p>
<strong>Phone:</strong>{" "}
<a href={`tel:${paragraph.fieldPhone}`} className="text-blue-600">
{paragraph.fieldPhone}
</a>
</p>
)}
{paragraph.fieldAddress?.processed && (
<div>
<strong>Address:</strong>
<div
className="mt-1 prose"
dangerouslySetInnerHTML={{ __html: paragraph.fieldAddress.processed }}
/>
</div>
)}
</div>
);
}
10. Navigation from Drupal Menu
Pull the nav directly from Drupal so editors control it without touching code.
10.1 Create a Main menu in Drupal
- Go to Structure → Menus → Main navigation
- Add links: Home (
/), About (/about), Services (/services), Contact (/contact)
10.2 Nav component (components/Nav.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
// components/Nav.tsx
import Link from "next/link";
import { gql } from "@/lib/drupal";
import { GET_MENU } from "@/lib/queries";
type MenuData = {
menu: {
items: {
title: string;
url: string;
children: { title: string; url: string }[];
}[];
};
};
// This is an async Server Component — it fetches data directly
export default async function Nav() {
let menuItems: MenuData["menu"]["items"] = [];
try {
const data = await gql<MenuData>(GET_MENU);
menuItems = data.menu?.items ?? [];
} catch {
// If menu fetch fails, render nav without items rather than crashing
console.error("Failed to fetch navigation menu");
}
return (
<nav className="bg-white border-b px-8 py-4 flex gap-8">
<Link href="/" className="font-bold text-lg">
My Site
</Link>
<ul className="flex gap-6 items-center">
{menuItems.map((item) => (
<li key={item.url}>
<Link
href={item.url}
className="text-gray-600 hover:text-gray-900"
>
{item.title}
</Link>
</li>
))}
</ul>
</nav>
);
}
10.3 Root layout (app/layout.tsx)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// app/layout.tsx
import Nav from "@/components/Nav";
import "./globals.css";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{/* Nav is a Server Component — fetches the Drupal menu on each request */}
<Nav />
{children}
<footer className="bg-gray-900 text-white text-center py-8 mt-16">
<p>© {new Date().getFullYear()} My Site</p>
</footer>
</body>
</html>
);
}
11. Contact Page with a Form
The contact page is different — it has a form that submits data. Forms require client-side JavaScript, which doesn’t work in Server Components. We split this into two files.
11.1 Contact page server component (app/contact/page.tsx)
The server component fetches Drupal content (contact info paragraph) and passes it down. The form itself is a separate Client Component.
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
// app/contact/page.tsx
import { gql } from "@/lib/drupal";
import { GET_PAGE_BY_PATH } from "@/lib/queries";
import { NodePage } from "@/lib/types";
import ParagraphRenderer from "@/components/paragraphs/ParagraphRenderer";
import ContactForm from "@/components/ContactForm";
type RouteData = {
route: {
__typename: "RouteInternal";
entity: NodePage;
} | null;
};
export default async function ContactPage() {
const data = await gql<RouteData>(GET_PAGE_BY_PATH, { path: "/contact" });
const page = data.route?.entity;
const sections = page?.fieldPageSections?.flatMap((s) => s.fieldSections) ?? [];
return (
<main className="max-w-4xl mx-auto py-16 px-8">
<h1 className="text-4xl font-bold mb-8">{page?.title ?? "Contact"}</h1>
{/* Render Drupal content blocks (contact info paragraph) */}
{sections.map((paragraph, index) => (
<ParagraphRenderer key={index} paragraph={paragraph} />
))}
{/* Client Component: the actual form */}
<ContactForm />
</main>
);
}
11.2 Contact form client component (components/ContactForm.tsx)
The "use client" directive at the top tells Next.js this component runs in the browser — enabling useState, event handlers, and form submission.
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
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
// components/ContactForm.tsx
"use client"; // ← This component runs in the browser
import { useState } from "react";
// useState lets us track form data and submission state
// useState<string>(""): starts as an empty string, can only be set to a string
export default function ContactForm() {
// Each useState returns [currentValue, functionToUpdateIt]
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
async function handleSubmit(e: React.FormEvent) {
// Prevent the default browser behaviour (page refresh on form submit)
e.preventDefault();
setStatus("sending");
try {
// POST to our own Next.js API route — not Drupal directly
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, message }),
});
if (res.ok) {
setStatus("sent");
// Reset the form fields
setName("");
setEmail("");
setMessage("");
} else {
setStatus("error");
}
} catch {
setStatus("error");
}
}
// Successful send — show confirmation message instead of the form
if (status === "sent") {
return (
<div className="bg-green-50 border border-green-200 p-6 rounded-lg">
<p className="text-green-800 font-semibold">
Thanks! Your message has been sent.
</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-6 mt-8">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Name
</label>
<input
id="name"
type="text"
required
value={name}
// onChange fires on every keystroke — update state with new value
onChange={(e) => setName(e.target.value)}
className="w-full border rounded-lg px-4 py-2"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full border rounded-lg px-4 py-2"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium mb-1">
Message
</label>
<textarea
id="message"
required
rows={5}
value={message}
onChange={(e) => setMessage(e.target.value)}
className="w-full border rounded-lg px-4 py-2"
/>
</div>
{status === "error" && (
<p className="text-red-600 text-sm">
Something went wrong. Please try again.
</p>
)}
<button
type="submit"
disabled={status === "sending"}
className="bg-blue-600 text-white px-8 py-3 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{status === "sending" ? "Sending..." : "Send Message"}
</button>
</form>
);
}
11.3 Contact API route (app/api/contact/route.ts)
A Next.js API route that handles the form POST. This runs on the server and can safely send email or call a third-party service.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// app/api/contact/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
const { name, email, message } = body;
// Basic validation
if (!name || !email || !message) {
return NextResponse.json(
{ error: "Missing required fields" },
{ status: 400 }
);
}
// In production: send email via SendGrid, Mailgun, Resend, etc.
// For now, log to the server console
console.log("Contact form submission:", { name, email, message });
// TODO: Replace with your email service
// await sendEmail({ to: "you@example.com", from: email, subject: `New message from ${name}`, text: message });
return NextResponse.json({ success: true });
}
12. Image Handling
To use Next.js <Image> with Drupal image URLs, add your Drupal domain to next.config.ts:
1
2
3
4
5
6
7
8
9
10
11
12
13
// next.config.ts
const nextConfig = {
images: {
remotePatterns: [
{
protocol: "http",
hostname: "drupaltest.ddev.site",
},
],
},
};
export default nextConfig;
13. Debugging GraphQL Field Names
If a field returns null or the query fails, the most common cause is a wrong field name. Drupal machine names and GraphQL Compose names don’t always match what you expect.
Find the exact field name
1
2
3
4
5
# Check what fields NodePage exposes
curl -s -X POST "http://drupaltest.ddev.site/graphql" \
-H "Content-Type: application/json" \
--data '{"query":"{ __type(name: \"NodePage\") { fields { name } } }"}' \
| python3 -m json.tool
1
2
3
4
5
# Check what types are in your paragraph union
curl -s -X POST "http://drupaltest.ddev.site/graphql" \
-H "Content-Type: application/json" \
--data '{"query":"{ __type(name: \"ParagraphUnion\") { possibleTypes { name } } }"}' \
| python3 -m json.tool
Common naming pitfalls
| Drupal machine name | GraphQL Compose name |
|---|---|
field_page_sections | fieldPageSections |
field_cta_url | fieldCtaUrl |
field_body | fieldBody (with .processed sub-field for formatted text) |
body (base field) | body |
Use GraphiQL in the browser
Drupal ships with GraphiQL (in-browser query editor) at:
1
https://drupaltest.ddev.site/graphql/explorer
Use it to explore your schema with autocomplete before writing Next.js code.
14. Common Errors and Fixes
UnsupportedType returned for paragraphs: GraphQL Compose hasn’t been enabled on the paragraph type. Go to each paragraph type, edit it, and enable the GraphQL Compose checkbox.
null returned for a field: The field isn’t checked in the Manage display → GraphQL column for that content type or paragraph type.
Cannot query field "fieldX" on type "NodePage": The field name is wrong. Run __type(name: "NodePage") { fields { name } } to get exact names.
params TypeScript error in Next.js 15: params must be Promise<{...}> and awaited: const { slug } = await params.
Images not loading: Add the Drupal hostname to remotePatterns in next.config.ts.
Contact form not submitting: The form component is missing "use client" at the top — it won’t have access to useState or event handlers without it.
15. Development Workflow
1
2
3
4
5
6
7
# Terminal 1: Drupal
cd my-drupal-project
ddev start
# Terminal 2: Next.js
cd my-site
npm run dev
- Add content in Drupal at
https://drupaltest.ddev.site - Test GraphQL queries at
https://drupaltest.ddev.site/graphql/explorer - View frontend at
http://localhost:3000
When you add a new paragraph type in Drupal, the workflow is:
- Create paragraph type + fields in Drupal UI
- Enable GraphQL Compose on the paragraph type
- Check fields in Manage display → GraphQL column
- Add a
... on ParagraphNewType { }fragment toGET_PAGE_BY_PATHinlib/queries.ts - Add the interface to
lib/types.ts - Create the React component in
components/paragraphs/ - Add the case to
ParagraphRenderer.tsx
Summary
You now have a complete working pattern for a headless Drupal 11 + Next.js site:
- Drupal models content as nodes with paragraph fields
- GraphQL Compose exposes everything via
/graphql lib/drupal.tsis the single fetch helper all pages use[[...slug]]/page.tsxhandles every Drupal-managed page viaroute(path:)ParagraphRendererswitches on__typenameto render the right component- Server Components handle all data fetching — no loading states, no API keys exposed to the browser
- Client Components (
"use client") are used only where browser interaction is needed (the contact form)