Post

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:


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 TypeFieldsUsed on
herofield_heading (text), field_subheading (text), field_cta_text (text), field_cta_url (link)Home
text_blockfield_heading (text), field_body (long text, formatted)All pages
services_listfield_heading (text), field_services (entity reference → service_item paragraph)Services
service_itemfield_title (text), field_description (long text), field_icon (text)Services
team_memberfield_name (text), field_role (text), field_bio (long text), field_photo (image)About
contact_infofield_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_sections only (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:

  1. Home — path: /home (or set as front page)
  2. About — path: /about
  3. Services — path: /services
  4. Contact — path: /contact
  5. 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

  1. Go to Configuration → Web services → GraphQL → Servers → Add server
  2. Name: Headless API
  3. Schema: GraphQL Compose
  4. Endpoint: /graphql
  5. Save

3.3 Enable GraphQL on your content types

For Basic page:

  1. Structure → Content types → Basic page → Edit
  2. Scroll to GraphQL Compose section
  3. Enable all four checkboxes:
    • ✅ Enable GraphQL
    • ✅ Enable single query
    • ✅ Enable edge query
    • Enable loading by route ← required for path-based routing
  4. Save → ddev drush cr

3.4 Enable GraphQL on Paragraph types

For each paragraph type (hero, text_block, services_list, etc.):

  1. Structure → Paragraph types → [type] → Edit
  2. Enable GraphQL Compose checkbox
  3. Save

3.5 Expose fields via GraphQL

For each field on each content type and paragraph type:

  1. Go to Structure → Content types → Basic page → Manage display
  2. Check the GraphQL column for each field you want exposed
  3. 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

  1. Go to Structure → Menus → Main navigation
  2. 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 nameGraphQL Compose name
field_page_sectionsfieldPageSections
field_cta_urlfieldCtaUrl
field_bodyfieldBody (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
  1. Add content in Drupal at https://drupaltest.ddev.site
  2. Test GraphQL queries at https://drupaltest.ddev.site/graphql/explorer
  3. View frontend at http://localhost:3000

When you add a new paragraph type in Drupal, the workflow is:

  1. Create paragraph type + fields in Drupal UI
  2. Enable GraphQL Compose on the paragraph type
  3. Check fields in Manage display → GraphQL column
  4. Add a ... on ParagraphNewType { } fragment to GET_PAGE_BY_PATH in lib/queries.ts
  5. Add the interface to lib/types.ts
  6. Create the React component in components/paragraphs/
  7. 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.ts is the single fetch helper all pages use
  • [[...slug]]/page.tsx handles every Drupal-managed page via route(path:)
  • ParagraphRenderer switches on __typename to 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)
This post is licensed under CC BY 4.0 by the author.