BotID: BotIdClient’s inline script never executes on client-rendered pages — React dev warning + protected requests sent without x-is-human
Package: botid 1.5.11 (latest on npm as of 2026-07-09) Framework: Next.js 16.2.9 (App Router), React 19.2.4 Affected export: BotIdClient from botid/client
Summary
BotIdClient renders its entire client bootstrap as a single raw inline <script dangerouslySetInnerHTML> with no type attribute — from the end of node_modules/botid/dist/client/index.js (1.5.11, verbatim):
var K=({protect:f})=>_jsxruntime.jsx.call(void 0, _jsxruntime.Fragment,{
children:_jsxruntime.jsx.call(void 0, "script",{
dangerouslySetInnerHTML:{__html:`(${L.toString()})({ protect: ${JSON.stringify(f)} });`}
})
});
exports.BotIdClient = K;
(L is the serialized bootstrap that patches window.fetch and XMLHttpRequest so requests to protected routes carry BotID signals, e.g. the x-is-human header.)
This works whenever the HTML is server-rendered: the browser’s parser executes the inline script early, before app code fires any requests. But when the tree is client-rendered — React creates the <script> element itself instead of hydrating parser-created HTML — two things go wrong, because React deliberately neuters script elements it creates (it constructs them via the innerHTML trick, so they are flagged parser-inserted and never run):
-
Dev-console warning on every such page load. React logs, once per load:
Encountered a script tag while rendering React component. Scripts inside React components are never executed when rendering on the client. Consider using template tag instead ( <template> HTML content template element - HTML | MDN ).
React exempts scripts whose
typemarks them as a data block (e.g.application/ld+json) via itsisScriptDataBlockcheck — but BotID’s script is executable JS, so notypevalue can opt it out. -
Protection is silently absent on those pages. The bootstrap genuinely never executes, so
fetch/XHR are never patched. Every call to a protected route from such a page goes out withoutx-is-human/ BotID signals, and server-sidecheckBotId()behaves as ifBotIdClientweren’t mounted at all (that state is what produces its “Possible misconfiguration” server log).
When does client-render happen in practice?
In Next.js App Router, any page delivered via the __next_error__ shell — dev-mode 404s and error pages. The served HTML is just the bare shell (<html id="__next_error__">…) with no SSR’d layout markup, so the entire tree — root layout and BotIdClient included — mounts client-side and hits both failure modes above. The same mechanism applies to any other path where the component mounts without corresponding server-rendered HTML.
Reproduction (~2 minutes)
- Fresh Next.js 16 App Router app;
npm i botid@1.5.11; mount<BotIdClient protect={[…]} />in the root layout<head>per the quickstart at BotID . next dev- Load any non-existent URL (e.g.
http://localhost:3000/nope) in the browser → the console shows the React warning quoted above. curl -s http://localhost:3000/nope | head -c 300→ the response is the<html id="__next_error__">shell. Note the BotID inline script is absent from the served HTML — confirming React creates it during client render.- On that page,
window.fetchis unpatched; any request to a protected route carries nox-is-humanheader.
Why it matters
- The warning fires on every dev 404/error-page load and looks like an app bug — we spent a debugging session tracing it through React internals before pinning it on the type-less inline script in
botid/client. - The substantive half is silent: pages served via the error shell have no client-side BotID at all, and nothing in the browser indicates that.
Suggested fix
Make the component detect non-execution and recover:
- Have the inline bootstrap set a marker global as its first statement. (The public types already reserve
Window.IS_HUMAN_INITIALIZED, though nothing in the shipped 1.5.11 bootstrap sets it — any dedicated marker works.) - In a
useEffect, if the marker is absent — i.e. React created the script element and it never ran — re-inject the same payload viadocument.createElement('script')+appendChild. DOM-appended scripts execute normally, restoring protection on client-rendered pages, and they never pass through React’s warning path. - Optionally, skip rendering the JSX
<script>when mounting in a browser where the marker is absent (a client-render rather than a hydration pass). That removes the dev warning entirely while preserving the parser-executed fast path on server-rendered HTML.
Why we can’t work around it app-side
Conditionally unmounting BotIdClient (e.g. in dev) makes checkBotId() log “Possible misconfiguration” server-side on every protected call, so keeping it mounted unconditionally is the correct configuration — which means the fix has to live in the package.
Environment recap: botid 1.5.11 · next 16.2.9 · react / react-dom 19.2.4 · reproduced 2026-07-09.
Addendum (2026-07-09, after Vercel support’s first reply)
Support pointed out the documented alternative for Next.js ≥ 15.3: initBotId() from botid/client/core in instrumentation-client.ts. We verified this against the shipped 1.5.11 package (the export exists, and the package README indeed recommends it, relegating <BotIdClient/> to “Next.js < 15.3”), and it resolves both symptoms app-side — instrumentation-client runs before hydration, including on error-shell loads, so the fetch/XHR patch installs without React ever creating a script element.
That narrows but does not void this report:
<BotIdClient/>remains the documented path for Next.js < 15.3 (and the component is exported without any version gate or warning) — those users still get the silent protection gap on client-rendered pages, plus the dev warning. The marker +appendChildfallback suggested above still applies.- Failing silently is the sharp edge: when the inline script doesn’t run, nothing in the browser indicates protection is absent. If fixing the component isn’t planned, a runtime
console.warnwhen it mounts without executing — or deprecating it for ≥ 15.3 in docs and types — would prevent the next team from tracing this through React internals.