Published: Dec 20, 2025
Updated: Apr 30, 2026
Update (April 2026): The original version of this post recommended Next.js rewrites. They look like they work — events come back
200 OK— but I later found they silently fragment device identity in production. This rewrite captures the actual right way: the officialcreateRouteHandlerfrom@openpanel/nextjs/server. The old rewrite-based setup is preserved at the bottom for context.
If you're self-hosting OpenPanel on Coolify or anywhere else, you usually end up with two domains — a dashboard host (e.g. opdashboard.yourdomain.com) and an API host (opapi.yourdomain.com). To dodge CORS pain and adblockers you want both to look same-origin from your Next.js app.
The instinct is to reach for next.config.ts rewrites — they look cleaner than dropping a route handler in. They aren't.
OpenPanel derives deviceId and sessionId server-side from the requesting client's IP and User-Agent. Stable fingerprint → stable identity → stable analytics.
Next.js rewrites are a transparent forward. Your Vercel function (or edge node) proxies the request through, but it does not inject the original client IP. The upstream OpenPanel API only sees your platform's egress IP — and on Vercel that egress IP changes per region and per cold start. Two events from the same browser, seconds apart, can come back with completely different deviceId and sessionId values.
I caught this on a real production site: four POST /api/op/track calls from the same browser within ~5 seconds returned three different deviceIds. The Set-Cookie story doesn't help either — OpenPanel's server isn't setting one, and the SDK doesn't persist anything in localStorage. Identity lives entirely on the server, derived from a fingerprint your rewrite isn't passing along.
End result: inflated unique visitors, fragmented sessions, broken funnels, and broken retention.
createRouteHandlerOpenPanel ships a route handler exactly for this case. Reading its source confirms what the rewrite misses — it pulls the real IP from cf-connecting-ip, x-forwarded-for, or x-vercel-forwarded-for and forwards it to the upstream as the openpanel-client-ip header. That's the piece a plain rewrite cannot do.
import { createRouteHandler } from "@openpanel/nextjs/server";
export const { GET, POST } = createRouteHandler({
apiUrl: process.env.NEXT_PUBLIC_OPENPANEL_API_URL,
});This single handler covers both the SDK script (/api/op/op1.js) and event ingestion (/api/op/track*). You don't need to point at your dashboard host to serve the script — the handler fetches op1.js from the public OpenPanel CDN once per day (cached server-side for 24h) and serves it from your origin. Same adblock resistance, one fewer subdomain to wire up.
Then point the client at the proxied paths:
import { OpenPanelComponent } from "@openpanel/nextjs";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{process.env.NODE_ENV === "production" && (
<OpenPanelComponent
clientId={process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID!}
apiUrl="/api/op"
scriptUrl="/api/op/op1.js"
trackScreenViews
trackOutgoingLinks
trackAttributes
/>
)}
{children}
</body>
</html>
);
}Both apiUrl and scriptUrl are required for self-hosted setups. Dropping either silently falls back to a hosted default the SDK can't reach (api.openpanel.dev for the API, public CDN for the script) — that's the same trap the original post warned about and it still applies here.
If you also track server-side events (Stripe webhooks, Polar, anything in app/api/webhooks/*), the JS SDK has its own default that points at the hosted instance:
import { OpenPanel } from "@openpanel/sdk";
export const opServer = new OpenPanel({
clientId: process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID!,
clientSecret: process.env.OPENPANEL_CLIENT_SECRET!,
apiUrl: process.env.NEXT_PUBLIC_OPENPANEL_API_URL, // required for self-hosted
});Without apiUrl, every server-side event ships to https://api.openpanel.dev, where your client ID is unknown. The request is rejected, retried three times, then dropped. Nothing shows up in your dashboard, nothing shows up in your logs. Easy to miss for months.
After deploying, hit two pages from the same browser and watch DevTools → Network → /api/op/track. The response body looks like:
{"deviceId":"...","sessionId":"..."}Both values should be stable across requests. If they change on every request, the client IP isn't getting through and you're back at the rewrite problem.
For context, the original setup was:
async rewrites() {
return [
{ source: "/op1.js", destination: "https://opdashboard.yourdomain.com/op1.js" },
{ source: "/api/op/:path*", destination: "https://opapi.yourdomain.com/:path*" },
];
}It feels cleaner. It returns 200s. It silently mangles your data. Don't ship it.
createRouteHandler from @openpanel/nextjs/server, not Next.js rewrites.apiUrl and scriptUrl on OpenPanelComponent.apiUrl to the server-side new OpenPanel({ ... }) instance too.deviceId is stable across page loads.For more on the official SDK, check out the OpenPanel Next.js documentation.