next-fetch-panel
v0.6.0
Published
Real-time server-side fetch inspector panel for Next.js App Router
Maintainers
Readme
next-fetch-panel
A real-time DevTools panel that intercepts all server-side fetch() calls in a Next.js App Router application and streams them to a floating panel in the browser. Each browser session only sees its own requests, so it is safe to run in shared environments like staging.
Requirements
- Next.js 15 or 16 (App Router)
- React 19
- Node.js runtime (not Edge)
Installation
npm install next-fetch-panelWiring it up
There are four integration points. Each one has a simple form (one line, nothing else needed) and a composable form (for projects that already have logic in those files).
1. instrumentation.ts — fetch interception
Simple — nothing else in your instrumentation.ts:
// instrumentation.ts
export { register } from "next-fetch-panel/patch";Composable — you already have setup code:
// instrumentation.ts
import { registerDevPanel } from "next-fetch-panel/patch";
import { registerOTel } from "@vercel/otel";
export async function register() {
registerOTel("my-app");
await registerDevPanel();
}registerDevPanel skips itself when NEXT_RUNTIME !== "nodejs" so it never runs in Edge contexts.
2. middleware.ts / proxy.ts — session isolation
Next.js 16+ renamed this file from
middleware.tstoproxy.tsand the export frommiddlewaretoproxy. Both versions work the same way — match the file name and export name to your Next.js version.
Simple — no other middleware (Next.js ≤ 15):
// middleware.ts
export { middleware, config } from "next-fetch-panel/middleware";Simple — Next.js 16+:
// proxy.ts
import { withDevPanel } from "next-fetch-panel/middleware";
export const proxy = withDevPanel();
export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };Composable — you already have your own logic:
// proxy.ts (Next.js 16+) or middleware.ts (Next.js ≤ 15)
import { withDevPanel } from "next-fetch-panel/middleware";
export const proxy = withDevPanel(async (request) => {
if (!request.cookies.has("auth-token"))
return NextResponse.redirect(new URL("/login", request.url));
// return nothing to let the request continue normally
});
export const config = {
matcher: ["/(ca|us)/:path*", "/((?!api|_next|.*\\..*).*)"],
};withDevPanel handles NextResponse.next() internally — only return a response when you want to block or redirect. The function itself is the middleware handler, so you can export it directly as proxy, middleware, or any name your project uses.
3. app/api/dev-network/route.ts — SSE stream
Simple — no access control needed:
// app/api/dev-network/route.ts
export { dynamic, GET } from "next-fetch-panel/route";Composable — restrict access (recommended for staging):
// app/api/dev-network/route.ts
import { createDevPanelRoute } from "next-fetch-panel/route";
export const dynamic = "force-dynamic";
export const GET = createDevPanelRoute({
guard: (request) => {
const token = request.headers.get("authorization");
if (!isValidAdminToken(token))
return new Response("Forbidden", { status: 403 });
},
});4. app/layout.tsx — panel component
// app/layout.tsx
import { DevNetworkPanel } from "next-fetch-panel";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{children}
<DevNetworkPanel />
</body>
</html>
);
}Both modes are resizable. In sheet mode, drag the handle at the inward-facing edge to change its height (bottom/top) or width (left/right). In fixed modal mode, all four edges have handles — drag any edge to resize. The size is saved to localStorage automatically.
Controlling the panel programmatically
Use the useDevNetworkPanel hook to open, close, or toggle the panel from your own components — without relying on the default floating button.
"use client";
import { useDevNetworkPanel } from "next-fetch-panel";
export function MyToolbar() {
const { isOpen, toggle } = useDevNetworkPanel();
return (
<button onClick={toggle}>
{isOpen ? "Hide" : "Show"} network panel
</button>
);
}The hook returns { isOpen, open, close, toggle }. All four values are stable references — safe to use as event handlers without useCallback.
To hide the default floating button (when you're providing your own trigger), pass showToggleButton={false} to <DevNetworkPanel>:
<DevNetworkPanel showToggleButton={false} />Toggle button position
Use buttonPosition to place the default toggle button in any of six positions (default: "bottom-right"):
<DevNetworkPanel buttonPosition="bottom-center" />| Value | Position |
|-------|----------|
| "bottom-right" | Bottom-right corner (default) |
| "bottom-center" | Bottom edge, centered |
| "bottom-left" | Bottom-left corner |
| "top-right" | Top-right corner |
| "top-center" | Top edge, centered |
| "top-left" | Top-left corner |
In fixed panel mode, the panel container anchors to the same corner/edge as the button.
Redacting secrets
By default, the panel redacts:
- URL params:
api_key,apikey,secret,token,access_token,password,key - Headers:
authorization,x-api-key,x-secret,x-auth-token - JSON body keys:
password,secret,token,api_key,apiKey,accessToken,access_token
Pass a redact option to registerDevPanel and/or createDevPanelAxiosInterceptors to customize:
// extend defaults — keep existing patterns, add your own
await registerDevPanel({
redact: (defaults) => ({
...defaults,
urlParams: [...defaults.urlParams, "shop_token"],
bodyKeys: [...defaults.bodyKeys, "refreshToken"],
}),
});
// override a category entirely — only these values will be redacted
await registerDevPanel({
redact: { headers: ["x-my-secret"] },
});
// disable a category — show everything
await registerDevPanel({
redact: { headers: [] },
});The same redact option is available on createDevPanelAxiosInterceptors({ redact: ... }).
The actual HTTP request is never modified — only the copy kept for display.
Capture limits
By default, request and response bodies are capped at 50 KB and the server keeps the last 100 entries in memory. Both limits are configurable:
// instrumentation.ts
await registerDevPanel({
bodyLimit: 200_000, // bytes — applies to request and response bodies (default: 50_000)
bufferSize: 200, // entries kept in the server-side ring buffer (default: 100)
});
// axios client
createDevPanelAxiosInterceptors({
bodyLimit: 200_000, // same option, independent from the fetch patch
});
// app/layout.tsx — keep in sync with bufferSize above
<DevNetworkPanel bufferSize={200} />bufferSize on <DevNetworkPanel> controls how many entries the panel displays. Keep it in sync with the bufferSize passed to registerDevPanel so the server buffer and the client display cap match.
Using axios
Register the dev panel interceptors before your own interceptors. Use a synchronous require() so they run first in the response chain:
// your-axios-client.ts
import axios from "axios";
const http = axios.create({ baseURL: "https://api.example.com" });
if (typeof window === "undefined") {
try {
const { createDevPanelAxiosInterceptors } = require("next-fetch-panel/interceptor");
const { request, response, error } = createDevPanelAxiosInterceptors();
http.interceptors.request.use(request);
http.interceptors.response.use(response, error);
} catch {}
}
export default http;No axios configuration change is required — adapter: 'fetch' is not needed. The interceptors work with any axios adapter.
If your project makes server-side requests only through axios (no native fetch() in Server Components or Route Handlers), you can skip instrumentation.ts entirely — the interceptors are sufficient. If you use both, set up instrumentation.ts as well so native fetch() calls are also captured.
Disabling per environment
Every entry point accepts an enabled option (or prop) that defaults to true. Pass false to make that layer a complete no-op — no patching, no cookies, no SSE stream, no panel rendered.
// instrumentation.ts
await registerDevPanel({ enabled: process.env.NODE_ENV !== "production" });
// middleware.ts / proxy.ts
export const middleware = withDevPanel(handler, { enabled: process.env.NODE_ENV !== "production" });
// app/api/dev-network/route.ts
export const GET = createDevPanelRoute({ enabled: process.env.NODE_ENV !== "production" });
// axios client
createDevPanelAxiosInterceptors({ enabled: process.env.NODE_ENV !== "production" });
// app/layout.tsx
<DevNetworkPanel enabled={process.env.NODE_ENV !== "production"} />You can use any condition — NODE_ENV, a custom env var, a feature flag, or any boolean. Each entry point is independent, so you can disable just the fetch patch while keeping the panel open for other sessions, or shut everything off at once.
Security notes
| Concern | How it is handled |
|---|---|
| User A seeing User B's requests | Each browser session gets a unique __dev_sid cookie. The SSE stream filters strictly by session ID. |
| Panel accessible to anyone | Use the guard option on createDevPanelRoute. |
| Secrets leaking to the browser | Configured patterns are replaced with [REDACTED] before entries reach the store. |
| Running in production | Pass enabled: process.env.NODE_ENV !== "production" to each entry point. |
Exporting logs for test automation
The snapshot endpoint returns the current server-side log buffer for a session as JSON. Call it in test teardown to save network logs as CI artifacts.
1. Create the route:
// app/api/dev-network/snapshot/route.ts
export { dynamic, GET_SNAPSHOT as GET } from "next-fetch-panel/route";With access control:
// app/api/dev-network/snapshot/route.ts
import { createDevPanelSnapshot } from "next-fetch-panel/route";
export const dynamic = "force-dynamic";
export const GET = createDevPanelSnapshot({
guard: (request) => {
if (!isValidToken(request.headers.get("authorization")))
return new Response("Forbidden", { status: 403 });
},
});2. Call it from your test runner:
// Playwright example — save logs as artifact after each test
test.afterEach(async ({ request, page }, testInfo) => {
const cookie = (await page.context().cookies()).find(c => c.name === "__dev_sid");
if (!cookie) return;
const res = await request.get("/api/dev-network/snapshot", {
headers: { Cookie: `__dev_sid=${cookie.value}` },
});
const { entries } = await res.json();
await testInfo.attach("network-logs.json", {
contentType: "application/json",
body: JSON.stringify(entries, null, 2),
});
});Response shape:
{ entries: LogEntry[] }The snapshot only includes entries captured since the session was created, up to the configured bufferSize (default: 100). Increase it in CI if your test suites make many requests:
await registerDevPanel({ bufferSize: 2000 });Public API
import { DevNetworkPanel, useDevNetworkPanel } from "next-fetch-panel";
import type { LogEntry, ButtonPosition } from "next-fetch-panel";
import { registerDevPanel } from "next-fetch-panel/patch";
import { withDevPanel } from "next-fetch-panel/middleware";
import { createDevPanelRoute, createDevPanelSnapshot } from "next-fetch-panel/route";
import { createDevPanelAxiosInterceptors } from "next-fetch-panel/interceptor";