
Buoy needs two things in any React web app:
import '@buoy-gg/core/web/register', before React DOM loads. It lets Highlight Updates and the other render tools see React, and it starts recording fetch and XHR calls so Network shows the requests the page made before Buoy loaded.FloatingDevTools, inside your providers, loaded only in the browser and only for the people who should see it.The examples load the host in development only. To show Buoy to admins or QA in a production build, see Production builds.
Where each one goes depends on the framework. Each setup below comes from a test app that Buoy's framework test suite installs the way you would, then checks tool by tool. The examples mount a DevTools component like the one in Installation.
Use the Angular guide and its AI install prompt.
Run ng add @buoy-gg/angular, then npx buoy login.
Start your app with ng serve.
The guide covers NgRx, NGXS, QA, and custom builders.
The buoy() plugin does both for you:
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { buoy } from '@buoy-gg/core/vite';
export default defineConfig({
plugins: [react(), buoy()],
});
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { buoy } from '@buoy-gg/core/vite';
export default defineConfig({
plugins: [react(), buoy()],
});
import { BuoyDevTools } from '@buoy-gg/core/web/auto';
export function App() {
return (
<Providers>
{/* your app */}
<BuoyDevTools licenseKey={import.meta.env.VITE_BUOY_KEY} />
</Providers>
);
}
import { BuoyDevTools } from '@buoy-gg/core/web/auto';
export function App() {
return (
<Providers>
{/* your app */}
<BuoyDevTools licenseKey={import.meta.env.VITE_BUOY_KEY} />
</Providers>
);
}
The plugin adds the hook to index.html and loads every installed tool.
BuoyDevTools loads Buoy only in dev builds.
Its files still sit in a release build, but no page loads them.
The early hook still loads, and it stays off for most visitors.
To keep the host and tools out of the build, set it up by hand.
Put the hook on the first line of src/main.tsx:
import '@buoy-gg/core/web/register';
import { createRoot } from 'react-dom/client';
import { App } from './App';
createRoot(document.getElementById('root')!).render(<App />);
import '@buoy-gg/core/web/register';
import { createRoot } from 'react-dom/client';
import { App } from './App';
createRoot(document.getElementById('root')!).render(<App />);
Load the host lazily behind import.meta.env.DEV, so it stays out of the production bundle:
import { lazy, Suspense } from 'react';
const DevTools = import.meta.env.DEV ? lazy(() => import('./DevTools')) : () => null;
export function App() {
return (
<Providers>
{/* your app */}
<Suspense fallback={null}>
<DevTools licenseKey={import.meta.env.VITE_BUOY_KEY} />
</Suspense>
</Providers>
);
}
import { lazy, Suspense } from 'react';
const DevTools = import.meta.env.DEV ? lazy(() => import('./DevTools')) : () => null;
export function App() {
return (
<Providers>
{/* your app */}
<Suspense fallback={null}>
<DevTools licenseKey={import.meta.env.VITE_BUOY_KEY} />
</Suspense>
</Providers>
);
}
Next runs instrumentation-client.ts in the project root before the app hydrates. Put the hook there:
// instrumentation-client.ts
import '@buoy-gg/core/web/register';
// instrumentation-client.ts
import '@buoy-gg/core/web/register';
Load the host from a client component with next/dynamic and ssr: false, and render it in the root layout inside your providers:
// app/BuoyDevTools.tsx
'use client';
import dynamic from 'next/dynamic';
const DevTools =
process.env.NODE_ENV === 'development'
? dynamic(() => import('./DevTools'), { ssr: false })
: () => null;
export function BuoyDevTools() {
return <DevTools licenseKey={process.env.NEXT_PUBLIC_BUOY_KEY} />;
}
// app/BuoyDevTools.tsx
'use client';
import dynamic from 'next/dynamic';
const DevTools =
process.env.NODE_ENV === 'development'
? dynamic(() => import('./DevTools'), { ssr: false })
: () => null;
export function BuoyDevTools() {
return <DevTools licenseKey={process.env.NEXT_PUBLIC_BUOY_KEY} />;
}
This works with Turbopack and with webpack (next dev --webpack).
Put the hook in instrumentation-client.ts, as for the App Router, and mount the host from pages/_app.tsx with the same next/dynamic pattern.
The Pages Router loads React DOM before instrumentation-client.ts runs. Network and the other tools work without any extra step. Highlight Updates also needs a small inline script in pages/_document.tsx, which runs before any of Next's bundles:
// pages/_document.tsx
import { Head, Html, Main, NextScript } from 'next/document';
import { earlyHookScript } from '@buoy-gg/core/web/register';
export default function Document() {
return (
<Html lang="en">
<Head>
<script dangerouslySetInnerHTML={{ __html: earlyHookScript }} />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
// pages/_document.tsx
import { Head, Html, Main, NextScript } from 'next/document';
import { earlyHookScript } from '@buoy-gg/core/web/register';
export default function Document() {
return (
<Html lang="en">
<Head>
<script dangerouslySetInnerHTML={{ __html: earlyHookScript }} />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
The script keeps a reference to React DOM, and the hook in instrumentation-client.ts passes it to Buoy. When the hook doesn't run, the script only leaves an unused placeholder, so it can stay in production builds.
Put the hook on the first line of app/root.tsx. entry.client.tsx is too late, because React Router loads the route modules, and React DOM with them, before it.
// app/root.tsx
import '@buoy-gg/core/web/register';
import { lazy, Suspense, useEffect, useState } from 'react';
const DevTools = import.meta.env.DEV ? lazy(() => import('./DevTools')) : () => null;
// Buoy is browser-only, so it renders after hydration.
function ClientDevTools() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return (
<Suspense fallback={null}>
<DevTools licenseKey={import.meta.env.VITE_BUOY_KEY} />
</Suspense>
);
}
// app/root.tsx
import '@buoy-gg/core/web/register';
import { lazy, Suspense, useEffect, useState } from 'react';
const DevTools = import.meta.env.DEV ? lazy(() => import('./DevTools')) : () => null;
// Buoy is browser-only, so it renders after hydration.
function ClientDevTools() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return (
<Suspense fallback={null}>
<DevTools licenseKey={import.meta.env.VITE_BUOY_KEY} />
</Suspense>
);
}
Render <ClientDevTools /> in the root route's component, inside your providers.
Start's default client entry is generated for you. Add src/client.tsx with the same code plus the hook on the first line:
// src/client.tsx
import '@buoy-gg/core/web/register';
import { StartClient } from '@tanstack/react-start/client';
import { StrictMode } from 'react';
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(
document,
<StrictMode>
<StartClient />
</StrictMode>,
);
// src/client.tsx
import '@buoy-gg/core/web/register';
import { StartClient } from '@tanstack/react-start/client';
import { StrictMode } from 'react';
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(
document,
<StrictMode>
<StartClient />
</StrictMode>,
);
Mount the host from the root route's component with the same ClientDevTools wrapper as React Router, inside your providers.
Beta. Start with the phone app guide. It covers install, sign-in, tools, and Desktop links. See its test notes and limits before you ship.
A Capacitor app runs your web app inside a phone app. Set up Buoy the same way as Vite + React, with two changes.
Use the app's debug flag. cap run ships a production vite build, even to a debug run. So import.meta.env.DEV is false there, and Buoy would never load. BuoyDevTools checks Capacitor.DEBUG instead. The phone app sets it, and it is true in debug builds. On iOS, CAPACITOR_DEBUG in Info.plist can also turn it on. If you load Buoy by hand, check the flag yourself:
import { Capacitor } from '@capacitor/core';
const showBuoy = Capacitor.isNativePlatform() ? Capacitor.DEBUG : import.meta.env.DEV;
const DevTools = showBuoy ? lazy(() => import('./DevTools')) : () => null;
import { Capacitor } from '@capacitor/core';
const showBuoy = Capacitor.isNativePlatform() ? Capacitor.DEBUG : import.meta.env.DEV;
const DevTools = showBuoy ? lazy(() => import('./DevTools')) : () => null;
Mount it outside the router. In Ionic, put BuoyDevTools inside IonApp but outside IonRouterOutlet. Ionic's page changes move the page, and that would move Buoy with it.
Buoy reads the rest from the phone app on its own:
ios or android, not as a browser. It keeps the same device ID after a restart.@capacitor/app. If your app has its own backButton listener, skip your action while isBuoyHoldingBackButton() from @buoy-gg/core/web returns true.10.0.2.2.adb reverse tcp:42831 tcp:42831.server.url): Buoy uses your dev server's host.externalSync={{ socketURL: 'http://192.168.1.20:42831' }} with your Mac's address.server.hostname: pass socketURL too. Buoy can't tell that page from live reload.Android blocks plain http and ws from the app by default. Allow them in debug builds only:
// capacitor.config.ts
const config: CapacitorConfig = {
// ...
server: { cleartext: true },
android: { allowMixedContent: true },
};
// capacitor.config.ts
const config: CapacitorConfig = {
// ...
server: { cleartext: true },
android: { allowMixedContent: true },
};
Run npx cap sync after you change it. If Android still blocks the connection, Buoy logs a warning that names these settings.
In debug builds, Buoy sees each call your app makes to a plugin. You add no code for this. The early hook (@buoy-gg/core/web/register) starts it first. So Buoy also sees the listeners your app adds when it starts.
With the buoy() plugin, install each tool and it shows up.
Without the plugin, add each tool to modules, like the rest:
import * as clock from '@buoy-gg/clock/web';
import * as lifecycle from '@buoy-gg/lifecycle/web';
import * as location from '@buoy-gg/location/web';
import * as permissions from '@buoy-gg/permissions/web';
import * as notifications from '@buoy-gg/notifications/web';
export const modules = {
// ...your other tools
clock,
lifecycle,
location,
permissions,
'push-notifications': notifications,
};
import * as clock from '@buoy-gg/clock/web';
import * as lifecycle from '@buoy-gg/lifecycle/web';
import * as location from '@buoy-gg/location/web';
import * as permissions from '@buoy-gg/permissions/web';
import * as notifications from '@buoy-gg/notifications/web';
export const modules = {
// ...your other tools
clock,
lifecycle,
location,
permissions,
'push-notifications': notifications,
};
Here is what each one does:
@capacitor/preferences keys. It shows localStorage and sessionStorage too. You can edit keys and undo.appStateChange, pause, resume and appUrlOpen to your @capacitor/app listeners. On Android it sends backButton too. It also hides the page, so visibilitychange fires. It sets the battery that @capacitor/device reports. Its dark mode reaches matchMedia('(prefers-color-scheme: dark)') listeners, like React Native's js mode. CSS keeps the real theme. Capacitor has no low memory event, so that one is missing.@capacitor/geolocation and navigator.geolocation.checkPermissions and requestPermissions from any plugin. It knows these names: location, coarseLocation, camera, photos, receive, display, notifications, microphone, contacts, calendar, reminders, bluetooth, motion and tracking. Other names go to the phone as usual. When your app asks for one Buoy has set, Buoy shows its own test prompt. The real one stays hidden. With enforce on, approximate location gives a rough fix, about 2 km off, like iOS does.token or password.@capacitor/preferences keys when it switches users. Buoy's own keys stay.ion-img.buoyAssets() from @buoy-gg/assets/vite. With buoy() too, Buoy loads its list for you. See Assets that haven't loaded.@capacitor/push-notifications and @capacitor/local-notifications listeners. Buoy adds no push listener of its own. So it can't take the tap that opened your app.Buoy only sees calls that go to the phone's own code. A plugin that runs only in JavaScript won't show up.
Add @capacitor/app and @capacitor/device if you can. Buoy reads your app's bundle id and the simulator from them. MCP needs those to send real events to a simulator, and Desktop needs them to send a test push.
Hosted Ask Buoy needs a real Buoy sign-in, not a key. Pass signIn to BuoyDevTools. In a Capacitor app, Buoy then shows a QR code and a short code. Open buoy.gg/activate on any device and allow it. Buoy uses your bundle id. To pick a different id, pass signIn={{ appId: 'com.example.app' }}.
<BuoyDevTools signIn askBuoy="hosted" />
<BuoyDevTools signIn askBuoy="hosted" />
Install @buoy-gg/ask-buoy first.
Your app id shows up as app: plus the id in your Sites list at buoy.gg/dashboard/sites.
Vite's build makes names short. Then tools like Highlight show a letter, not <HomePage>. This setting keeps the names:
// vite.config.ts
export default defineConfig({
plugins: [react()],
esbuild: { keepNames: true },
});
// vite.config.ts
export default defineConfig({
plugins: [react()],
esbuild: { keepNames: true },
});
JS Top shows the file and line behind each timer when your build has source maps. Turn them on for debug builds:
// vite.config.ts
export default defineConfig({
build: { sourcemap: true },
});
// vite.config.ts
export default defineConfig({
build: { sourcemap: true },
});
http address on your network also needs allowInsecureNetwork.Three.js support is in Beta. Start with the three.js guide. It has full setup for R3F and plain apps.
Use the same web tools with Three.js. Buoy draws on the page, above the canvas.
Place <FloatingDevTools> next to <Canvas>. Do not put it inside <Canvas>. Keep Buoy in your app's Query and Redux providers. Pass your Zustand stores and Jotai atoms as usual. Buoy cannot read R3F's private store.
Add the Scene tool to see the tree. It can pick, hide, move, turn, and tint nodes. Its guide shows how to link your scene. Add Perf Monitor for counts. Add Assets and Events for file loads.
Add react, react-dom, and react-native-web as dev deps. The web tools need all three to run.
Load the boot file before your app:
// boot.ts
import '@buoy-gg/core/web/register';
void import('./app');
// boot.ts
import '@buoy-gg/core/web/register';
void import('./app');
Then mount Buoy in your app:
// app.ts
import { mountBuoy } from '@buoy-gg/core/web';
import * as network from '@buoy-gg/network/web';
import * as storage from '@buoy-gg/storage/web';
const unmount = mountBuoy({
modules: { network, storage },
licenseKey: import.meta.env.VITE_BUOY_KEY,
});
// Call when the app shuts down.
// unmount();
// app.ts
import { mountBuoy } from '@buoy-gg/core/web';
import * as network from '@buoy-gg/network/web';
import * as storage from '@buoy-gg/storage/web';
const unmount = mountBuoy({
modules: { network, storage },
licenseKey: import.meta.env.VITE_BUOY_KEY,
});
// Call when the app shuts down.
// unmount();
mountBuoy takes the same props as <FloatingDevTools>. Call its return value to remove Buoy. It uses its own React root. It cannot read providers from another root. Query, Redux, and Highlight Updates have no app to watch here. Pass a vanilla store for Zustand to watch.
The boot import must run before Three.js loads. The hook keeps a short list of past events. Weak refs let the app free old scenes. It keeps a hook that was already there. A late boot import cannot catch past Three.js events. The hook uses the same dev and release rules.
Put the canvas in a wrapper for full screen:
await document.querySelector('#scene-wrap')?.requestFullscreen();
await document.querySelector('#scene-wrap')?.requestFullscreen();
Buoy moves into that wrapper. It moves back on exit. A bare canvas in full screen cannot show Buoy. Its child DOM is fallback content.
Press Esc to free a mouse held by pointer lock. Then click Buoy. Text fields stop keys sent to game handlers. When focus enters Buoy, it sends key-up events. Games must accept these events to clear held keys. Wheel events in Buoy stay in Buoy. Drag and Esc still work in its panels. These guards cannot stop page handlers in the capture phase.
Buoy's DOM tools cannot read objects drawn in WebGL. DOM overlays do not show in a WebXR headset. A scene in a worker is outside the hook's reach.
Buoy also works in production builds, for example for admins, developers or QA testers using the live site. Two things change:
Access. Outside development, FloatingDevTools needs Pro or Business. Use a Pro key, or Sign in with Buoy with no key in the build.
Who sees it. Load the host for the users who should have it, instead of behind a development check. Buoy doesn't know your roles, so the check is yours:
const DevTools = lazy(() => import('./DevTools'));
function MaybeDevTools() {
const user = useCurrentUser();
if (!user?.isAdmin) return null;
return (
<Suspense fallback={null}>
<DevTools licenseKey={import.meta.env.VITE_BUOY_KEY} />
</Suspense>
);
}
const DevTools = lazy(() => import('./DevTools'));
function MaybeDevTools() {
const user = useCurrentUser();
if (!user?.isAdmin) return null;
return (
<Suspense fallback={null}>
<DevTools licenseKey={import.meta.env.VITE_BUOY_KEY} />
</Suspense>
);
}
Keep the early hook import where it is. In a production build it does nothing for most visitors: it runs only in a browser where FloatingDevTools has rendered in the past week. So the first page an admin opens after signing in lists requests from when Buoy loaded, and later page loads include the requests made while the page loads. Highlight Updates works the same way. When someone signs out on a shared browser, call clearReleaseOptIn() from @buoy-gg/core/web to stop it there.
The Desktop connection has its own production switch: pass externalSync={{ enableInRelease: true }} to connect from a production build. It connects to Buoy Desktop on the admin's own machine, and Desktop asks once whether to allow your site. See Desktop and MCP for the other production rules.
Pass the dev token or key through a variable your framework exposes to the browser: VITE_BUOY_KEY in Vite, React Router and TanStack Start, and NEXT_PUBLIC_BUOY_KEY in Next.js. buoy login picks the right one from your package.json.
Buoy's web build is React, so it runs in any React app whose bundler reads the browser export condition. In a framework not listed here, put the hook wherever code runs before React DOM loads. If nothing runs that early, put it as early as you can: Network then shows requests from that point on, and Highlight Updates won't see renders. The other tools don't depend on the hook.