Installing the widget
One script tag, what it costs, and how to confirm it is live.
The tag
<script async src="https://brokenhere.com/w.js" data-wq-key="wq_a7k9vd9pnw5imawe"></script>Put it before </body> on every page you want reportable. Keep async,
because the widget must never delay your page.
The key is public. It sits in a script tag on your website where anyone can read it, so treat it as an identifier and not as a secret. Allowed origins are what stop it being abused.
If a tag attribute is awkward in your setup, the key can go in the URL instead:
<script async src="https://brokenhere.com/w.js?k=wq_a7k9vd9pnw5imawe"></script>The project's Setup (the first section of its Settings) prints both with your own key in them, under its first step, Install the widget, one tab per place you might paste it.
Where to put it
Once, in the template every page shares:
| Where you build | Where it goes |
|---|---|
| HTML | Just before </body>. |
| Next.js | In app/layout.tsx, inside <body>, as a next/script (below). With the Pages Router, in pages/_app.tsx the same way. |
| React / Vite | In index.html, just before </body>. |
| WordPress | The Broken Here plugin: install it, then paste your key under Settings › Broken Here. It adds the pre-hook too. |
| Shopify | Online Store > Themes > Edit code > layout/theme.liquid, before </body>. |
| Webflow | Site settings > Custom code > Footer code, then publish. |
| Google Tag Manager (GTM) | A Custom HTML tag, trigger All Pages, holding the ?k= form of the tag. |
| Coding agent | A prompt to paste into the agent in your repository, which adds the tag and fills in the settings. See below. |
For Next.js:
import Script from "next/script";
<Script
src="https://brokenhere.com/w.js?k=wq_a7k9vd9pnw5imawe"
strategy="afterInteractive"
/>Setup remembers the tab you pick in that browser, so somebody on Shopify sees Shopify first on their next project too. Under the tabs, Also catch errors thrown while the page loads holds the optional pre-hook.
What it costs your visitors
| File | Size | When it loads |
|---|---|---|
| the pre-hook | 0.5 kB, inline | Immediately, if you add it |
w.js | 18.6 kB gzip | On page load, async |
w-shot.js | 11 kB gzip | Only once someone starts a report |
The screenshot renderer and the selector generator live in w-shot.js. A
visitor who never reports a bug never downloads them.
A common performance budget is 170 kB of compressed JavaScript for a whole page on mobile. The widget is about a tenth of that, and it is off the critical path.
Confirming it is live
The widget calls home once on boot to fetch its configuration, and that first call doubles as the install check. Setup looks for it on its own. While nothing has arrived, the install step says "Waiting for the first page load" and checks again every few seconds, so all you do is open the site after deploying the tag. When the call lands, the step turns to Live, first seen and how long ago, and its tick counts towards the checklist. Check now asks at once.
After that, the app notes a boot at most once an hour, and the line also says when the widget was last seen. A tag not seen for seven days gets a warning saying for how long and asking you to check it is still on the site, because a redesign that dropped it looks exactly like a quiet week. Once setup is done, the Setup item in the settings menu says the same in two words (live, not seen for 9 days, a site was refused), whichever section you are on.
When your site is refused
The app refuses a boot from an origin the project does not accept, and Setup
names the origin in a warning like this one: https://staging.acme.com tried
to load the widget 2 minutes ago and was refused because it is not in your
allowed origins. That is usually the whole reason a correct tag shows
nothing.
On a refused site, or with a key the app does not recognise, visitors see no
Report issue button and no prompt; your own data-wq-trigger button opens
a panel saying bug reports are not switched on for this site yet, and the
browser console says why. On a refused site, the widget tells a signed-in team
member to add its origin under Settings › Sites & access; with an
unrecognised key, it tells them to copy the tag again from Setup in the
project's settings. If our side is down, nothing is refused and the button
stays.
When the refused site is on a domain the project already names (its site URL
or an allowed origin, such as staging.acme.com beside acme.com), the
notice has an Allow button that adds it to the allowed origins, after
asking. Allow it only if the site is yours. An allowed origin is also where a
team member's sign-in from the widget
is handed over, and the refused origin is whatever the caller claimed. A site
on none of the project's domains gets no button, since anyone holding your
public tag can name any site. Type it into
allowed origins yourself if it is yours, or
have an admin block it under Settings › Sites & access if it is not. Setup
stops showing a refusal nobody has repeated for a week.
If nothing arrives
After ten minutes of waiting Setup stops checking and lists what to look at:
- Look for
w.jsin the Network tab. A 404 means thesrcis wrong. - A 404 from
/api/ingest/<key>means the key does not match a project; a 403 means the site is not in your allowed origins (Setup names it, above). - Check the tag is in the deployed HTML and not only in your local build.
If the tag ends up on the page twice
Nothing bad happens. Including the snippet in both a layout and a page is a common enough mistake that the bundle refuses to install a second time, so there is still one button and one set of captured evidence. It is worth tidying up, but not worth a deployment of its own.
Setting the project up from your codebase
The settings a new project needs (which origins it is served from, which API it calls, which field names carry secrets, which elements to blur) are answers your repository already holds, and filling the settings in from memory is how they end up wrong. Setup's install step prints a prompt for the coding agent that is already in your repository, under its last tab, Coding agent. Paste it into Cursor or Claude Code; the agent answers five questions from the code and sends the answers with one request. Nothing about your code leaves the agent but the answers.
The prompt asks what counts as an allowed origin, what counts as your API and which words name a sensitive field. It never says where to look, because where your stack keeps those is the agent's problem, and the agent knows your repository. If the repository serves more than one site, the agent lists them and stops, and you tell it which one this project is.
The request is PUT /api/projects/<id>/settings with a setup token in
the Authorization header. Setup writes the token into the prompt when a
member opens it. The token unlocks this one project's settings and nothing
else, so a prompt pasted into the wrong chat cannot reach another project. It
lasts a day, so a prompt left in a log is dead the next day. The
request accepts allowedOrigins, apiOrigins, redactKeys and
maskSelectors and nothing else. Blocked origins come from spam reports
rather than from code, and the project's name, switches and integrations stay
with the people who can sign in. Origins go through the same rules as the
settings form, so a line the parser refuses comes back named in the response
rather than stored. Redacted keys and masked selectors are added to the ones
the project has; an agent reporting what it found must never take password
off the list by not mentioning it. What it set shows up under
Settings › Sites & access and Settings › Capture & privacy, and you
remove a line there.
GET on the same URL, with the same header, returns what is currently set,
which is why the prompt asks the agent to read before it writes.
Single-page apps
There is nothing to do. The widget reads location.href when a report is
sent, so on React Router, Next.js and the like, the report records whichever
route the reporter was on.
The error prompt counts per route rather than per page load. Once a failing request has been reported or dismissed on a route, it is not offered again there. An SPA never reloads, so "once per load" would mean once per session.
Keyboard and screen readers
The report form is a modal dialog and behaves like one: Escape closes it, Tab cycles inside it rather than wandering into the page behind, and closing hands focus back to whatever opened it, the floating button or your own trigger. Closing it while a report is sending gives your page back at once and cancels nothing. The report finishes behind a small notice and its receipt arrives the same way; if it is refused, the form comes back as it was. If the reporter leaves the page before it lands, the form offers their words back the next time it opens. The prompt that offers a report when your API fails is announced politely, without stealing focus mid-sentence. Anything the form refuses (a file that is not an image, one too large to send, a failed send) is announced and scrolled into view rather than written below the fold.
You can drive the element picker three ways. With a mouse, you aim it. The arrow keys step through the things worth pointing at (text, controls, images) one at a time, naming each one in the bar as they go, and Enter takes the one outlined. On a touch screen a finger presses, slides and lifts, and the outline and the name in the bar follow it, so nobody has to choose an element they cannot see under their own fingertip. Esc cancels, and so does the bar's own Cancel button, which is the way out on a phone with no Esc key.
On a phone the form is a sheet on the bottom edge of the screen, and its actions stay pinned to the bottom of that sheet rather than sitting at the end of the form, so Send report is on screen at 320×568 without anyone having to discover that the panel scrolls. The sheet is sized against the visible viewport, and it lifts clear of the on-screen keyboard while somebody is typing into it. On iOS the keyboard does not resize the window, so a form pinned to the bottom of the window would have its Send button behind the keyboard. Fields are 16px there, which is the size at which Safari stops zooming the page when a reporter taps into one, and the trigger and the sheet keep clear of the home pill and the notch.
Every colour comes from the --wq-* properties, so contrast is yours to set;
the defaults meet WCAG AA on a white page. If your palette is darker, set
--wq-bg and --wq-fg and check the accent against them.
Content Security Policy
If your site sends a CSP, the widget needs three things:
script-src https://brokenhere.com;
connect-src https://brokenhere.com;
img-src data:;img-src data: is for the screenshot, which the browser builds as a data URL
before uploading it.
That is enough for the widget itself. Its own styling is a constructed
stylesheet rather than a <style> element, so style-src does not apply to
it. The button, the form and the element picker all look right under a policy
as strict as default-src 'self', and a report sends.
Screenshots need one more thing
The renderer that turns the page into an image writes a <style> while it
works, which style-src does govern. Without style-src 'unsafe-inline',
reports arrive with the description, the element, the errors and the failed
requests, and no picture.
The trade is yours to make. If you would rather keep the policy than have the images, nothing breaks, and reports arrive as text only.
Errors thrown before the widget loads
The tag is async, so between your page painting and w.js finishing its
download, nothing is listening. "It broke while the page was loading" is exactly
the report lost in that gap, and the reporter cannot get it back by trying
again.
Paste this inline, as early in <head> as you can, to close the gap.
Setup prints it too, folded under the install tabs as Also catch errors
thrown while the page loads:
<script>"use strict";(()=>{(function(){let o=window,r=o.__wqEarly||(o.__wqEarly=[]),s=50,t=n=>{!r.done&&r.length<s&&r.push(n)};addEventListener("error",n=>{t({kind:1,message:String(n.message??""),filename:String(n.filename??""),lineno:n.lineno||0,colno:n.colno||0,at:Date.now()})}),addEventListener("unhandledrejection",n=>{let a;try{let e=n.reason;a=e instanceof Error?`${e.name}: ${e.message}
${e.stack??""}`:String(e)}catch{a="unhandled rejection"}t({kind:2,message:a,filename:"",lineno:0,colno:0,at:Date.now()})})})();})();</script>It is about half a kilobyte, two listeners and an array, and it decides
nothing. It buffers up to 50 events, then stops. When w.js arrives it drains the buffer through the same rules as
everything else, so dev-server noise is still dropped, third-party scripts
are still counted rather than recorded, and each error keeps the time it
actually happened rather than the time the widget booted. Draining also
closes the buffer, so nothing is recorded twice.
It is inline on purpose. A separate file would have to download before it could help, and that wait is the gap it is there to close.
It covers uncaught errors and unhandled promise rejections, the things that
explain a page that broke while it was loading. Console warnings from before
the widget booted are still lost; wrapping console early enough to catch
them would mean deciding, in the snippet, which ones are worth keeping, and
that decision belongs in one place.
The pre-hook is optional. Without it, everything after boot is still recorded, exactly as before.
If you send a CSP
An inline script needs permission. Either add a nonce (<script nonce="...">
and script-src 'nonce-...') or take the SHA-256 hash of the snippet and add
it to script-src. Adding 'unsafe-inline' to make this work is not worth
it; skip the pre-hook instead.