[{"data":1,"prerenderedAt":586},["ShallowReactive",2],{"article-\u002Fwriting\u002Fconsumer-node-preview-100-rc1-preview-fully-decoupled-drupal-sites-20261112":3,"read-next-\u002Fwriting\u002Fconsumer-node-preview-100-rc1-preview-fully-decoupled-drupal-sites-20261112":172},{"id":4,"title":5,"articleType":6,"categories":7,"date":10,"description":11,"extension":12,"feedImage":13,"hiddenCategories":14,"meta":16,"paragraphs":17,"path":154,"readingTime":155,"related":156,"sitemap":165,"stem":166,"topics":167,"__hash__":171},"articleEntries\u002Farticles-data\u002Fconsumer-node-preview-100-rc1-preview-fully-decoupled-drupal-sites-20261112.json","Consumer Node Preview 1.0.0-rc1; preview for Fully Decoupled Drupal sites","Blog post",[8,9],"Drupal","Druxt","2026-11-12T20:00:00+11:00","Consumer Node Preview shows unsaved Drupal edits in your Fully Decoupled frontend, the way readers will see them. Druxt Node Preview 0.1.0 is the Nuxt side.","json",true,[15],"Planet Drupal",{},[18,37,60,66,77,91,101,116,126,135,147],{"type":19,"layout":20,"regions":21},"section","layout_onecol",{"content":22},[23,26,33,35],{"type":24,"html":25},"text_formatted","\u003Cp>Press Preview on a Fully Decoupled Drupal site, and Consumer Node Preview \u003Ccode>1.0.0-rc1\u003C\u002Fcode> shows the unsaved page in the frontend your readers will see: the title you just changed and the text you just rewrote, rendered by the site that will publish them.\u003C\u002Fp>",{"type":27,"alt":28,"caption":29,"width":30,"height":31,"src":32},"media","Drupal's preview screen with Frontend, Drupal and JSON:API tabs. The Frontend tab frames the druxtjs.org page Getting started with Druxt inside the site's own header and breadcrumb. A Show changes against the live page toggle is on and reports 2 changes: the title gains the words from zero, and in the first paragraph piece of content is struck through and replaced with page.","\u003Cp>Two unsaved edits, to the title and the first paragraph, rendered by the Druxt site readers see.\u003C\u002Fp>",968,760,"\u002Fimages\u002Fwriting\u002Fconsumer-node-preview-100-rc1-preview-fully-decoupled-drupal-sites-20261112\u002Fpreview-diff.png",{"type":24,"html":34},"\u003Cp>Drupal's own Preview renders the node in Drupal's theme, not your frontend, so editors save a draft to check it. Next.js draft mode smooths that workaround, but still needs a saved revision. Consumer Node Preview works on what's still in the edit form, for the person writing it, before anything is saved.\u003C\u002Fp>",{"type":24,"html":36},"\u003Cp>Anything that reads JSON:API can be on the other end. Druxt Node Preview \u003Ccode>0.1.0\u003C\u002Fcode> is the Nuxt one, already running druxtjs.org's previews.\u003C\u002Fp>",{"type":19,"layout":20,"regions":38,"title":59},{"content":39},[40,42,48,50,52,57],{"type":24,"html":41},"\u003Cp>Consumer Node Preview builds on JSON:API Node Preview, which serves the unsaved node over JSON:API.\u003C\u002Fp>",{"type":43,"title":44,"language":45,"code":46,"highlighted":47},"code","Install both","bash","composer require drupal\u002Fjsonapi_node_preview:^1.0@beta drupal\u002Fconsumer_node_preview:^1.0@RC\ndrush en consumer_node_preview","\u003Cspan class=\"token function\">composer\u003C\u002Fspan> require drupal\u002Fjsonapi_node_preview:^1.0@beta drupal\u002Fconsumer_node_preview:^1.0@RC\ndrush en consumer_node_preview",{"type":24,"html":49},"\u003Cp>It stays a release candidate while JSON:API Node Preview is in beta.\u003C\u002Fp>",{"type":24,"html":51},"\u003Cp>Each app that reads your API is a consumer, and each gets its own preview targets, so a Next site and an Astro build can preview the same draft. A target is a label and a URL template. Add it on the consumer's form under \u003Cem>Configuration &gt; Web services &gt; Consumers\u003C\u002Fem>, or in \u003Ccode>settings.php\u003C\u002Fcode> when the URL changes per environment:\u003C\u002Fp>",{"type":43,"title":53,"language":54,"code":55,"highlighted":56},"settings.php","php","$settings['consumer_node_preview'] = [\n  'targets' => [\n    'frontend' => [\n      'label' => 'Frontend',\n      'url' => 'https:\u002F\u002Fwww.example.com\u002Fdruxt\u002Fnode\u002Fpreview?vm=[view_mode]&layout=[layout]#[jsonapi_node_preview]',\n      'layouts' => ['default', 'preview'],\n    ],\n  ],\n];","\u003Cspan class=\"token variable\">$settings\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'consumer_node_preview'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan> \u003Cspan class=\"token operator\">=\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\n  \u003Cspan class=\"token string single-quoted-string\">'targets'\u003C\u002Fspan> \u003Cspan class=\"token operator\">=>\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\n    \u003Cspan class=\"token string single-quoted-string\">'frontend'\u003C\u002Fspan> \u003Cspan class=\"token operator\">=>\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\n      \u003Cspan class=\"token string single-quoted-string\">'label'\u003C\u002Fspan> \u003Cspan class=\"token operator\">=>\u003C\u002Fspan> \u003Cspan class=\"token string single-quoted-string\">'Frontend'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token string single-quoted-string\">'url'\u003C\u002Fspan> \u003Cspan class=\"token operator\">=>\u003C\u002Fspan> \u003Cspan class=\"token string single-quoted-string\">'https:\u002F\u002Fwww.example.com\u002Fdruxt\u002Fnode\u002Fpreview?vm=[view_mode]&amp;layout=[layout]#[jsonapi_node_preview]'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token string single-quoted-string\">'layouts'\u003C\u002Fspan> \u003Cspan class=\"token operator\">=>\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'default'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan> \u003Cspan class=\"token string single-quoted-string\">'preview'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n    \u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">;\u003C\u002Fspan>",{"type":24,"html":58},"\u003Cp>Press Preview, and the module fills in the template's tokens and frames the page on the \u003Cem>Frontend\u003C\u002Fem> tab, so the page has to allow that: no \u003Ccode>X-Frame-Options\u003C\u002Fcode>, and Drupal's origin in any \u003Ccode>frame-ancestors\u003C\u002Fcode> rule. Beside it, \u003Cem>Drupal\u003C\u002Fem> is core's own preview, for anything Drupal renders itself, and \u003Cem>JSON:API\u003C\u002Fem> is the raw document: the tab I open first when a field doesn't turn up.\u003C\u002Fp>","Pointing Drupal at your frontend",{"type":19,"layout":20,"regions":61,"title":65},{"content":62},[63],{"type":24,"html":64},"\u003Cp>A preview URL includes the address of an unsaved draft. In a path or query string, it would end up in the frontend's access logs, any CDN in front of it, and the \u003Ccode>Referer\u003C\u002Fcode> of everything the page loads. So \u003Ccode>[jsonapi_node_preview]\u003C\u002Fcode> goes after the \u003Ccode>#\u003C\u002Fcode>: browsers never send the fragment to a server. Scripts on the page can still read it, so keep analytics, error tracking and session replay off the preview route.\u003C\u002Fp>","Keeping the draft out of the logs",{"type":19,"layout":20,"regions":67,"title":76},{"content":68},[69,71],{"type":24,"html":70},"\u003Cp>If Drupal and your frontend share a domain, like \u003Ccode>cms.example.com\u003C\u002Fcode> and \u003Ccode>www.example.com\u003C\u002Fcode>, the preview page can read with the editor's session cookie, once Drupal's CORS config names the frontend and allows credentials:\u003C\u002Fp>",{"type":43,"title":72,"language":73,"code":74,"highlighted":75},"services.yml, same site","yaml","parameters:\n  cors.config:\n    enabled: true\n    allowedHeaders: ['*']\n    allowedMethods: ['GET']\n    allowedOrigins: ['https:\u002F\u002Fwww.example.com']\n    supportsCredentials: true","\u003Cspan class=\"token key atrule\">parameters\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">:\u003C\u002Fspan>\n  \u003Cspan class=\"token key atrule\">cors.config\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">:\u003C\u002Fspan>\n    \u003Cspan class=\"token key atrule\">enabled\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">:\u003C\u002Fspan> \u003Cspan class=\"token boolean important\">true\u003C\u002Fspan>\n    \u003Cspan class=\"token key atrule\">allowedHeaders\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string\">'*'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\n    \u003Cspan class=\"token key atrule\">allowedMethods\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string\">'GET'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\n    \u003Cspan class=\"token key atrule\">allowedOrigins\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string\">'https:\u002F\u002Fwww.example.com'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\n    \u003Cspan class=\"token key atrule\">supportsCredentials\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">:\u003C\u002Fspan> \u003Cspan class=\"token boolean important\">true\u003C\u002Fspan>","Sharing a domain: the editor's session",{"type":19,"layout":20,"regions":78,"title":90},{"content":79},[80,82,88],{"type":24,"html":81},"\u003Cp>Browsers won't send that cookie to a frontend on another domain, such as \u003Ccode>example.vercel.app\u003C\u002Fcode>, so the preview reads with a token instead. Install Simple OAuth, then on the consumer's form turn on the Authorization Code grant, and give its \u003Cem>Authorization Code settings\u003C\u002Fem> a scope for a role made for previews, rather than your editors' own. A scope under \u003Cem>Default scopes\u003C\u002Fem> alone gets the preview a 403.\u003C\u002Fp>",{"type":27,"alt":83,"caption":84,"width":85,"height":86,"src":87},"The consumer form's Grant types, with Authorization Code and Client Credentials ticked, and the Authorization Code settings, with the editor scope ticked.","\u003Cp>The consumer's Authorization Code grant, with a scope for the editor role. The preview token needs both.\u003C\u002Fp>",918,474,"\u002Fimages\u002Fwriting\u002Fconsumer-node-preview-100-rc1-preview-fully-decoupled-drupal-sites-20261112\u002Fconsumer-token-grant.png",{"type":24,"html":89},"\u003Cp>End the target with \u003Ccode>;token=[preview_token]\u003C\u002Fcode>, and in \u003Ccode>settings.php\u003C\u002Fcode> name its consumer with \u003Ccode>'consumer' =&gt; 'my-frontend'\u003C\u002Fcode>. Each Preview then adds a token to the fragment. It acts as the editor, limited to that role, for 15 minutes by default. It travels without a cookie, so CORS can allow any origin: without credentials, another site reads only what an anonymous visitor could. In \u003Ccode>services.yml\u003C\u002Fcode>, set \u003Ccode>allowedOrigins\u003C\u002Fcode> to \u003Ccode>['*']\u003C\u002Fcode> and \u003Ccode>supportsCredentials\u003C\u002Fcode> to \u003Ccode>false\u003C\u002Fcode>.\u003C\u002Fp>","On another domain: a preview token",{"type":19,"layout":92,"regions":93,"title":100},"layout_twocol",{"first":94,"second":97},[95],{"type":24,"html":96},"\u003Cp>\u003Cstrong>View modes, for Nuxt developers.\u003C\u002Fstrong> Drupal's displays of the same content, such as Full or Teaser, each with its own fields. The editor's pick arrives as \u003Ccode>vm\u003C\u002Fcode>, and Druxt renders the node in that mode.\u003C\u002Fp>",[98],{"type":24,"html":99},"\u003Cp>\u003Cstrong>Layouts, for Drupal developers.\u003C\u002Fstrong> Nuxt's page wrappers, the components in \u003Ccode>layouts\u002F\u003C\u002Fcode> that put the site around a page. The editor's pick arrives as \u003Ccode>layout\u003C\u002Fcode>, so one node can be checked inside the full site or on a bare page.\u003C\u002Fp>","View modes and layouts",{"type":19,"layout":20,"regions":102,"title":115},{"content":103},[104,106,111,113],{"type":24,"html":105},"\u003Cp>On a Druxt site, the preview page is a Nuxt module that can register its own target:\u003C\u002Fp>",{"type":43,"title":107,"language":108,"code":109,"highlighted":110},"nuxt.config.js","javascript","\u002F\u002F npm install druxt-site @druxt-contrib\u002Fnode-preview\nexport default {\n  modules: ['@druxt-contrib\u002Fnode-preview', 'druxt-site'],\n  druxt: {\n    baseUrl: 'https:\u002F\u002Fcms.example.com',\n    proxy: { api: true },\n    nodePreview: {\n      layouts: ['default', 'preview'],\n      register: {\n        url: 'https:\u002F\u002Fexample.vercel.app',\n        consumer: 'my-frontend', \u002F\u002F The consumer's client ID.\n        token: true,\n      },\n    },\n  },\n}","\u003Cspan class=\"token comment\">\u002F\u002F npm install druxt-site @druxt-contrib\u002Fnode-preview\u003C\u002Fspan>\n\u003Cspan class=\"token keyword\">export\u003C\u002Fspan> \u003Cspan class=\"token keyword\">default\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n  \u003Cspan class=\"token literal-property property\">modules\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string\">'@druxt-contrib\u002Fnode-preview'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan> \u003Cspan class=\"token string\">'druxt-site'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"token literal-property property\">druxt\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n    \u003Cspan class=\"token literal-property property\">baseUrl\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">'https:\u002F\u002Fcms.example.com'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n    \u003Cspan class=\"token literal-property property\">proxy\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan> \u003Cspan class=\"token literal-property property\">api\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token boolean\">true\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n    \u003Cspan class=\"token literal-property property\">nodePreview\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n      \u003Cspan class=\"token literal-property property\">layouts\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string\">'default'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan> \u003Cspan class=\"token string\">'preview'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token literal-property property\">register\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n        \u003Cspan class=\"token literal-property property\">url\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">'https:\u002F\u002Fexample.vercel.app'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n        \u003Cspan class=\"token literal-property property\">consumer\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">'my-frontend'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan> \u003Cspan class=\"token comment\">\u002F\u002F The consumer's client ID.\u003C\u002Fspan>\n        \u003Cspan class=\"token literal-property property\">token\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token boolean\">true\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n    \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n\u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>",{"type":24,"html":112},"\u003Cp>With the secret of a consumer \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fdruxt-contrib\u002Fdruxt-node-preview\u002Fblob\u002F0.x\u002Fdocs\u002Fregistration-drupal-setup.md\">set up for Simple OAuth\u003C\u002Fa> in \u003Ccode>DRUXT_NODE_PREVIEW_CLIENT_SECRET\u003C\u002Fcode>, Nuxt adds its target to Drupal when it starts and every six hours, and removes it on a clean shutdown. A target that stops registering goes inactive after a day, so a crashed server cleans up after itself. With \u003Ccode>token\u003C\u002Fcode> on and Druxt's API proxy, the read never leaves the frontend's origin, and Drupal's CORS stays as it is.\u003C\u002Fp>",{"type":24,"html":114},"\u003Cp>The preview goes in the Druxt store before \u003Ccode>DruxtEntity\u003C\u002Fcode> resolves, so it renders through the same components as the live page, and the token comes off the address on load. It only fetches from its own origin, its Drupal site, or origins you list, so a crafted link can't point the preview, or the editor's token, at somebody else's server.\u003C\u002Fp>","Druxt renders it like any other page",{"type":19,"layout":20,"regions":117,"title":125},{"content":118},[119,121],{"type":24,"html":120},"\u003Cp>Nothing in the URL template is Druxt-specific. Druxt Node Preview is for Nuxt 2 and Node 16, and stays \u003Ccode>0.x\u003C\u002Fcode> until Druxt reaches \u003Ccode>1.0.0\u003C\u002Fcode>. In Nuxt 3, Next or Astro, this is the core of a preview page:\u003C\u002Fp>",{"type":43,"title":122,"language":108,"code":123,"highlighted":124},"A preview page for any other frontend","\u002F\u002F Target URL: https:\u002F\u002Fwww.example.com\u002Fpreview#[jsonapi_node_preview];token=[preview_token]\n\u002F\u002F Runs in the browser, in a module script: the fragment never reaches a server.\n\u002F\u002F The origins this page may fetch a preview from.\nconst allowed = ['https:\u002F\u002Fcms.example.com']\n\n\u002F\u002F Some routers turn https:\u002F\u002F into https:\u002F inside a hash.\nconst hash = decodeURIComponent(location.hash.slice(1))\nconst [address, token] = hash\n  .replace(\u002F^(https?):\\\u002F(?!\\\u002F)\u002F, '$1:\u002F\u002F')\n  .split(';token=')\nconst endpoint = new URL(address)\n\u002F\u002F Take the token back out of the address bar.\nhistory.replaceState(null, '', location.pathname + location.search)\n\nif (!allowed.includes(endpoint.origin)) {\n  throw new Error(`Refusing a preview from ${endpoint.origin}`)\n}\n\nconst response = await fetch(endpoint, {\n  \u002F\u002F A token target reads without the cookie.\n  credentials: token ? 'omit' : 'include',\n  headers: {\n    Accept: 'application\u002Fvnd.api+json',\n    ...(token && { Authorization: `Bearer ${token}` }),\n  },\n})\nif (!response.ok) throw new Error(`Preview failed: HTTP ${response.status}`)\nconst { data, included } = await response.json()\n\u002F\u002F Render data and included with the components a live page uses.","\u003Cspan class=\"token comment\">\u002F\u002F Target URL: https:\u002F\u002Fwww.example.com\u002Fpreview#[jsonapi_node_preview];token=[preview_token]\u003C\u002Fspan>\n\u003Cspan class=\"token comment\">\u002F\u002F Runs in the browser, in a module script: the fragment never reaches a server.\u003C\u002Fspan>\n\u003Cspan class=\"token comment\">\u002F\u002F The origins this page may fetch a preview from.\u003C\u002Fspan>\n\u003Cspan class=\"token keyword\">const\u003C\u002Fspan> allowed \u003Cspan class=\"token operator\">=\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string\">'https:\u002F\u002Fcms.example.com'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\n\n\u003Cspan class=\"token comment\">\u002F\u002F Some routers turn https:\u002F\u002F into https:\u002F inside a hash.\u003C\u002Fspan>\n\u003Cspan class=\"token keyword\">const\u003C\u002Fspan> hash \u003Cspan class=\"token operator\">=\u003C\u002Fspan> \u003Cspan class=\"token function\">decodeURIComponent\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">(\u003C\u002Fspan>location\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>hash\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>\u003Cspan class=\"token function\">slice\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">(\u003C\u002Fspan>\u003Cspan class=\"token number\">1\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">)\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">)\u003C\u002Fspan>\n\u003Cspan class=\"token keyword\">const\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>address\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan> token\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan> \u003Cspan class=\"token operator\">=\u003C\u002Fspan> hash\n  \u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>\u003Cspan class=\"token function\">replace\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">(\u003C\u002Fspan>\u003Cspan class=\"token regex\">\u003Cspan class=\"token regex-delimiter\">\u002F\u003C\u002Fspan>\u003Cspan class=\"token regex-source language-regex\">^(https?):\\\u002F(?!\\\u002F)\u003C\u002Fspan>\u003Cspan class=\"token regex-delimiter\">\u002F\u003C\u002Fspan>\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan> \u003Cspan class=\"token string\">'$1:\u002F\u002F'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">)\u003C\u002Fspan>\n  \u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>\u003Cspan class=\"token function\">split\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">(\u003C\u002Fspan>\u003Cspan class=\"token string\">';token='\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">)\u003C\u002Fspan>\n\u003Cspan class=\"token keyword\">const\u003C\u002Fspan> endpoint \u003Cspan class=\"token operator\">=\u003C\u002Fspan> \u003Cspan class=\"token keyword\">new\u003C\u002Fspan> \u003Cspan class=\"token class-name\">URL\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">(\u003C\u002Fspan>address\u003Cspan class=\"token punctuation\">)\u003C\u002Fspan>\n\u003Cspan class=\"token comment\">\u002F\u002F Take the token back out of the address bar.\u003C\u002Fspan>\nhistory\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>\u003Cspan class=\"token function\">replaceState\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">(\u003C\u002Fspan>\u003Cspan class=\"token keyword\">null\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan> \u003Cspan class=\"token string\">''\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan> location\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>pathname \u003Cspan class=\"token operator\">+\u003C\u002Fspan> location\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>search\u003Cspan class=\"token punctuation\">)\u003C\u002Fspan>\n\n\u003Cspan class=\"token keyword\">if\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">(\u003C\u002Fspan>\u003Cspan class=\"token operator\">!\u003C\u002Fspan>allowed\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>\u003Cspan class=\"token function\">includes\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">(\u003C\u002Fspan>endpoint\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>origin\u003Cspan class=\"token punctuation\">)\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">)\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n  \u003Cspan class=\"token keyword\">throw\u003C\u002Fspan> \u003Cspan class=\"token keyword\">new\u003C\u002Fspan> \u003Cspan class=\"token class-name\">Error\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">(\u003C\u002Fspan>\u003Cspan class=\"token template-string\">\u003Cspan class=\"token template-punctuation string\">`\u003C\u002Fspan>\u003Cspan class=\"token string\">Refusing a preview from \u003C\u002Fspan>\u003Cspan class=\"token interpolation\">\u003Cspan class=\"token interpolation-punctuation punctuation\">${\u003C\u002Fspan>endpoint\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>origin\u003Cspan class=\"token interpolation-punctuation punctuation\">}\u003C\u002Fspan>\u003C\u002Fspan>\u003Cspan class=\"token template-punctuation string\">`\u003C\u002Fspan>\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">)\u003C\u002Fspan>\n\u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\n\n\u003Cspan class=\"token keyword\">const\u003C\u002Fspan> response \u003Cspan class=\"token operator\">=\u003C\u002Fspan> \u003Cspan class=\"token keyword\">await\u003C\u002Fspan> \u003Cspan class=\"token function\">fetch\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">(\u003C\u002Fspan>endpoint\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n  \u003Cspan class=\"token comment\">\u002F\u002F A token target reads without the cookie.\u003C\u002Fspan>\n  \u003Cspan class=\"token literal-property property\">credentials\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> token \u003Cspan class=\"token operator\">?\u003C\u002Fspan> \u003Cspan class=\"token string\">'omit'\u003C\u002Fspan> \u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">'include'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"token literal-property property\">headers\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n    \u003Cspan class=\"token literal-property property\">Accept\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">'application\u002Fvnd.api+json'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n    \u003Cspan class=\"token operator\">...\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">(\u003C\u002Fspan>token \u003Cspan class=\"token operator\">&amp;&amp;\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan> \u003Cspan class=\"token literal-property property\">Authorization\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token template-string\">\u003Cspan class=\"token template-punctuation string\">`\u003C\u002Fspan>\u003Cspan class=\"token string\">Bearer \u003C\u002Fspan>\u003Cspan class=\"token interpolation\">\u003Cspan class=\"token interpolation-punctuation punctuation\">${\u003C\u002Fspan>token\u003Cspan class=\"token interpolation-punctuation punctuation\">}\u003C\u002Fspan>\u003C\u002Fspan>\u003Cspan class=\"token template-punctuation string\">`\u003C\u002Fspan>\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">)\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n\u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">)\u003C\u002Fspan>\n\u003Cspan class=\"token keyword\">if\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">(\u003C\u002Fspan>\u003Cspan class=\"token operator\">!\u003C\u002Fspan>response\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>ok\u003Cspan class=\"token punctuation\">)\u003C\u002Fspan> \u003Cspan class=\"token keyword\">throw\u003C\u002Fspan> \u003Cspan class=\"token keyword\">new\u003C\u002Fspan> \u003Cspan class=\"token class-name\">Error\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">(\u003C\u002Fspan>\u003Cspan class=\"token template-string\">\u003Cspan class=\"token template-punctuation string\">`\u003C\u002Fspan>\u003Cspan class=\"token string\">Preview failed: HTTP \u003C\u002Fspan>\u003Cspan class=\"token interpolation\">\u003Cspan class=\"token interpolation-punctuation punctuation\">${\u003C\u002Fspan>response\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>status\u003Cspan class=\"token interpolation-punctuation punctuation\">}\u003C\u002Fspan>\u003C\u002Fspan>\u003Cspan class=\"token template-punctuation string\">`\u003C\u002Fspan>\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">)\u003C\u002Fspan>\n\u003Cspan class=\"token keyword\">const\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan> data\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan> included \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan> \u003Cspan class=\"token operator\">=\u003C\u002Fspan> \u003Cspan class=\"token keyword\">await\u003C\u002Fspan> response\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>\u003Cspan class=\"token function\">json\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">(\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">)\u003C\u002Fspan>\n\u003Cspan class=\"token comment\">\u002F\u002F Render data and included with the components a live page uses.\u003C\u002Fspan>","A preview page for Next, Astro or Nuxt 3",{"type":19,"layout":20,"regions":127},{"content":128},[129],{"type":130,"title":131,"content":132},"jumbotron","Help paragraph preview land",[133],{"type":24,"html":134},"\u003Cp>Unsaved paragraphs come through with the patch on \u003Ca href=\"https:\u002F\u002Fwww.drupal.org\u002Fi\u002F3367724\">#3367724\u003C\u002Fa>, which druxtjs.org runs as a Composer patch. If you preview paragraphs, test it on your site and leave a review. It needs a tested review to land.\u003C\u002Fp>",{"type":19,"layout":20,"regions":136},{"content":137},[138,144],{"type":139,"description":140,"url":141,"gitpod":142,"drupalUrl":143},"repository","\u003Cp>Consumer Node Preview's code is on GitHub, and its releases and issue queue are on Drupal.org. If your editors work on a Fully Decoupled site, sponsorship is what moves the diff work up the list.\u003C\u002Fp>","https:\u002F\u002Fgithub.com\u002FDecipher\u002Fconsumer_node_preview",false,"https:\u002F\u002Fwww.drupal.org\u002Fproject\u002Fconsumer_node_preview",{"type":139,"description":145,"url":146,"gitpod":142},"\u003Cp>Druxt Node Preview is on GitHub. Its end-to-end suite builds a Drupal site with both modules and previews through a real Druxt frontend.\u003C\u002Fp>","https:\u002F\u002Fgithub.com\u002Fdruxt-contrib\u002Fdruxt-node-preview",{"type":19,"layout":20,"regions":148},{"content":149},[150,152],{"type":24,"html":151},"\u003Cp>Diffs are next: the changes toggle in the screenshot is druxtjs.org's early look at them.\u003C\u002Fp>",{"type":24,"html":153},"\u003Cp>If your frontend sits on a different domain to Drupal, is a 15-minute token long enough for the way your editors preview?\u003C\u002Fp>","\u002Fwriting\u002Fconsumer-node-preview-100-rc1-preview-fully-decoupled-drupal-sites-20261112","4 min",[157,160,163],{"path":158,"source":159},"\u002Fwriting\u002Fdrupal-site-settings-over-jsonapi-consumer-20260826","picked",{"path":161,"source":162},"\u002Fwriting\u002Fvale-100-prose-linting-drupal-20261028","view",{"path":164,"source":162},"\u002Fwriting\u002Fdruxt-0250-caching-and-performance-buffs-20261023",{"loc":154},"articles-data\u002Fconsumer-node-preview-100-rc1-preview-fully-decoupled-drupal-sites-20261112",[168,169,170],"editing","JSON:API","paragraphs","9TYXa50brm4Ye65PDvlSMOEPOBf2VaXdwlB7LO2bS6c",[173,278,448],{"id":174,"title":175,"articleType":6,"categories":176,"date":177,"description":178,"extension":12,"feedImage":142,"hiddenCategories":179,"meta":180,"paragraphs":181,"path":158,"readingTime":155,"related":267,"sitemap":273,"stem":274,"topics":275,"__hash__":277},"articleEntries\u002Farticles-data\u002Fdrupal-site-settings-over-jsonapi-consumer-20260826.json","Drupal site settings over JSON:API, per consumer",[8,9],"2026-08-26T19:20:00+10:00","Run the same decoupled frontend twice with two consumer IDs and get two differently branded sites. Decoupled Settings serves the site and theme config Drupal already holds over JSON:API, with per-consumer overrides. Four years ago I guessed the answer would be Consumers plus theme settings. Turns out it was.",[15],{},[182,189,195,201,231,241,254,260],{"type":19,"layout":20,"regions":183},{"content":184},[185,187],{"type":24,"html":186},"\u003Cp>In April 2022 I wrote about \u003Ca href=\"\u002Fwriting\u002Fdecoupling-configuration-config-pages-20220412\">decoupling configuration with Config Pages\u003C\u002Fa>, and ended it with a guess at where I'd go next:\u003C\u002Fp>\n\n\u003Cblockquote>\u003Cp>One of my future experiments with Druxt will be to look at the options of using the Drupal Consumers module alongside Drupal theme settings as a solution.\u003C\u002Fp>\u003C\u002Fblockquote>",{"type":24,"html":188},"\u003Cp>That experiment is now a module. \u003Ca href=\"https:\u002F\u002Fwww.drupal.org\u002Fproject\u002Fdecoupled_settings\">Decoupled Settings\u003C\u002Fa> serves the site and theme configuration Drupal already holds over JSON:API, and lets every frontend override just the values it needs. It went up on Drupal.org this week.\u003C\u002Fp>",{"type":19,"title":190,"layout":20,"regions":191},"What core doesn't give you",{"content":192},[193],{"type":24,"html":194},"\u003Cp>Core's JSON:API is an entity API. Content entities and config entities alike, so node types, image styles and views are all in there. What it has never exposed is simple configuration, and there's a good argument that it shouldn't by default, because plenty of what sits in \u003Ccode>system.site\u003C\u002Fcode> is nobody's business.\u003C\u002Fp>\n\n\u003Cp>But a decoupled frontend still needs the boring stuff:\u003C\u002Fp>\n\n\u003Cul>\u003Cli>The site name, for the document title\u003C\u002Fli>\n\u003Cli>The slogan\u003C\u002Fli>\n\u003Cli>The logo and the favicon\u003C\u002Fli>\n\u003Cli>The front page path, so the router knows what \"home\" means\u003C\u002Fli>\n\u003C\u002Ful>\n\n\u003Cp>So every decoupled build solves it locally, and most of the ways to solve it end up keeping a second copy of the site name somewhere.\u003C\u002Fp>",{"type":19,"title":196,"layout":20,"regions":197},"Isn't that what Config Pages is for?",{"content":198},[199],{"type":24,"html":200},"\u003Cp>Config Pages is for settings \u003Cem>you\u003C\u002Fem> invent: define a type, add the fields your app needs, done. That job hasn't changed, and it's still on my sites.\u003C\u002Fp>\n\n\u003Cp>Where it gets awkward is settings Drupal already stores. Model the site name as a field on a Config Page and the site name exists in two places, and something has to keep them in step, and sooner or later something won't.\u003C\u002Fp>",{"type":19,"title":202,"layout":20,"regions":203},"How it works",{"content":204},[205,207,213,215,221,223,226,229],{"type":24,"html":206},"\u003Cp>So the module doesn't store settings. It reads them from the config objects that already hold them, and a consumer stores only what it changes.\u003C\u002Fp>",{"type":27,"alt":208,"caption":209,"width":210,"height":211,"src":212},"The per-consumer overrides form for a consumer named Partner Portal, filtered to system.site. Columns are Setting, Inherited value, Override and Value for this consumer. system.site:name and system.site:slogan are ticked and overridden to 'Partner Portal' and 'Same code, different consumer'; langcode, page.403, page.404 and page.front are unticked and inherited.","\u003Cp>Tick a setting to override it for this consumer. Everything unticked keeps following the site.\u003C\u002Fp>",1336,1022,"\u002Fimages\u002Fwriting\u002Fdecoupled-settings-consumer-overrides.png",{"type":24,"html":214},"\u003Cp>Tick a setting and it's yours. Leave it alone and it keeps following the site. Tick it and leave the value blank and that frontend doesn't show a slogan at all, which is a different thing again from clearing the override and going back to inheriting.\u003C\u002Fp>\n\n\u003Cp>What's exposed at all is bounded twice: the config objects you list, and within those, the keys typed config schema declares. On install that's \u003Ccode>system.site\u003C\u002Fcode> plus the active theme, and both of \u003Ccode>system.site\u003C\u002Fcode>'s email keys ship excluded. The settings form shows exactly how wide the exposure is, unsaved changes included, so you can trim it to the keys you want before anything ships.\u003C\u002Fp>",{"type":27,"alt":216,"caption":217,"width":218,"height":219,"src":220},"The Decoupled Settings configuration form: an Exposed configuration list containing system.site, a checkbox to expose the active theme settings, an Excluded settings box listing system.site:mail and system.site:mail_notification, and a 'What a frontend will read' table showing each setting with its global value.","\u003Cp>The exposure list, the keys it holds back, and what a frontend gets from what's left. Filtered to \u003Ccode>system.site\u003C\u002Fcode> here, so the theme's fourteen keys aren't in shot.\u003C\u002Fp>",1016,1358,"\u002Fimages\u002Fwriting\u002Fdecoupled-settings-exposure-form.png",{"type":24,"html":222},"\u003Cp>Reading it is one GET:\u003C\u002Fp>",{"type":43,"title":224,"code":225},"Reading settings as a consumer","curl \"https:\u002F\u002Fdrupal.example.com\u002Fjsonapi\u002Fdecoupled\u002Fsettings?consumerId=partner_frontend\"",{"type":43,"title":227,"code":228},"Abbreviated: 7 of the 23 keys, otherwise as returned","{\n  \"data\": {\n    \"type\": \"decoupled_settings--settings\",\n    \"id\": \"decoupled-settings\",\n    \"attributes\": {\n      \"settings\": {\n        \"system.site\": {\n          \"name\": \"Partner Portal\",\n          \"slogan\": \"Same code, different consumer\",\n          \"page\": { \"front\": \"\u002Fnode\" }\n        },\n        \"olivero.settings\": {\n          \"favicon\": { \"url\": \"\u002Fcore\u002Fthemes\u002Folivero\u002Ffavicon.ico\", \"use_default\": true },\n          \"logo\": { \"url\": \"\u002Fcore\u002Fthemes\u002Folivero\u002Flogo.svg\", \"use_default\": true }\n        }\n      },\n      \"consumer\": \"partner_frontend\"\n    }\n  }\n}",{"type":24,"html":230},"\u003Cp>The logo and favicon come back as resolved URLs, through core's own theme settings resolution and its fallbacks. Responses collect the config cache tags of every object that contributed, so an editor changing the site name invalidates them with nobody writing a hook.\u003C\u002Fp>\n\n\u003Cp>With \u003Ca href=\"https:\u002F\u002Fwww.drupal.org\u002Fproject\u002Fsimple_oauth\">Simple OAuth\u003C\u002Fa> it's simpler still: a client credentials token identifies the consumer by itself, no header and no query parameter, because the OAuth client and the settings consumer are the same entity.\u003C\u002Fp>",{"type":19,"title":232,"layout":20,"regions":233},"One backend, many frontends",{"content":234},[235,237,239],{"type":24,"html":236},"\u003Cp>A consumer is a name on a request. Point two builds of the same frontend at one backend, have each identify as a different consumer, and each comes up as its own site: its own name, its own slogan, its own logo and favicon. Nothing in the code differs between them, only an environment variable.\u003C\u002Fp>",{"type":24,"html":238},"\u003Cp>Read the settings at build time and bake them into the artifact, and the artifact doesn't depend on Drupal at runtime, at the cost of a rebuild whenever a setting changes. Read them at runtime and the cache tags start earning their keep. Either way it's one request, so it matters very little what the frontend is built with.\u003C\u002Fp>\n\n\u003Cp>A consumer gets its own name, slogan, front page and branding assets. Menus, regions, layouts and per-market content are still yours to solve.\u003C\u002Fp>",{"type":24,"html":240},"\u003Cp>If you're on Druxt, a module that wires all of this into the build is close behind, for the current Druxt release. It identifies the app as a consumer, reads the settings, and applies the site name and favicon to the document head. It also bakes the whole resolved set into Nuxt's runtime config, so \u003Ccode>$config.decoupledSettings['system.site'].name\u003C\u002Fcode> works anywhere in the app.\u003C\u002Fp>",{"type":19,"title":242,"layout":20,"regions":243},"Where it stands",{"content":244},[245,247,249,251],{"type":24,"html":246},"\u003Cp>108 tests and 492 assertions across unit, kernel and functional coverage, green on Drupal 10 and Drupal 11. The first pipeline on drupalcode.org came back green across all eight jobs on its first run, which never happens.\u003C\u002Fp>",{"type":24,"html":248},"\u003Cp>It's \u003Cstrong>1.0.0-beta2\u003C\u002Fstrong>. Drupal's security advisory policy covers stable releases only, and the response shape can still move before 1.0. Adopt now and you're the second consumer of this API, which means you get a say in that shape while it's still soft.\u003C\u002Fp>\n\n\u003Cp>Overrides live on the consumer entity, so they're content rather than config, and they stay in the database rather than travelling with \u003Ccode>drush cim\u003C\u002Fcode>. Multilingual works one way today: exposed values follow language negotiation, but an override applies in every language, so you can translate a setting or brand it per consumer, not both yet.\u003C\u002Fp>",{"type":24,"html":250},"\u003Cp>If any of that is useful, the beta is out now:\u003C\u002Fp>",{"type":43,"title":252,"code":253},"Install","composer require 'drupal\u002Fdecoupled_settings:^1.0@beta'\ndrush en decoupled_settings",{"type":19,"title":255,"layout":20,"regions":256},"What next?",{"content":257},[258],{"type":24,"html":259},"\u003Cp>File (Field) Paths, still next up, and the Druxt half of this behind it.\u003C\u002Fp>\n\n\u003Cp>If you're running decoupled Drupal already, how are you getting the site name into your frontend today? I'd like to know, while the API shape is still soft enough to change.\u003C\u002Fp>",{"type":19,"layout":20,"regions":261},{"content":262},[263],{"type":139,"description":264,"url":265,"gitpod":142,"drupalUrl":266},"\u003Cp>Source on GitHub, project page and issue queue on Drupal.org. If your projects lean on modules like this one, sponsoring is what keeps them actively maintained.\u003C\u002Fp>","https:\u002F\u002Fgithub.com\u002FDecipher\u002Fdecoupled_settings","https:\u002F\u002Fwww.drupal.org\u002Fproject\u002Fdecoupled_settings",[268,270,271],{"path":269,"source":159},"\u002Fwriting\u002Fdecoupling-configuration-config-pages-20220412",{"path":164,"source":162},{"path":272,"source":162},"\u002Fwriting\u002Fdruxt-drupal-13x-resource-list-yours-20260909",{"loc":158},"articles-data\u002Fdrupal-site-settings-over-jsonapi-consumer-20260826",[276,169],"decoupled config","C4N8j3iGplRrhU0BkLce3LOKNl4mB-GLHnu7ZzRefMM",{"id":279,"title":280,"articleType":6,"categories":281,"date":282,"description":283,"extension":12,"feedImage":13,"hiddenCategories":284,"meta":285,"paragraphs":286,"path":164,"readingTime":435,"related":436,"sitemap":442,"stem":443,"topics":444,"__hash__":447},"articleEntries\u002Farticles-data\u002Fdruxt-0250-caching-and-performance-buffs-20261023.json","Druxt 0.25.0; caching and performance buffs",[8,9],"2026-10-23T18:00:00+11:00","Druxt 0.25.0 takes a Nuxt server from 17 requests to Drupal a page to 11, and to none with content caching on, with Drupal's own Cache-Control deciding how long and a webhook to clear it. Plus Manage display changes without a rebuild.",[15],{},[287,302,312,330,345,372,384,392,398,410,418,428],{"type":19,"layout":20,"regions":288},{"content":289},[290,292,294,296],{"type":24,"html":291},"\u003Cp>Druxt is the Fully Decoupled Drupal framework for Nuxt 2, and 0.25.0, the first Druxt release since 2023, makes it ask Drupal a lot less.\u003C\u002Fp>",{"type":24,"html":293},"\u003Cp>On druxtjs.org, the Druxt docs site, a page render made 17 requests to Drupal. On 0.25.0 it makes 11, and with content caching on it makes none, until Drupal's lifetime for the page runs out. The Nuxt server keeps what Drupal sends for as long as Drupal's \u003Ccode>Cache-Control\u003C\u002Fcode> allows, and a webhook clears it when content changes.\u003C\u002Fp>",{"type":24,"html":295},"\u003Cp>Separately, the first-load flicker is gone, and with \u003Ccode>schema.refresh\u003C\u002Fcode> on, a change in Drupal's Manage display shows up without a rebuild, which is the part I have wanted for years. The upgrade command is near the end.\u003C\u002Fp>",{"type":27,"src":297,"alt":298,"caption":299,"width":300,"height":301},"\u002Fimages\u002Fwriting\u002Fdruxt-0250-caching-and-performance-buffs-20261023\u002Fdruxt-0-25-0-content-cache-grid.png","Three dot grids, one dot per request to Drupal, one column per page of a 24-page warm crawl. Druxt 0.24.0 makes 17 on most pages and 29 and 35 on the two section pages, 438 in all. Druxt 0.25.0 with cache.content off makes 11 on most pages, 294 in all. Druxt 0.25.0 with cache.content on makes none on any page. Counted at a proxy in front of Drupal answering max-age 300.","Requests to Drupal per page over a 24-page warm crawl: 17 a page on Druxt 0.24.0, 11 on 0.25.0, and 0 on 0.25.0 with cache.content on. Counted at a proxy, Drupal answering max-age 300; each process also reads the index and the decoupled settings once at start.",1280,640,{"type":19,"layout":20,"regions":303,"title":311},{"content":304},[305,307,309],{"type":24,"html":306},"\u003Cp>0.24.0 made 17 calls a page, and six of them were the JSON:API index and the menus, asked again on every page. The index is Drupal's map of its API, and Druxt reads it to find everything else. Two of the six were duplicates within one render, and they go the moment you upgrade: identical requests now share one fetch, and the menu cache keys on the query it sends.\u003C\u002Fp>",{"type":24,"html":308},"\u003Cp>The other four are kept on the Nuxt server between requests. Drupal's \u003Ccode>Cache-Control\u003C\u002Fcode> decides how long, \u003Ccode>ttl\u003C\u002Fcode> can only shorten it, and when in doubt nothing is kept. The \u003Ca href=\"https:\u002F\u002Fdruxtjs.org\u002Fhow-to\u002Fcaching#how-long-it-lasts\">caching guide\u003C\u002Fa> has the full rules. Across a 24-page crawl, that is 144 index and menu requests down to none once the first page has been rendered.\u003C\u002Fp>",{"type":24,"html":310},"\u003Cp>A signed-in request never fills it: an \u003Ccode>Authorization\u003C\u002Fcode> header, basic auth or a Drupal session cookie skips it, and an analytics cookie doesn't count.\u003C\u002Fp>","Asking Drupal once",{"type":19,"layout":20,"regions":313,"title":329},{"content":314},[315,317,323,327],{"type":24,"html":316},"\u003Cp>Index and menu caching is on by default in production builds. Drupal needs one setting, the page cache max-age, which ships at 0. It applies to anonymous responses across the site, so browsers and CDNs will hold pages for five minutes too. Check Drupal itself rather than a CDN in front of it:\u003C\u002Fp>",{"type":27,"src":318,"alt":319,"caption":320,"width":321,"height":322},"\u002Fimages\u002Fwriting\u002Fdruxt-0250-caching-and-performance-buffs-20261023\u002Fdrupal-performance-max-age.png","Drupal 11's Performance page under Administration, Configuration, Development, with the Caching section open and Browser and proxy cache maximum age set to 5 min, above Drupal's note that the value is used as max-age in Cache-Control headers.","Administration > Configuration > Development > Performance, with the page cache maximum age at 5 minutes.",788,551,{"type":43,"title":324,"code":325,"language":45,"highlighted":326},"Five minutes, then check the header","drush -y config:set system.performance cache.page.max_age 300\ndrush -y config:export\ncurl -sI https:\u002F\u002Fcms.example.com\u002Fjsonapi | grep -i cache-control\n# cache-control: max-age=300, public","drush \u003Cspan class=\"token parameter variable\">-y\u003C\u002Fspan> config:set system.performance cache.page.max_age \u003Cspan class=\"token number\">300\u003C\u002Fspan>\ndrush \u003Cspan class=\"token parameter variable\">-y\u003C\u002Fspan> config:export\n\u003Cspan class=\"token function\">curl\u003C\u002Fspan> \u003Cspan class=\"token parameter variable\">-sI\u003C\u002Fspan> https:\u002F\u002Fcms.example.com\u002Fjsonapi \u003Cspan class=\"token operator\">|\u003C\u002Fspan> \u003Cspan class=\"token function\">grep\u003C\u002Fspan> \u003Cspan class=\"token parameter variable\">-i\u003C\u002Fspan> cache-control\n\u003Cspan class=\"token comment\"># cache-control: max-age=300, public\u003C\u002Fspan>",{"type":24,"html":328},"\u003Cp>If you have turned CORS on in Drupal's \u003Ccode>services.yml\u003C\u002Fcode>, allow one origin, or \u003Ccode>*\u003C\u002Fcode> without credentials. Two or more origins, or an origin pattern, add \u003Ccode>Vary: Origin\u003C\u002Fcode> to every response, and nothing gets cached. Credentials are fine with one origin, so a preview setup like \u003Ca href=\"https:\u002F\u002Fwww.drupal.org\u002Fproject\u002Fconsumer_node_preview\">Consumer Node Preview\u003C\u002Fa>'s keeps caching, and an editor's signed-in preview skips the cache anyway.\u003C\u002Fp>","Turning it on",{"type":19,"layout":20,"title":331,"regions":332},"Caching content",{"content":333},[334,336,341,343],{"type":24,"html":335},"\u003Cp>The index and menus are cached by default. Set \u003Ccode>cache.content\u003C\u002Fcode> and the same cache keeps everything else Druxt fetches from Drupal: entities, collections, views and route lookups.\u003C\u002Fp>",{"type":43,"title":337,"code":338,"language":339,"highlighted":340},"nuxt.config.js, with content caching","export default {\n  druxt: {\n    cache: { content: true, secret: process.env.DRUXT_CACHE_SECRET },\n  },\n}","js","\u003Cspan class=\"token keyword\">export\u003C\u002Fspan> \u003Cspan class=\"token keyword\">default\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n  \u003Cspan class=\"token literal-property property\">druxt\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n    \u003Cspan class=\"token literal-property property\">cache\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan> \u003Cspan class=\"token literal-property property\">content\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token boolean\">true\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan> \u003Cspan class=\"token literal-property property\">secret\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> process\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>env\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>\u003Cspan class=\"token constant\">DRUXT_CACHE_SECRET\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n\u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>",{"type":24,"html":342},"\u003Cp>On a local copy of druxtjs.org, a warm docs page went from 11 calls to Drupal to none. A cold crawl, every page visited once, went from 438 calls on 0.24.0 to 157, a third of them on the first page, where schema refresh reads Drupal's displays. Only successful anonymous responses are kept, so a signed-in visitor still reaches Drupal, and a page published after someone saw its 404 shows straight away.\u003C\u002Fp>",{"type":24,"html":344},"\u003Cp>Content changes far more often than menus, so pair it with the clear from Drupal, below. Each cache area holds at most 50 MB of stored JSON per consumer by default, set by \u003Ccode>cache.maxSize\u003C\u002Fcode>, and can take about three times that in memory, so size it against your Nuxt process.\u003C\u002Fp>",{"type":19,"layout":20,"regions":346,"title":371},{"content":347},[348,350,353,355,357,361,365,369],{"type":24,"html":349},"\u003Cp>Five minutes is a long time to wait for a menu edit, so Drupal can clear the cache the moment content changes. Give Druxt a secret:\u003C\u002Fp>",{"type":43,"title":107,"code":351,"language":339,"highlighted":352},"export default {\n  druxt: {\n    cache: { secret: process.env.DRUXT_CACHE_SECRET },\n  },\n}","\u003Cspan class=\"token keyword\">export\u003C\u002Fspan> \u003Cspan class=\"token keyword\">default\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n  \u003Cspan class=\"token literal-property property\">druxt\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n    \u003Cspan class=\"token literal-property property\">cache\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan> \u003Cspan class=\"token literal-property property\">secret\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> process\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>env\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>\u003Cspan class=\"token constant\">DRUXT_CACHE_SECRET\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n\u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>",{"type":24,"html":354},"\u003Cp>A server-rendered production build then has a clear endpoint, \u003Ccode>POST \u002F_druxt\u002Fcache\u002Fclear\u003C\u002Fcode>, locked to the secret in an \u003Ccode>X-Druxt-Secret\u003C\u002Fcode> header. Each Nuxt process keeps its own cache, so a site running several clears each one. List Druxt, or a module that adds it such as \u003Ccode>druxt-site\u003C\u002Fcode>, under \u003Ccode>modules\u003C\u002Fcode> rather than \u003Ccode>buildModules\u003C\u002Fcode>, or the endpoint answers 404 under \u003Ccode>nuxt start\u003C\u002Fcode>.\u003C\u002Fp>",{"type":24,"html":356},"\u003Cp>On druxtjs.org, Drupal's Purge module sends that POST whenever a cache tag is invalidated, so a saved menu link reaches the frontend on the next request. It clears Druxt's cache only, so a CDN in front of Drupal needs its own purger, and a \u003Ccode>drush cr\u003C\u002Fcode> doesn't send it.\u003C\u002Fp>",{"type":43,"title":358,"code":359,"language":45,"highlighted":360},"Purge, sending the clear to Nuxt","drush -y pm:install purge purge_ui purge_purger_http \\\n  purge_queuer_coretags purge_processor_lateruntime\ndrush p:purger-add httpbundled   # note the purger ID it prints\n# At Configuration > Development > Performance > Purge, set the purger to\n# Nuxt's host and port, path \u002F_druxt\u002Fcache\u002Fclear, method POST,\n# invalidation type tag, and a header X-Druxt-Secret.","drush \u003Cspan class=\"token parameter variable\">-y\u003C\u002Fspan> pm:install purge purge_ui purge_purger_http \u003Cspan class=\"token punctuation\">\\\u003C\u002Fspan>\n  purge_queuer_coretags purge_processor_lateruntime\ndrush p:purger-add httpbundled   \u003Cspan class=\"token comment\"># note the purger ID it prints\u003C\u002Fspan>\n\u003Cspan class=\"token comment\"># At Configuration > Development > Performance > Purge, set the purger to\u003C\u002Fspan>\n\u003Cspan class=\"token comment\"># Nuxt's host and port, path \u002F_druxt\u002Fcache\u002Fclear, method POST,\u003C\u002Fspan>\n\u003Cspan class=\"token comment\"># invalidation type tag, and a header X-Druxt-Secret.\u003C\u002Fspan>",{"type":43,"title":362,"code":363,"language":54,"highlighted":364},"settings.php, keeping the secret out of exported config","$config['purge_purger_http.settings.PURGER_ID']['headers'][0]['value'] = getenv('DRUXT_CACHE_SECRET');","\u003Cspan class=\"token variable\">$config\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'purge_purger_http.settings.PURGER_ID'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'headers'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token number\">0\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'value'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan> \u003Cspan class=\"token operator\">=\u003C\u002Fspan> \u003Cspan class=\"token function\">getenv\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">(\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'DRUXT_CACHE_SECRET'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">)\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">;\u003C\u002Fspan>",{"type":43,"title":366,"code":367,"language":45,"highlighted":368},"Check the endpoint, expecting 204","curl -s -o \u002Fdev\u002Fnull -w '%{http_code}\\n' -X POST \\\n  -H \"X-Druxt-Secret: your-secret\" http:\u002F\u002Fnuxt:3000\u002F_druxt\u002Fcache\u002Fclear","\u003Cspan class=\"token function\">curl\u003C\u002Fspan> \u003Cspan class=\"token parameter variable\">-s\u003C\u002Fspan> \u003Cspan class=\"token parameter variable\">-o\u003C\u002Fspan> \u002Fdev\u002Fnull \u003Cspan class=\"token parameter variable\">-w\u003C\u002Fspan> \u003Cspan class=\"token string\">'%{http_code}\\n'\u003C\u002Fspan> \u003Cspan class=\"token parameter variable\">-X\u003C\u002Fspan> POST \u003Cspan class=\"token punctuation\">\\\u003C\u002Fspan>\n  \u003Cspan class=\"token parameter variable\">-H\u003C\u002Fspan> \u003Cspan class=\"token string\">\"X-Druxt-Secret: your-secret\"\u003C\u002Fspan> http:\u002F\u002Fnuxt:3000\u002F_druxt\u002Fcache\u002Fclear",{"type":24,"html":370},"\u003Cp>Every tag invalidation clears the whole cache, which is blunt but cheap. The \u003Ca href=\"https:\u002F\u002Fdruxtjs.org\u002Fhow-to\u002Fcaching#clear-on-save-with-purge\">caching guide\u003C\u002Fa> has the details.\u003C\u002Fp>","Clearing it from Drupal",{"type":19,"layout":20,"regions":373,"title":383},{"content":374},[375,377,381],{"type":24,"html":376},"\u003Cp>Druxt builds its schemas, which fields a component renders and in what order, from Manage display at build time, so moving a field meant a rebuild. With \u003Ccode>schema.refresh\u003C\u002Fcode> on, the server regenerates them after each clear: move a field, save, and the next page has it.\u003C\u002Fp>",{"type":43,"title":378,"code":379,"language":339,"highlighted":380},"nuxt.config.js, with schema refresh","export default {\n  modules: ['druxt-site'], \u002F\u002F or druxt-schema, not buildModules\n  druxt: {\n    cache: { secret: process.env.DRUXT_CACHE_SECRET },\n    schema: { refresh: true },\n  },\n}","\u003Cspan class=\"token keyword\">export\u003C\u002Fspan> \u003Cspan class=\"token keyword\">default\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n  \u003Cspan class=\"token literal-property property\">modules\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string\">'druxt-site'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan> \u003Cspan class=\"token comment\">\u002F\u002F or druxt-schema, not buildModules\u003C\u002Fspan>\n  \u003Cspan class=\"token literal-property property\">druxt\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n    \u003Cspan class=\"token literal-property property\">cache\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan> \u003Cspan class=\"token literal-property property\">secret\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> process\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>env\u003Cspan class=\"token punctuation\">.\u003C\u002Fspan>\u003Cspan class=\"token constant\">DRUXT_CACHE_SECRET\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n    \u003Cspan class=\"token literal-property property\">schema\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan> \u003Cspan class=\"token literal-property property\">refresh\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token boolean\">true\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n\u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>",{"type":24,"html":382},"\u003Cp>The first page after a clear pays for it, with about two dozen configuration reads on druxtjs.org, and every page after that is back to normal. The server generates with the build's access to Drupal, so those credentials need to be in production too. A static site can run the same refresh from its own Node server with \u003Ccode>createSchemaRefresh\u003C\u002Fcode>, which the \u003Ca href=\"https:\u002F\u002Fdruxtjs.org\u002Fhow-to\u002Fcaching#schemas-after-a-display-change\">caching guide\u003C\u002Fa> covers.\u003C\u002Fp>","Display changes without a rebuild",{"type":19,"layout":20,"regions":385,"title":391},{"content":386},[387,389],{"type":24,"html":388},"\u003Cp>Over a network that adds up. On a local copy of druxtjs.org, alternating the versions page by page, 0.25.0 rendered warm pages nearly a third faster than 0.24.0 in all 66 paired visits, and in about a sixth of 0.24.0's time with content caching on. Inside a hosting cluster, where Drupal is a couple of milliseconds away, the time saved is small, and the win is Drupal answering fewer requests. Put a proxy in front of your own Drupal and count: how many times does your site ask the same question?\u003C\u002Fp>",{"type":24,"html":390},"\u003Cp>Drupal is getting quicker too. ptmkenny's core patch in \u003Ca href=\"https:\u002F\u002Fwww.drupal.org\u002Fproject\u002Fdrupal\u002Fissues\u002F3626255\">#3626255\u003C\u002Fa> reads JSON:API's cached normalizations in bulk. On the docs collection behind druxtjs.org's sitemap, with normalizations warm, it took 1301 database queries down to 77 with identical responses. It needs reviews, and the issue covers running it on 11.x.\u003C\u002Fp>","What it saves",{"type":19,"layout":20,"regions":393,"title":397},{"content":394},[395],{"type":24,"html":396},"\u003Cul>\u003Cli>No more first-load flicker. Druxt components no longer fetch their data again when a production build hydrates, so any \u003Ccode>components:extend\u003C\u002Fcode> hook you added to hide it can go.\u003C\u002Fli>\u003Cli>Pages don't jump on a refetch, because components keep their content while they fetch.\u003C\u002Fli>\u003Cli>Views follow the language you asked for, not the site default.\u003C\u002Fli>\u003Cli>A 403 or 404 from a route renders through Nuxt's own error page.\u003C\u002Fli>\u003Cli>Vue and Vuex are peer dependencies, so a site uses its own copy, and a mismatched second copy can't stop Nuxt at startup.\u003C\u002Fli>\u003C\u002Ful>","Also in 0.25.0",{"type":19,"layout":20,"regions":399,"title":409},{"content":400},[401,403,407],{"type":24,"html":402},"\u003Cp>The packages version together, so move them in one go:\u003C\u002Fp>",{"type":43,"title":404,"code":405,"language":45,"highlighted":406},"Upgrade the set together","# Drop any druxt package your site doesn't use\nnpm install druxt@^0.25.0 druxt-site@^0.15.0 druxt-router@^0.32.0 \\\n  druxt-entity@^0.29.0 druxt-menu@^0.22.0 druxt-blocks@^0.18.0 \\\n  druxt-views@^0.23.0 druxt-breadcrumb@^0.18.0 druxt-schema@^0.12.0 \\\n  axios@^0.34.0\n# Still on Vue 2.6? Add vue@^2.7.14 vuex@^3.6.2\n# Using druxt-auth? Take it to 0.5.1 in the same install.","\u003Cspan class=\"token comment\"># Drop any druxt package your site doesn't use\u003C\u002Fspan>\n\u003Cspan class=\"token function\">npm\u003C\u002Fspan> \u003Cspan class=\"token function\">install\u003C\u002Fspan> druxt@^0.25.0 druxt-site@^0.15.0 druxt-router@^0.32.0 \u003Cspan class=\"token punctuation\">\\\u003C\u002Fspan>\n  druxt-entity@^0.29.0 druxt-menu@^0.22.0 druxt-blocks@^0.18.0 \u003Cspan class=\"token punctuation\">\\\u003C\u002Fspan>\n  druxt-views@^0.23.0 druxt-breadcrumb@^0.18.0 druxt-schema@^0.12.0 \u003Cspan class=\"token punctuation\">\\\u003C\u002Fspan>\n  axios@^0.34.0\n\u003Cspan class=\"token comment\"># Still on Vue 2.6? Add vue@^2.7.14 vuex@^3.6.2\u003C\u002Fspan>\n\u003Cspan class=\"token comment\"># Using druxt-auth? Take it to 0.5.1 in the same install.\u003C\u002Fspan>",{"type":24,"html":408},"\u003Cp>The deprecated \u003Ccode>DruxtField\u003C\u002Fcode> components are off unless you set \u003Ccode>druxt.entity.components.fields: true\u003C\u002Fcode>, and a \u003Ccode>consola\u003C\u002Fcode> 2.15.3 pin can go. Wrapper components no longer render module data as HTML attributes, so \u003Ccode>entity=\"[object Object]\"\u003C\u002Fcode> is gone from your markup. Wrappers still read it from \u003Ccode>$attrs\u003C\u002Fcode>. The rest is in the \u003Ca href=\"https:\u002F\u002Fdruxtjs.org\u002Fapi\u002Fpackages\u002Fdruxt\u002FCHANGELOG\">changelog on druxtjs.org\u003C\u002Fa>.\u003C\u002Fp>","Upgrading",{"type":19,"layout":20,"title":411,"regions":412},"Why 0.25.0, and not Nuxt 4?",{"content":413},[414,416],{"type":24,"html":415},"\u003Cp>Nuxt 2 is end of life, so it is a fair question. Druxt had years of improvements and fixes sitting unreleased, and I would rather finish them than carry them across half done. 0.25.0 gets that work out, and gives the 2.0.0 port a stable, fast baseline to start from. Get it right on the stack it runs on today, then upgrade, then add new features, then polish.\u003C\u002Fp>",{"type":24,"html":417},"\u003Cp>There is one more 0.x release planned before 1.0.0. 0.26.0 is mostly waiting on a \u003Ca href=\"https:\u002F\u002Fwww.drupal.org\u002Fproject\u002Fdecoupled_router\u002Fissues\u002F3111456\">decoupled router patch\u003C\u002Fa>, so a site whose default language has no URL prefix stops redirecting its front page to \u003Ccode>\u002Fen\u003C\u002Fcode>, plus whatever other multilingual fixes turn up before then. Then 1.0.0, the last Druxt on Nuxt 2, with Druxt 2.0.0, the Nuxt 4 rewrite under \u003Ccode>@druxt\u003C\u002Fcode>, on track for a first beta alongside it, building on the caching model 0.25.0 settled on.\u003C\u002Fp>",{"type":19,"layout":20,"title":419,"regions":420},"Experimenting with WebSockets",{"content":421},[422,424],{"type":24,"html":423},"\u003Cp>Something to play with while you wait. \u003Ca href=\"https:\u002F\u002Fumami.demo.druxtjs.org\u002Fplay\">Umami Go\u003C\u002Fa> is a multiplayer card game on the Umami demo, dealt from the magazine's recipes and rendered by Druxt. It is also where you can try the upcoming Druxt WebSockets module, still in development: edit a recipe in Drupal, and it changes in every hand holding it.\u003C\u002Fp>",{"type":27,"src":425,"alt":426,"caption":427,"width":300,"height":301},"\u002Fimages\u002Fwriting\u002Fdruxt-0250-caching-and-performance-buffs-20261023\u002Fumami-go.jpg","The Umami Go page: the title Umami Go in white italic serif on a dark brown lattice, the line 'A card game for two to five, dealt from this magazine's recipes', a Start a table button with a box to join by code, a fan of five recipe cards, and a 'Built on Druxt' badge with the Druxt icon.","Umami Go, a card game dealt from Drupal's Umami recipes, with a live table over the upcoming Druxt WebSockets module.",{"type":19,"layout":20,"regions":429},{"content":430},[431],{"type":139,"description":432,"url":433,"gitpod":142,"drupalUrl":434},"\u003Cp>Druxt 1.0.0 and 2.x move as fast as sponsorship lets them.\u003C\u002Fp>","https:\u002F\u002Fgithub.com\u002Fdruxt\u002Fdruxt.js","https:\u002F\u002Fwww.drupal.org\u002Fproject\u002Fdruxt","7 min",[437,438,440],{"path":272,"source":159},{"path":439,"source":162},"\u002Fwriting\u002Fjsonapi-views-120-20260812",{"path":441,"source":162},"\u002Fwriting\u002Fdruxt-auth-050-two-ways-to-sign-in-20260926",{"loc":164},"articles-data\u002Fdruxt-0250-caching-and-performance-buffs-20261023",[445,169,446],"caching","Nuxt","edV1vBUxLvz0cphWthoinHz6wGs9sGxURbqR767Wg8k",{"id":449,"title":450,"articleType":6,"categories":451,"date":452,"description":453,"extension":12,"feedImage":142,"hiddenCategories":454,"meta":455,"paragraphs":456,"path":161,"readingTime":155,"related":578,"sitemap":582,"stem":583,"topics":584,"__hash__":585},"articleEntries\u002Farticles-data\u002Fvale-100-prose-linting-drupal-20261028.json","Vale 1.0.0; prose linting for Drupal",[8],"2026-10-28T16:20:00+11:00","Vale (for Drupal) holds every validated save to your style guide, from the node form or over JSON:API, and refuses the ones that break it.",[15],{},[457,464,470,495,509,527,543,556,566,573],{"type":19,"layout":20,"regions":458},{"content":459},[460,462],{"type":24,"html":461},"\u003Cp>Drupal is 25 this year, and its code has had linters for a long time. Its words haven't, and with Drupal AI writing straight into the node form, more of them now come from a model. A search of drupal.org turns up readability scores and spellcheckers, but nothing that holds a save to your own style guide.\u003C\u002Fp>",{"type":24,"html":463},"\u003Cp>Vale (for Drupal) does that with the \u003Ca href=\"https:\u002F\u002Fvale.sh\">Vale\u003C\u002Fa> prose linter. Break an error-level rule and the save is refused, or only the publish if drafts should save freely. \u003Ccode>1.0.0\u003C\u002Fcode> is out now for Drupal 10.3, 11 and 12, and needs the Vale binary on your server.\u003C\u002Fp>",{"type":19,"title":465,"layout":20,"regions":466},"Doesn't CI already do this?",{"content":467},[468],{"type":24,"html":469},"\u003Cp>Vale in CI is the right tool for Markdown in a repository. Content in Drupal arrives through a save instead: the node form, a Fully Decoupled frontend over JSON:API, a workspace being published. The module checks there, on the server, whatever editor the author used.\u003C\u002Fp>",{"type":19,"title":471,"layout":20,"regions":472},"Setting it up",{"content":473},[474,476,480,482,485,487,489],{"type":24,"html":475},"\u003Cp>\u003Ccode>drush vale:install\u003C\u002Fcode> puts the binary in \u003Ccode>bin\u002F\u003C\u002Fcode> beside your web root, checked against its published SHA-256:\u003C\u002Fp>",{"type":43,"title":477,"code":478,"language":45,"highlighted":479},"Install the module and Vale, on a Composer site with Drush","composer require drupal\u002Fvale\ndrush en vale\ndrush vale:install","\u003Cspan class=\"token function\">composer\u003C\u002Fspan> require drupal\u002Fvale\ndrush en vale\ndrush vale:install",{"type":24,"html":481},"\u003Cp>Write a \u003Ccode>.vale.ini\u003C\u002Fcode>, run \u003Ccode>bin\u002Fvale sync\u003C\u002Fcode> beside it, deploy the \u003Ccode>styles\u003C\u002Fcode> directory with the site, and point \u003Ccode>settings.php\u003C\u002Fcode> at both:\u003C\u002Fp>",{"type":43,"title":53,"language":54,"code":483,"highlighted":484},"$settings['vale_binary'] = $app_root . '\u002F..\u002Fbin\u002Fvale';\n$settings['vale_config'] = $app_root . '\u002F..\u002F.vale.ini';\n\n\u002F\u002F Optional.\n$config['vale.settings']['publish_only'] = TRUE;\n$config['vale.settings']['workspace_mode'] = 'block';\n\u002F\u002F Keyed by your Markdown text format's machine name.\n$config['vale.settings']['formats'] = ['markdown' => 'md'];","\u003Cspan class=\"token variable\">$settings\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'vale_binary'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan> \u003Cspan class=\"token operator\">=\u003C\u002Fspan> \u003Cspan class=\"token variable\">$app_root\u003C\u002Fspan> \u003Cspan class=\"token operator\">.\u003C\u002Fspan> \u003Cspan class=\"token string single-quoted-string\">'\u002F..\u002Fbin\u002Fvale'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">;\u003C\u002Fspan>\n\u003Cspan class=\"token variable\">$settings\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'vale_config'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan> \u003Cspan class=\"token operator\">=\u003C\u002Fspan> \u003Cspan class=\"token variable\">$app_root\u003C\u002Fspan> \u003Cspan class=\"token operator\">.\u003C\u002Fspan> \u003Cspan class=\"token string single-quoted-string\">'\u002F..\u002F.vale.ini'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">;\u003C\u002Fspan>\n\n\u003Cspan class=\"token comment\">\u002F\u002F Optional.\u003C\u002Fspan>\n\u003Cspan class=\"token variable\">$config\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'vale.settings'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'publish_only'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan> \u003Cspan class=\"token operator\">=\u003C\u002Fspan> \u003Cspan class=\"token constant boolean\">TRUE\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">;\u003C\u002Fspan>\n\u003Cspan class=\"token variable\">$config\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'vale.settings'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'workspace_mode'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan> \u003Cspan class=\"token operator\">=\u003C\u002Fspan> \u003Cspan class=\"token string single-quoted-string\">'block'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">;\u003C\u002Fspan>\n\u003Cspan class=\"token comment\">\u002F\u002F Keyed by your Markdown text format's machine name.\u003C\u002Fspan>\n\u003Cspan class=\"token variable\">$config\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'vale.settings'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'formats'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan> \u003Cspan class=\"token operator\">=\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\u003Cspan class=\"token string single-quoted-string\">'markdown'\u003C\u002Fspan> \u003Cspan class=\"token operator\">=>\u003C\u002Fspan> \u003Cspan class=\"token string single-quoted-string\">'md'\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">;\u003C\u002Fspan>",{"type":24,"html":486},"\u003Cp>Then tick \u003Cem>Check with Vale\u003C\u002Fem> under \u003Cem>Prose checks\u003C\u002Fem> in a text field's settings, and the Status report confirms it.\u003C\u002Fp>",{"type":24,"html":488},"\u003Cp>Each field can set a minimum level, or change any rule's level or turn it off. With publish-only mode on, drafts save freely and the publish is held to the rules, following Content Moderation when it's installed.\u003C\u002Fp>",{"type":27,"alt":490,"caption":491,"width":492,"height":493,"src":494},"A Drupal edit form after a refused save. Two warnings ending \"Blocks publishing\" sit above an error that names the ai-tells HedgingPhrases and AICompoundPhrases rules, the line and the matched text.","\u003Cp>On druxtjs.org: a hedge and a cliché in a text paragraph, and the publish refused until they go.\u003C\u002Fp>",1015,455,"\u002Fimages\u002Fwriting\u002Fvale-100-prose-linting-drupal-20261028\u002Fvale-druxtjs-publish-refused.png",{"type":19,"title":496,"layout":20,"regions":497},"Writing your own rules",{"content":498},[499,501,505,507],{"type":24,"html":500},"\u003Cp>Published styles are a start, but your own rules matter most: product names, words your organisation avoids, phrases your editors overuse. Each rule is a short YAML file in a folder under \u003Ccode>styles\u003C\u002Fcode>, kept in your codebase and deployed with the site:\u003C\u002Fp>",{"type":43,"title":502,"language":73,"code":503,"highlighted":504},"styles\u002FHouse\u002FTerms.yml","extends: substitution\nmessage: \"Use '%s' instead of '%s'.\"\nlevel: error\nignorecase: false\nswap:\n  drupal: Drupal\n  headless: Fully Decoupled","\u003Cspan class=\"token key atrule\">extends\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">:\u003C\u002Fspan> substitution\n\u003Cspan class=\"token key atrule\">message\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"Use '%s' instead of '%s'.\"\u003C\u002Fspan>\n\u003Cspan class=\"token key atrule\">level\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">:\u003C\u002Fspan> error\n\u003Cspan class=\"token key atrule\">ignorecase\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">:\u003C\u002Fspan> \u003Cspan class=\"token boolean important\">false\u003C\u002Fspan>\n\u003Cspan class=\"token key atrule\">swap\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">:\u003C\u002Fspan>\n  \u003Cspan class=\"token key atrule\">drupal\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">:\u003C\u002Fspan> Drupal\n  \u003Cspan class=\"token key atrule\">headless\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">:\u003C\u002Fspan> Fully Decoupled",{"type":24,"html":506},"\u003Cp>Add \u003Ccode>House\u003C\u002Fcode> to \u003Ccode>BasedOnStyles\u003C\u002Fcode> in \u003Ccode>.vale.ini\u003C\u002Fcode>, and a save with \"headless drupal\" in it is refused. \u003Ccode>drush vale:check node.article.body \"Some text.\"\u003C\u002Fcode> tries a rule against a field before an editor meets it. Levels are the site builder's call, so start a new rule at warning and promote it once you trust it.\u003C\u002Fp>",{"type":24,"html":508},"\u003Cp>Vale's \u003Ca href=\"https:\u002F\u002Fvale.sh\u002Fdocs\u002Fstyles\">styles documentation\u003C\u002Fa> covers the other checks, from banned words to sentence length, and you can browse published styles in \u003Ca href=\"https:\u002F\u002Fvale.sh\u002Fexplorer\">Vale's explorer\u003C\u002Fa>. The rules this post was held to are in this site's \u003Ca href=\"https:\u002F\u002Fgithub.com\u002FDecipher\u002Fstuar.tc\u002Fblob\u002Fmain\u002F.vale.ini\">\u003Ccode>.vale.ini\u003C\u002Fcode>\u003C\u002Fa>.\u003C\u002Fp>",{"type":19,"title":510,"layout":20,"regions":511},"Over JSON:API",{"content":512},[513,515,519,521,525],{"type":24,"html":514},"\u003Cp>This is the part I built it for. On a Fully Decoupled site, a \u003Ca href=\"https:\u002F\u002Fdruxtjs.org\">Druxt\u003C\u002Fa> or Nuxt frontend writes over JSON:API, and the check runs in the entity's validation, which those writes go through as well as the form. Post a body with a repeated word, with Vale's built-in style on, and you get a 422 that points at the field:\u003C\u002Fp>",{"type":43,"title":516,"code":517,"language":12,"highlighted":518},"POST \u002Fjsonapi\u002Fnode\u002Farticle, with \"Check the the docs.\" in the body","{\n  \"errors\": [\n    {\n      \"title\": \"Unprocessable Content\",\n      \"status\": \"422\",\n      \"detail\": \"body.0.value: Body: Vale.Repetition: 'the' is repeated! (line 1: \\\"the the\\\")\",\n      \"source\": {\n        \"pointer\": \"\u002Fdata\u002Fattributes\u002Fbody\u002Fvalue\"\n      }\n    }\n  ]\n}","\u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n  \u003Cspan class=\"token property\">\"errors\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\n    \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"title\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"Unprocessable Content\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"status\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"422\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"detail\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"body.0.value: Body: Vale.Repetition: 'the' is repeated! (line 1: \\\"the the\\\")\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"source\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n        \u003Cspan class=\"token property\">\"pointer\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"\u002Fdata\u002Fattributes\u002Fbody\u002Fvalue\"\u003C\u002Fspan>\n      \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\n    \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\n  \u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\n\u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>",{"type":24,"html":520},"\u003Cp>Several errors in one value share that entry, one per line of \u003Ccode>detail\u003C\u002Fcode>, each naming its field. With publish-only mode on, a refused publish comes back on the entity, at \u003Ccode>\u002Fdata\u003C\u002Fcode>, still naming the field. Warnings and suggestions come back in \u003Ccode>meta.vale\u003C\u002Fcode>, on a refused write or a successful one, with \u003Ccode>checked\u003C\u002Fcode> false if Vale couldn't run:\u003C\u002Fp>",{"type":43,"title":522,"language":12,"code":523,"highlighted":524},"meta.vale on a 201, with a warning","{\n  \"meta\": {\n    \"vale\": {\n      \"checked\": true,\n      \"alerts\": [\n        {\n          \"entity_type\": \"node\",\n          \"bundle\": \"article\",\n          \"id\": \"a22048fc-b0ec-4a9b-a80b-14be85498d81\",\n          \"field\": \"body\",\n          \"delta\": 0,\n          \"rule\": \"Microsoft.Adverbs\",\n          \"level\": \"warning\",\n          \"message\": \"Remove 'very' if it's not important to the meaning of the statement.\",\n          \"line\": 1,\n          \"start\": 9,\n          \"end\": 12,\n          \"match\": \"very\",\n          \"description\": \"\",\n          \"link\": \"https:\u002F\u002Flearn.microsoft.com\u002Fen-us\u002Fstyle-guide\u002Fword-choice\u002Fuse-simple-words-concise-sentences\"\n        }\n      ]\n    }\n  }\n}","\u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n  \u003Cspan class=\"token property\">\"meta\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n    \u003Cspan class=\"token property\">\"vale\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"checked\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token boolean\">true\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"alerts\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\n        \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"entity_type\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"node\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"bundle\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"article\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"id\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"a22048fc-b0ec-4a9b-a80b-14be85498d81\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"field\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"body\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"delta\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token number\">0\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"rule\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"Microsoft.Adverbs\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"level\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"warning\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"message\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"Remove 'very' if it's not important to the meaning of the statement.\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"line\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token number\">1\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"start\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token number\">9\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"end\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token number\">12\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"match\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"very\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"description\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n          \u003Cspan class=\"token property\">\"link\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"https:\u002F\u002Flearn.microsoft.com\u002Fen-us\u002Fstyle-guide\u002Fword-choice\u002Fuse-simple-words-concise-sentences\"\u003C\u002Fspan>\n        \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\n      \u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\n    \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\n  \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\n\u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>",{"type":24,"html":526},"\u003Cp>So a Fully Decoupled editor gets the same feedback as the node form, without rebuilding the rules in JavaScript.\u003C\u002Fp>",{"type":19,"title":528,"layout":20,"regions":529},"Checking before the save",{"content":530},[531,533,537,541],{"type":24,"html":532},"\u003Cp>\u003Ccode>POST \u002Fvale\u002Fcheck\u003C\u002Fcode> returns the alerts a field would get, with the sentence around each, and saves nothing. It's built for an editor that checks as you type, in Druxt or anywhere else.\u003C\u002Fp>",{"type":43,"title":534,"language":12,"code":535,"highlighted":536},"POST \u002Fvale\u002Fcheck","{\n  \"entity_type\": \"node\",\n  \"bundle\": \"article\",\n  \"field\": \"body\",\n  \"format\": \"basic_html\",\n  \"text\": \"\u003Cp>Check the the docs. This step is very easy to miss.\u003C\u002Fp>\"\n}","\u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n  \u003Cspan class=\"token property\">\"entity_type\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"node\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"token property\">\"bundle\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"article\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"token property\">\"field\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"body\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"token property\">\"format\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"basic_html\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"token property\">\"text\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"&lt;p>Check the the docs. This step is very easy to miss.&lt;\u002Fp>\"\u003C\u002Fspan>\n\u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>",{"type":43,"title":538,"language":12,"code":539,"highlighted":540},"The response","{\n  \"checked\": true,\n  \"publish_only\": true,\n  \"alerts\": [\n    {\n      \"rule\": \"Vale.Repetition\",\n      \"level\": \"error\",\n      \"message\": \"'the' is repeated!\",\n      \"match\": \"the the\",\n      \"sentence\": \"Check the the docs.\"\n    },\n    {\n      \"rule\": \"Microsoft.Adverbs\",\n      \"level\": \"warning\",\n      \"message\": \"Remove 'very' if it's not important to the meaning of the statement.\",\n      \"match\": \"very\",\n      \"sentence\": \"This step is very easy to miss.\",\n      \"link\": \"https:\u002F\u002Flearn.microsoft.com\u002Fen-us\u002Fstyle-guide\u002Fword-choice\u002Fuse-simple-words-concise-sentences\"\n    }\n  ]\n}","\u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n  \u003Cspan class=\"token property\">\"checked\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token boolean\">true\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"token property\">\"publish_only\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token boolean\">true\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n  \u003Cspan class=\"token property\">\"alerts\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token punctuation\">[\u003C\u002Fspan>\n    \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"rule\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"Vale.Repetition\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"level\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"error\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"message\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"'the' is repeated!\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"match\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"the the\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"sentence\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"Check the the docs.\"\u003C\u002Fspan>\n    \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n    \u003Cspan class=\"token punctuation\">{\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"rule\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"Microsoft.Adverbs\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"level\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"warning\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"message\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"Remove 'very' if it's not important to the meaning of the statement.\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"match\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"very\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"sentence\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"This step is very easy to miss.\"\u003C\u002Fspan>\u003Cspan class=\"token punctuation\">,\u003C\u002Fspan>\n      \u003Cspan class=\"token property\">\"link\"\u003C\u002Fspan>\u003Cspan class=\"token operator\">:\u003C\u002Fspan> \u003Cspan class=\"token string\">\"https:\u002F\u002Flearn.microsoft.com\u002Fen-us\u002Fstyle-guide\u002Fword-choice\u002Fuse-simple-words-concise-sentences\"\u003C\u002Fspan>\n    \u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>\n  \u003Cspan class=\"token punctuation\">]\u003C\u002Fspan>\n\u003Cspan class=\"token punctuation\">}\u003C\u002Fspan>",{"type":24,"html":542},"\u003Cp>A caller needs the \u003Cem>Use the Vale check endpoint\u003C\u002Fem> permission and edit access to the field, and a cookie session sends an \u003Ccode>X-CSRF-Token\u003C\u002Fcode>.\u003C\u002Fp>",{"type":19,"title":544,"layout":20,"regions":545},"Before a workspace goes live",{"content":546},[547,549,551],{"type":24,"html":548},"\u003Cp>Before a workspace is published, the module checks everything in it. With \u003Ccode>workspace_mode\u003C\u002Fcode> set to \u003Ccode>block\u003C\u002Fcode>, the publish is refused while anything has an error or a value can't be checked, even though an ordinary save goes through when Vale can't run. The default, \u003Ccode>advise\u003C\u002Fcode>, publishes and lists the findings.\u003C\u002Fp>",{"type":24,"html":550},"\u003Cp>Publish-only mode and workspaces suit each other. Inside a workspace every save is a draft, including an edit to a page that's already live, so editors save freely and the workspace's publish is where the rules apply, in \u003Ccode>advise\u003C\u002Fcode> mode as well as \u003Ccode>block\u003C\u002Fcode>. \u003Ccode>drush vale:lint --workspace=stage\u003C\u002Fcode> runs the same check from a deploy script.\u003C\u002Fp>",{"type":27,"alt":552,"caption":553,"width":30,"height":554,"src":555},"Two Drupal messages after a refused workspace publish. The error lists a Vale.Repetition and a Microsoft.Contractions finding on the body of a node called Release notes, then a summary: Stage, 2 prose errors, 1 warning or suggestion. A warning below lists a Microsoft.Adverbs finding.","\u003Cp>Publishing the Stage workspace, refused: two errors in a draft, and the warning listed beside them.\u003C\u002Fp>",393,"\u002Fimages\u002Fwriting\u002Fvale-100-prose-linting-drupal-20261028\u002Fvale-workspace-publish-refused.png",{"type":19,"title":557,"layout":20,"regions":558},"Before you install",{"content":559},[560,562,564],{"type":24,"html":561},"\u003Cp>Vale ships as one binary, and it has to be on every web server and in the CLI container. \u003Ccode>drush vale:install\u003C\u002Fcode> covers Linux and macOS. On a container host, build it into every image that serves Drupal or runs Drush, as druxtjs.org does on Lagoon, and add \u003Ccode>gcompat\u003C\u002Fcode> on Alpine. Other managed hosts are \u003Ca href=\"https:\u002F\u002Fgithub.com\u002FDecipher\u002Fvale\u002Fissues\u002F33\">on the 1.1.0 list\u003C\u002Fa>.\u003C\u002Fp>",{"type":24,"html":563},"\u003Cp>On a test site, Vale added about 55 ms to an uncached JSON:API save of a short paragraph, and nothing when the text hadn't changed, because results are cached until the text or the styles do. If Vale is missing or times out, the content saves and the author is told it wasn't checked, unless you set \u003Ccode>fail_closed\u003C\u002Fcode>.\u003C\u002Fp>",{"type":24,"html":565},"\u003Cp>For saves that skip validation, like a migration, \u003Ccode>drush vale:lint\u003C\u002Fcode> checks the content afterwards and exits with 1, so a deploy can depend on it. Any text field you opt in is checked, on a Drupal CMS site too. The title is a base field rather than one you opt in, and Canvas keeps its text in component props, so neither is checked.\u003C\u002Fp>",{"type":19,"layout":20,"regions":567},{"content":568},[569],{"type":139,"description":570,"url":571,"gitpod":142,"drupalUrl":572},"\u003Cp>The code is on GitHub, and the issue queue is on Drupal.org. If your editors rely on Vale, sponsoring is what funds the next release.\u003C\u002Fp>","https:\u002F\u002Fgithub.com\u002FDecipher\u002Fvale","https:\u002F\u002Fwww.drupal.org\u002Fproject\u002Fvale",{"type":19,"layout":20,"regions":574},{"content":575},[576],{"type":24,"html":577},"\u003Cp>Next up are hooks for other modules and summary fields, with AI-suggested rewrites further out. What would you want it to do for your editors?\u003C\u002Fp>",[579,580,581],{"path":158,"source":159},{"path":272,"source":162},{"path":164,"source":162},{"loc":161},"articles-data\u002Fvale-100-prose-linting-drupal-20261028",[168,169,170],"j9RsprwhDPhBA054YDuyzy48MF6UJb5DTPzeCzeKOpg",1794473903431]