jsonapi_frontend is designed for gradual migration: keep Drupal running, move routes/content types to a frontend over time.
- Drupal stays on the main domain (e.g.
https://www.example.com) - Your router/CDN sends selected paths to the frontend (e.g.
/blog/*) - Everything else stays on Drupal
You need some layer that can do path-based routing (CDN, edge worker, reverse proxy, load balancer).
Cloudflare Worker (simple and flexible)
export default {
async fetch(request) {
const url = new URL(request.url)
const frontend = "https://my-site.vercel.app"
const headlessPrefixes = ["/blog", "/news"]
if (headlessPrefixes.some((p) => url.pathname.startsWith(p))) {
return fetch(new Request(frontend + url.pathname + url.search, request))
}
return fetch(request)
},
}nginx (reverse proxy)
location ^~ /blog/ { proxy_pass https://my-site.vercel.app; }
location ^~ /news/ { proxy_pass https://my-site.vercel.app; }
location / { proxy_pass http://drupal_upstream; }Apache VirtualHost
ProxyPass /blog https://my-site.vercel.app/blog
ProxyPassReverse /blog https://my-site.vercel.app/blog
ProxyPass / http://localhost:8080/
ProxyPassReverse / http://localhost:8080/.htaccess (sometimes possible, sometimes not)
This requires mod_rewrite + mod_proxy and is often disallowed on shared hosting:
RewriteEngine On
RewriteRule ^blog/(.*)$ https://my-site.vercel.app/blog/$1 [P,L]
RewriteRule ^news/(.*)$ https://my-site.vercel.app/news/$1 [P,L]- Frontend is on the main domain (e.g.
https://www.example.com) - Drupal runs on an origin/subdomain (e.g.
https://cms.example.com) - The frontend renders headless content and proxies non-headless requests to Drupal
Install:
composer require drupal/jsonapi_frontend
drush en jsonapi_frontendConfigure at /admin/config/services/jsonapi-frontend:
- Choose Deployment mode
- Set Drupal URL (used for
drupal_urland/or origin proxying) - Select which bundles are headless (or enable all)
- (Optional) enable Views support (requires
jsonapi_views) - (Optional) enable cache revalidation webhooks
You can use any framework. Two easy options:
- TypeScript client (optional):
@codewheel/jsonapi-frontend-client - Next.js starter (optional): https://github.com/code-wheel/jsonapi-frontend-next
- Astro starter (optional): https://github.com/code-wheel/jsonapi-frontend-astro
One-click deploy (Vercel):
- Next.js: https://vercel.com/new/clone?repository-url=https://github.com/code-wheel/jsonapi-frontend-next&env=DRUPAL_BASE_URL
- Astro: https://vercel.com/new/clone?repository-url=https://github.com/code-wheel/jsonapi-frontend-astro&env=DRUPAL_BASE_URL
The core pattern is the same:
- Catch-all route receives a path
- Call
/jsonapi/resolveto getjsonapi_url/data_url/drupal_url/ redirects - If
headless=false, redirect/proxy to Drupal - Otherwise fetch JSON:API and render
Minimal Nuxt setup that keeps secrets server-side (works with optional “Protect /jsonapi/*”):
nuxt.config.ts:
export default defineNuxtConfig({
runtimeConfig: {
drupalBaseUrl: process.env.DRUPAL_BASE_URL,
drupalProxySecret: process.env.DRUPAL_PROXY_SECRET,
},
})server/utils/drupal.ts:
export async function drupalFetch<T>(path: string, opts?: { query?: Record<string, unknown> }) {
const config = useRuntimeConfig()
const url = new URL(path, config.drupalBaseUrl)
const headers: Record<string, string> = { Accept: "application/vnd.api+json" }
if (config.drupalProxySecret) headers["X-Proxy-Secret"] = config.drupalProxySecret
return await $fetch<T>(url.toString(), { query: opts?.query, headers })
}pages/[...slug].vue (sketch):
const route = useRoute()
const path = "/" + (Array.isArray(route.params.slug) ? route.params.slug.join("/") : "")
const resolved = await drupalFetch<any>("/jsonapi/resolve", { query: { path, _format: "json" } })
if (!resolved?.resolved) throw createError({ statusCode: 404 })
if (resolved.redirect) return navigateTo(resolved.redirect.to, { external: true, redirectCode: resolved.redirect.status ?? 302 })
if (!resolved.headless && resolved.drupal_url) return navigateTo(resolved.drupal_url, { external: true, redirectCode: 302 })
// then fetch resolved.jsonapi_url or resolved.data_url and renderRemix loaders run server-side by default, so it’s a good fit for /jsonapi/resolve + optional origin protection.
app/routes/$.tsx (catch-all route):
import { redirect } from "@remix-run/node"
import type { LoaderFunctionArgs } from "@remix-run/node"
export async function loader({ params, request }: LoaderFunctionArgs) {
const path = "/" + (params["*"] ?? "")
const base = process.env.DRUPAL_BASE_URL!
const proxySecret = process.env.DRUPAL_PROXY_SECRET
const url = new URL("/jsonapi/resolve", base)
url.searchParams.set("path", path)
url.searchParams.set("_format", "json")
const headers: Record<string, string> = { Accept: "application/vnd.api+json" }
if (proxySecret) headers["X-Proxy-Secret"] = proxySecret
const resolved = await fetch(url, { headers }).then((r) => r.json())
if (!resolved?.resolved) throw new Response("Not Found", { status: 404 })
if (resolved.redirect) throw redirect(resolved.redirect.to, resolved.redirect.status ?? 302)
if (!resolved.headless && resolved.drupal_url) throw redirect(resolved.drupal_url, 302)
// then fetch resolved.jsonapi_url or resolved.data_url and return data
return { resolved }
}Two approaches:
-
Baseline: use jsonapi_menu_items to expose menu links with Drupal access filtering.
-
Turnkey: install jsonapi_frontend_menu to get a ready-to-render tree + optional active trail + per-link
resolvehints:GET /jsonapi/menu/main?path=/about-us&_format=json
If you want maximum cache reuse, call the menu endpoint without path and compute active trail client-side.
Most teams do this in one of two ways:
- Hybrid (recommended): keep Webform pages non-headless so the frontend redirects/proxies to Drupal for form rendering + submission.
- Add-on module: jsonapi_frontend_webform
- Fully headless (Next.js-specific): use a community Webform renderer (evaluate maintenance):
- Drupal project: next_webform
- NPM package: nextjs-drupal-webform
The headless approach typically relies on webform_rest (REST resources) and requires careful auth/CORS/CSRF handling for submissions.
Layout Builder works best when Drupal renders the page. In a hybrid/headless setup, the simplest and most portable approach is:
- Keep Layout Builder bundles non-headless in
/admin/config/services/jsonapi-frontend. - Let the resolver return
headless=false+drupal_url, and have the frontend redirect/proxy to Drupal for those pages.
Notes:
-
Split routing: route Layout Builder paths to Drupal in your router/CDN (or rely on the frontend redirect when
headless=false). -
Frontend-first (
nextjs_first): your frontend proxy should forward non-headless requests to the Drupal origin. -
For truly headless Layout Builder, install the optional add-on
jsonapi_frontend_layoutand use:GET /jsonapi/layout/resolve?path=/about-us&_format=jsonThe response matches
/jsonapi/resolveand includes alayouttree when applicable.- Next.js starter: uses
resolvePathWithLayout()and renders a minimal Layout Builder tree (field blocks + inline blocks). - Astro: use
@codewheel/jsonapi-frontend-client’sresolvePathWithLayout()and implement your renderer mapping (framework-specific).
- Next.js starter: uses
For the starter templates (Next.js / Astro):
DEPLOYMENT_MODE=split_routing
DRUPAL_BASE_URL=https://www.example.comFor the starter templates (Next.js / Astro):
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-adminIf you enable “Protect /jsonapi/* with Proxy Secret (hide origin JSON:API)” in Drupal, your frontend must include X-Proxy-Secret on all /jsonapi/* requests. The starters automatically include it when DRUPAL_PROXY_SECRET is set.
Astro can run in SSR mode (like this starter) or in its default static mode (SSG). If you want SSG, you still use /jsonapi/resolve for correctness — the missing piece is getting a build-time list of paths.
- SSG works best with
split_routing(static builds can’t proxy Drupal HTML likenextjs_first). - Only pre-render public content; if your JSON:API requires per-user auth, prefer SSR.
- In Drupal admin (
/admin/config/services/jsonapi-frontend), the “Static builds (SSG)” section shows copy/paste route list sources based on your headless bundle/View selections.
Fetch collections for the bundles you want to pre-render and collect path.alias.
Example (pages):
curl "https://cms.example.com/jsonapi/node/page?filter[status]=1&fields[node--page]=path&page[limit]=50"Example getStaticPaths() (for a catch-all route like src/pages/[...slug].astro):
export async function getStaticPaths() {
const baseUrl = import.meta.env.DRUPAL_BASE_URL
const url = new URL("/jsonapi/node/page", baseUrl)
url.searchParams.set("filter[status]", "1")
url.searchParams.set("fields[node--page]", "path")
url.searchParams.set("page[limit]", "50")
const doc = await fetch(url).then((r) => r.json())
const paths = (doc.data ?? [])
.map((node) => node?.attributes?.path?.alias)
.filter((p) => typeof p === "string" && p.startsWith("/"))
return paths.map((p) => ({
params: { slug: p.split("/").filter(Boolean) },
props: { path: p },
}))
}Then render each page by calling /jsonapi/resolve (for the path) and fetching the returned jsonapi_url.
If you have a lot of content, paginate using JSON:API links.next (or page[offset]/page[limit]).
If you prefer one build-time routes feed, enable the “Routes feed endpoint” in the “Static builds (SSG)” section. Your build tooling can then page through a single endpoint:
curl -H "X-Routes-Secret: $ROUTES_FEED_SECRET" "https://cms.example.com/jsonapi/routes?_format=json&page[limit]=50"Follow links.next until it is null. Each item includes path plus either jsonapi_url (entity) or data_url (View).
If you prefer one “routes feed”, create a View that returns the alias/path for everything you want to pre-render (and expose it via jsonapi_views).
Then fetch /jsonapi/views/{view_id}/{display_id} in getStaticPaths() and map each row into route params.
- For best CDN caching, keep
/jsonapi/resolve+ JSON:API public (anonymous) and rely on entity access and published state. - If you require authenticated reads, keep credentials server-side and forward the
Authorizationheader through your router/proxy. Do not edge-cache auth responses. - If you use cookie-based Drupal sessions for writes, you’ll need
X-CSRF-Token(/session/token) plus a strict CORS policy; bearer tokens avoid CSRF.
The resolver is safe, but it’s still an extra lookup. Treat it like part of your public JSON:API surface and rate limit it at the edge:
/jsonapi/resolve*(path enumeration / load)/jsonapi/*(API load)
Cloudflare (high-level)
- Add a Rate Limiting rule or WAF rule for
/jsonapi/resolve*and/jsonapi/*(block or managed challenge after a threshold). - Example expressions:
- Resolver:
http.request.uri.path eq "/jsonapi/resolve" - JSON:API:
starts_with(http.request.uri.path, "/jsonapi/")
- Resolver:
nginx (example)
limit_req_zone $binary_remote_addr zone=jsonapi_resolve:10m rate=30r/m;
limit_req_zone $binary_remote_addr zone=jsonapi_api:10m rate=120r/m;
location = /jsonapi/resolve {
limit_req zone=jsonapi_resolve burst=30 nodelay;
proxy_pass http://drupal_upstream;
}
location ^~ /jsonapi/ {
limit_req zone=jsonapi_api burst=60 nodelay;
proxy_pass http://drupal_upstream;
}If you use authenticated JSON:API requests (Basic/OAuth/JWT), ensure your proxy forwards the Authorization header to Drupal.
nginx
proxy_set_header Authorization $http_authorization;In production, always restrict remote images to your Drupal host:
- Set
DRUPAL_IMAGE_DOMAIN, or - Ensure
DRUPAL_BASE_URLis set at build time so the starter can derive a safe allowlist.
- Set
trusted_host_patternsin Drupalsettings.php(prevents Host-header injection issues). - Set “Drupal URL” in the module settings so generated
drupal_urlvalues are deterministic.
Example settings.php:
$settings['trusted_host_patterns'] = [
'^example\\.com$',
'^www\\.example\\.com$',
'^cms\\.example\\.com$',
];This module avoids storing secrets in config exports (config sync). Secrets are stored in Drupal state by default, and you can optionally override them in settings.php for deterministic deploys:
$settings['jsonapi_frontend']['proxy_secret'] = getenv('DRUPAL_PROXY_SECRET');
$settings['jsonapi_frontend']['routes_secret'] = getenv('ROUTES_FEED_SECRET');
$settings['jsonapi_frontend']['revalidation_secret'] = getenv('REVALIDATION_SECRET');In this mode the Drupal module enforces the X-Proxy-Secret header for most requests:
- Always excluded:
/admin/*,/user/*,/batch*,/system* - Default:
/jsonapi/*is excluded so JSON:API can be accessed directly - Optional: enable “Protect /jsonapi/*” in the module settings to also require
X-Proxy-Secretfor/jsonapi/*(server-side only)
If you proxy Drupal HTML through your frontend, also proxy Drupal assets (commonly /sites/*, /core/*, /modules/*, /themes/*) so pages can load CSS/JS/files.
Create rules in your edge/router so headless paths go to the frontend, and everything else goes to Drupal. Example rule set:
/blog/* → frontend
/news/* → frontend
/* → Drupal
Point the main domain to your frontend, and keep Drupal on an origin/subdomain.
Resolve a path:
curl "https://YOUR-DRUPAL-BASE-URL/jsonapi/resolve?path=/about-us&_format=json"Expected behavior:
resolved: falsefor unknown/unviewable pathskind: "entity"withjsonapi_urlfor entitieskind: "view"withdata_urlwhenjsonapi_viewsis installed and configuredkind: "redirect"withredirect.to+redirect.statuswhen the Redirect module matches the pathheadless: true/falsedepending on your configuration
- To move more content: enable additional bundles (and add routing rules in split-routing mode).
- To keep content on Drupal: leave bundles unchecked (the resolver returns
headless: falseanddrupal_url).
If you use multilingual routes:
- Pass
langcodeto the resolver, or - Set Resolver langcode fallback to
currentin the module settings.