Why an In-App DevTools Panel Beats a Browser Extension
If you've ever spent your mornings typing the same test credentials into a local login form, or re-filling a multi-field form just to reproduce a bug that only appears with a specific combination of values, you know how much friction those small rituals add up to. Changing which backend environment your frontend points at can be even worse when it requires a dev server restart.
One approach is to build a browser extension to control your app and its environment. But extensions are indirect to work on and have real limitations you end up having to hack around. They're also poorly suited for temporary tools that only you need during development.
A better alternative: keep that tooling inside the app itself. The source lives in the repo alongside the rest of the code, and the tooling runs in the app just like any other module. That's the "App DevTools" pattern, demonstrated in a small React demo app with a public repo. In the demo, the tools toggle a feature flag, but the pattern gives you control over just about anything at runtime.
The Wiring
import loadDevTools from './dev-tools/load'
import * as React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
// load and install the dev tools (if they need to be)
// and when that's done, let's render the app
// NOTE: if we don't need to install the devtools, then the callback
// is called synchronously so there's no penalty for including this
// in production.
loadDevTools(() => {
ReactDOM.render(<App />, document.getElementById('root'))
})
function loadDevTools(callback) {
// this allows you to explicitly disable it in development for example
const explicitlyDisabled =
window.location.search.includes('dev-tools=false') ||
window.localStorage.getItem('dev-tools') === 'false'
const explicitlyEnabled =
window.location.search.includes('dev-tools=true') ||
window.localStorage.getItem('dev-tools') === 'true'
// we want it enabled by default everywhere but production and we also want
// to support the dev tools in production (to make us more productive triaging production issues).
// you can enable the DevTools via localStorage or the query string.
if (
!explicitlyDisabled &&
(process.env.NODE_ENV === 'development' || explicitlyEnabled)
) {
// use a dynamic import so the dev-tools code isn't bundled with the regular
// app code so we don't worry about bundle size.
import('./dev-tools')
.then((devTools) => devTools.install())
.finally(callback)
} else {
// if we don't need the DevTools, call the callback immediately.
callback()
}
}
export default loadDevTools
Several details matter in that boot sequence:
- The DevTools are only active when explicitly enabled in production, or when not disabled in development.
- The DevTools code never lands in the production bundle; it's code-split separately so developer-experience improvements don't affect user experience.
- The app waits to render until the DevTools have installed, so they can modify the global environment before any application code runs.
- If the tools aren't needed, the install check is cheap and rendering starts immediately—there's no penalty for including the code path.
Inside dev-tools.js, you have full freedom to do anything, including async work. Just export an install function:
import * as React from 'react'
function install() {
function DevTools() {
return <div>Hi from the DevTools</div>
}
// add dev tools UI to the page
const devToolsRoot = document.createElement('div')
document.body.appendChild(devToolsRoot)
ReactDOM.render(<DevTools />, devToolsRoot)
}
export { install }
The src/dev-tools/dev-tools.js file in the repo shows what you can put there.
Local-Only Tooling
A notable extension of the pattern is support for tools that exist only on your machine. Maybe you're prototyping an automation, or you have a helper nobody else on the team wants. The loader can check for a file that's git-ignored and load it if present:
// load local dev tools if it's there
// NOTE: this is using some webpack-specific features.
// if you're not using webpack, you might consider using
// https://npm.im/preval.macro or https://npm.im/codegen.macro
const requireDevToolsLocal = require.context(
'./',
false,
/dev-tools\.local\.js/,
)
const local = requireDevToolsLocal.keys()[0]
if (local) {
requireDevToolsLocal(local).default
}
That looks for src/dev-tools/dev-tools.local.js and loads it when found. Add *.local.* to your .gitignore, and you get a script that runs whenever you load the app locally, but is never committed and never runs for anyone else.
What to Build With It
Beyond toggling feature flags with a pleasant UI, you can listen for URL changes and auto-fill forms using the screen and async utilities from @testing-library/dom together with the event helpers from @testing-library/user-event. You could also surface model data under a form to inspect validation state. Switching backend environments from a UI rather than by editing config files was an especially large win in practice.
Keeping DevTools Out of Users' Way
It's easy to optimize for developer experience at the expense of the user experience that pays the bills. The loader above is designed so bundling and startup time don't grow, but that's only half the battle. You also need to make sure you never ship app code that assumes the DevTools are present. A solid test suite that runs with the DevTools disabled is the safeguard against that.
In practice, this pattern was surprisingly useful beyond developers: backend engineers, QA, and product folks all found value in the in-app tools. When enabled carefully in production, they also sped up debugging of live issues. With that setup in place, the automation opportunities for day-to-day development are essentially open-ended.



