Next.js starter template for Drupal JSON:API with jsonapi_frontend.
Zero to rendering Drupal content in under 30 minutes.
Click the green "Use this template" button above, or:
gh repo create my-site --template code-wheel/jsonapi-frontend-next
cd my-sitenpm installcp .env.example .env.localEdit .env.local:
DRUPAL_BASE_URL=https://your-drupal-site.comnpm run devOpen http://localhost:3000 and navigate to any path that exists in Drupal.
- Next.js 14+ App Router
- TypeScript
- Tailwind CSS
- Automatic path resolution via
jsonapi_frontend - Entity and View rendering
- Full media support (images, video, audio, files, embedded media)
- HTML sanitization (XSS protection)
- SEO-friendly metadata
- Two deployment modes (Split Routing or Next.js First)
- Optional Layout Builder tree rendering (via
jsonapi_frontend_layout)
- Node.js 22+
- A Drupal 10+ site with:
jsonapi_frontendmodule enabled- JSON:API module enabled (core)
jsonapi_viewsmodule (optional, for Views support)
Request: /about-us
↓
Resolver: GET /jsonapi/layout/resolve?path=/about-us&_format=json (falls back to /jsonapi/resolve)
↓
Response: { kind: "entity", jsonapi_url: "/jsonapi/node/page/...", headless: true }
↓
Fetch: GET /jsonapi/node/page/...?include=field_image,field_media...
↓
Render: <NodePage entity={...} included={...} />
If you use Drupal Layout Builder and want true headless rendering, install the add-on module:
This starter will then use:
GET /jsonapi/layout/resolve?path=/about-us&_format=json
When the resolved entity is rendered with Layout Builder, the response includes a layout tree. The starter renders a minimal layout tree (field blocks + inline blocks) and falls back to the normal entity renderer for everything else.
├── app/
│ ├── [...slug]/page.tsx # Catch-all route for all Drupal paths
│ ├── layout.tsx # Root layout
│ ├── page.tsx # Homepage
│ └── not-found.tsx # 404 page
├── components/
│ ├── entity/ # Entity type components
│ │ ├── EntityRenderer.tsx
│ │ ├── NodePage.tsx
│ │ └── NodeArticle.tsx
│ ├── media/ # Media components
│ │ ├── DrupalImage.tsx
│ │ ├── DrupalVideo.tsx
│ │ ├── DrupalAudio.tsx
│ │ ├── DrupalFile.tsx
│ │ ├── DrupalMedia.tsx
│ │ └── BodyContent.tsx
│ └── view/
│ └── ViewRenderer.tsx
├── lib/drupal/ # Drupal integration utilities
│ ├── resolve.ts # Path resolver
│ ├── fetch.ts # JSON:API fetching
│ ├── types.ts # TypeScript types
│ ├── url.ts # URL utilities
│ └── media.ts # Media extraction
└── proxy.ts # Proxy (Next.js First mode)
| Variable | Required | Description |
|---|---|---|
DRUPAL_BASE_URL |
Yes | Your Drupal site URL |
DEPLOYMENT_MODE |
No | split_routing (default) or nextjs_first |
DRUPAL_ORIGIN_URL |
Next.js First | Drupal origin for proxying |
DRUPAL_PROXY_SECRET |
Next.js First | Shared secret from Drupal admin |
REVALIDATION_SECRET |
Production | Secret for cache revalidation webhooks |
DRUPAL_IMAGE_DOMAIN |
Recommended | Restrict image sources (defaults to DRUPAL_BASE_URL host) |
DRUPAL_JWT_TOKEN |
Optional | Server-side JWT token for Drupal auth |
DRUPAL_BASIC_USERNAME |
Optional | Server-side Basic auth username |
DRUPAL_BASIC_PASSWORD |
Optional | Server-side Basic auth password |
See .env.example for detailed documentation.
If your Drupal JSON:API requires auth, set one of these in .env.local (server-side only):
DRUPAL_BASIC_USERNAME+DRUPAL_BASIC_PASSWORDDRUPAL_JWT_TOKEN
When auth is configured, this starter disables Next.js fetch caching to avoid leaking access-controlled content across users.
- In production, do not allow wildcard image domains. Set
DRUPAL_IMAGE_DOMAIN(or rely onDRUPAL_BASE_URLif images come from the same host). - Non-headless redirects are validated to only go to your configured Drupal origin (avoids open-redirect footguns).
Drupal stays on your main domain. Configure your CDN/router to send specific paths to Next.js.
DEPLOYMENT_MODE=split_routing
DRUPAL_BASE_URL=https://www.example.comNext.js handles all traffic. Non-headless content is proxied to Drupal.
DEPLOYMENT_MODE=nextjs_first
DRUPAL_BASE_URL=https://cms.example.com
DRUPAL_ORIGIN_URL=https://cms.example.com
DRUPAL_PROXY_SECRET=your-secret-from-drupal-adminSee the Migration Guide for complete setup instructions.
| Type | Component | Description |
|---|---|---|
| Image | DrupalImage |
Next.js Image with optimization |
| Video | DrupalVideo |
Local video files |
| Remote Video | DrupalVideo |
YouTube, Vimeo embeds |
| Audio | DrupalAudio |
Audio files with controls |
| File | DrupalFile |
Downloadable files with icons |
import { extractPrimaryImage } from "@/lib/drupal"
import { DrupalImage, BodyContent } from "@/components/media"
function MyComponent({ entity, included }) {
const heroImage = extractPrimaryImage(entity, included)
const body = entity.attributes?.body?.processed
return (
<div>
{heroImage && <DrupalImage image={heroImage} preset="hero" priority />}
{body && <BodyContent html={body} included={included} />}
</div>
)
}| Preset | Size | Use Case |
|---|---|---|
thumbnail |
150x150 | Thumbnails, avatars |
medium |
500px wide | In-content images |
large |
1000px wide | Featured images |
hero |
1920x400 | Hero banners |
full |
Original | When you need full size |
- Create a component in
components/entity/:
// components/entity/NodeEvent.tsx
import { JsonApiResource, extractPrimaryImage } from "@/lib/drupal"
import { DrupalImage, BodyContent } from "@/components/media"
interface Props {
entity: JsonApiResource
included?: JsonApiResource[]
}
export function NodeEvent({ entity, included }: Props) {
const title = entity.attributes?.title as string
const body = entity.attributes?.body as { processed: string } | undefined
const image = extractPrimaryImage(entity, included)
return (
<main className="max-w-3xl mx-auto px-4 py-8">
{image && <DrupalImage image={image} preset="hero" priority />}
<h1>{title}</h1>
{body && <BodyContent html={body.processed} included={included} />}
</main>
)
}- Register in
components/entity/EntityRenderer.tsx:
case "node--event":
return <NodeEvent entity={entity} included={doc.included} />- Add includes in
app/[...slug]/page.tsxif needed:
const DEFAULT_INCLUDES = [
// ... existing
"field_event_image",
"field_event_image.field_media_image",
]npm install -g vercel
vercelSet environment variables in Vercel project settings.
npm run build
npm startThis starter includes automatic cache invalidation via webhooks from Drupal.
- Editor saves content in Drupal
- Drupal sends POST to
/api/revalidatewith cache tags - Next.js invalidates matching cached pages
- Next request fetches fresh content
-
In Drupal admin (
/admin/config/services/jsonapi-frontend):- Enable "Cache revalidation webhooks"
- Set URL to
https://your-nextjs-site.com/api/revalidate - Copy the generated secret
-
In Next.js
.env.local:REVALIDATION_SECRET=your-secret-from-drupal-admin
# Health check
curl https://your-nextjs-site.com/api/revalidate
# Manual revalidation (for testing)
curl -X POST https://your-nextjs-site.com/api/revalidate \
-H "Content-Type: application/json" \
-H "X-Revalidation-Secret: your-secret" \
-d '{"operation":"update","paths":["/about-us"],"tags":["drupal"]}'- Check
DEFAULT_INCLUDESinapp/[...slug]/page.tsx - Set
DRUPAL_IMAGE_DOMAINenvironment variable - Verify files are accessible to anonymous users
- Check
DRUPAL_PROXY_SECRETmatches Drupal admin - Verify CORS is configured in Drupal
- Check the content type is enabled in Drupal admin at
/admin/config/services/jsonapi-frontend - Verify the path alias exists
- Check entity access (unpublished content returns not found)
Add to Drupal settings.php:
$settings['cors'] = [
'enabled' => TRUE,
'allowedOrigins' => ['https://your-nextjs-site.com'],
'allowedMethods' => ['GET'],
'allowedHeaders' => ['Content-Type', 'Accept', 'Authorization'],
];MIT
- jsonapi_frontend - The Drupal module
- JSON:API - Drupal core module
- jsonapi_views - Views as JSON:API endpoints