Skip to content

TutorialFor front-end developersDrupal 11

Next.js on Drupal quickstart

This tutorial builds a small Next.js application that reads content from a Drupal site over JSON:API. You write a fetch client, a page that lists articles, and a page that shows one article with its image, fetching from the server so no CORS configuration is needed.

This is the API-only path: editors manage content in Drupal, and your app decides how every page looks. For a site where editors build pages from your components and preview drafts in your app, follow the Canvas Headless quickstart instead.

Next.js is the worked example because it has the most mature Drupal integration ecosystem, including the contrib Next.js for Drupal packages, which this tutorial deliberately skips so the raw fetch pattern stays visible. Nothing here depends on the framework in substance: the same JSON:API client transfers to SvelteKit, Nuxt, Astro, or a native app, a field Choose your front end surveys.

A Next.js App Router project with three files of your own: a lib/drupal.js client that talks to JSON:API, an /articles page that lists the ten most recent published articles, and an /articles/[id] page that renders one article and its hero image. Every page renders on the server, so the browser never calls Drupal directly and you skip CORS for now.

The Drupal side is core only: the JSON:API module, read-only, serving the Article content model from JSON:API basics. You write no Drupal code.

  • A Drupal 11 site installed with DDEV per Install Drupal core, reachable at its https://*.ddev.site URL.
  • The Article content model from JSON:API basics, with a few published articles. One should have an image in its field_image field.
  • The JSON:API module installed: ddev drush pm:install jsonapi. It is read-only by default, which is all this tutorial needs.
  • Familiarity with the read flow from JSON:API basics.
  • Node.js 20.9 or later and npm. The current Next.js release requires it.
  • mkcert, which DDEV already uses to sign its local HTTPS certificates.

Scaffold a project that uses plain JavaScript, the App Router, and no src/ directory. The flags answer every prompt, so the files match the paths in this tutorial:

Terminal window
npx create-next-app@latest drupal-frontend --js --app --no-src-dir --eslint --no-tailwind --yes
cd drupal-frontend

The client reads Drupal’s base URL from an environment variable so the URL never hardcodes into the code. Create .env.local at the project root with your site’s origin:

Terminal window
DRUPAL_BASE_URL=https://my-site.ddev.site

Use your real site URL, without a trailing slash. The name has no NEXT_PUBLIC_ prefix on purpose: every fetch in this app runs on the server, so the variable stays out of the browser bundle, and so will any API token you add next to it later. Restart npm run dev after changing .env.local, because Next.js reads environment files only at startup.

Node.js does not use your operating system’s certificate store, so it rejects the certificate DDEV serves until you point it at mkcert’s root certificate. Set the variable in the terminal that runs the dev server:

Terminal window
export NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem"

Node.js reads NODE_EXTRA_CA_CERTS only when the process starts, so it belongs in your shell (or shell profile), not in .env.local.

Create lib/drupal.js. One helper sends every request with the JSON:API Accept header and throws on any non-2xx response, so a failing backend surfaces as an error page rather than blank content. Two exported functions wrap the queries the pages need:

lib/drupal.js
const DRUPAL_BASE_URL = process.env.DRUPAL_BASE_URL;
// One JSON:API request, decoded. Throws on any non-2xx response so a
// failing backend surfaces as an error page instead of empty content.
async function drupalFetch(path) {
const url = new URL(path, DRUPAL_BASE_URL);
const response = await fetch(url, {
headers: { Accept: 'application/vnd.api+json' },
// Cache rendered pages for 60 seconds, then refetch.
next: { revalidate: 60 },
});
if (!response.ok) {
throw new Error(`JSON:API request failed: ${response.status} ${url}`);
}
return response.json();
}
// The ten most recent published articles, newest first. Sparse fieldsets
// keep the payload to the two attributes the listing page renders.
export async function getArticles() {
const document = await drupalFetch(
'/jsonapi/node/article' +
'?filter[status]=1' +
'&sort=-created' +
'&page[limit]=10' +
'&fields[node--article]=title,created',
);
return document.data;
}
// One article by its UUID, with the hero image file included so the page
// can render it without a second round trip.
export async function getArticle(id) {
return drupalFetch(`/jsonapi/node/article/${id}?include=field_image`);
}

getArticles applies the techniques from JSON:API basics: filter[status]=1 for published only, sort=-created for newest first, page[limit]=10 to cap the list, and a sparse fieldset so the payload carries only the two attributes the listing renders. getArticle adds include=field_image so a single request returns the article and its image file together.

Create app/articles/page.js. As a server component it can be async and call getArticles directly; the fetch runs on the server, so the browser never contacts Drupal and CORS does not apply:

app/articles/page.js
import Link from 'next/link';
import { getArticles } from '../../lib/drupal';
export default async function ArticlesPage() {
const articles = await getArticles();
return (
<main>
<h1>Articles</h1>
<ul>
{articles.map((article) => (
<li key={article.id}>
<Link href={`/articles/${article.id}`}>
{article.attributes.title}
</Link>
</li>
))}
</ul>
</main>
);
}

Each list item links to the detail route using the article’s UUID, which JSON:API returns as the resource id.

Create app/articles/[id]/page.js. The dynamic [id] segment receives the UUID from the URL. The page reads the compound document: the article is in data, and because of include=field_image the file resource is in the top-level included array. The image’s alt text rides on the relationship’s meta:

app/articles/[id]/page.js
import { getArticle } from '../../../lib/drupal';
export default async function ArticlePage({ params }) {
const { id } = await params;
const document = await getArticle(id);
const article = document.data;
// ?include=field_image put the file resource in the top-level
// `included` array; the relationship's meta carries the alt text.
const image = document.included?.find(
(resource) => resource.type === 'file--file',
);
return (
<main>
<h1>{article.attributes.title}</h1>
{image && (
<img
src={
new URL(
image.attributes.uri.url,
process.env.DRUPAL_BASE_URL,
).href
}
alt={article.relationships.field_image.data?.meta?.alt ?? ''}
width="480"
/>
)}
<div
dangerouslySetInnerHTML={{
__html: article.attributes.body?.processed ?? '',
}}
/>
</main>
);
}

The file resource stores a site-relative uri.url, so the page resolves it against the Drupal base URL to build an absolute src the browser can load.

Every page shows “JSON:API request failed: 404”

Section titled “Every page shows “JSON:API request failed: 404””

The base URL or path is wrong. Confirm DRUPAL_BASE_URL in .env.local has no trailing slash and that curl https://my-site.ddev.site/jsonapi/node/article, with your real site URL, returns data. A 404 on that URL means the JSON:API module or the Article content model is missing. Restart npm run dev after editing .env.local.

Pages fail with “fetch failed” or “unable to verify the first certificate”

Section titled “Pages fail with “fetch failed” or “unable to verify the first certificate””

Node.js does not trust DDEV’s local certificate. Stop the dev server, run export NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem" in the same terminal, and start npm run dev again.

The list is empty but the request succeeds

Section titled “The list is empty but the request succeeds”

The filter[status]=1 clause returns only published articles, and anonymous requests see only what the anonymous role may view. Publish an article, or confirm anonymous users have access content on the Drupal site.

Images do not load, with a CORS or mixed-content error in the console

Section titled “Images do not load, with a CORS or mixed-content error in the console”

The <img> element loads from the browser, so its src must be an absolute HTTPS URL the browser can reach. Confirm DRUPAL_BASE_URL uses https:// and that the file URL the page builds points at your Drupal site, not at localhost:3000.

Next.js reads environment files once at startup. Stop and restart npm run dev.

Was this page helpful?

Drupal is a registered trademark of Dries Buytaert.