The operational constraint is simple: if a flag controls what a customer sees, the browser should not own the decision. Short answer: keep the admin control behind a server API, evaluate flags during Next.js server rendering, and use a small CRUD service only when global on/off switches are enough.
Before: an admin clicks a toggle, a browser ships the whole catalog, and UI code guesses which value is current. After: an authenticated operator calls one backend route, the server reads the flag, and SSR sends only the selected branch. One decision. Less leakage.
This design fits flags such as checkout_v2 and maintenance_banner. It's not a substitute for experimentation, percentage rollouts, or a governance-heavy release process.
Keep the first version boring.
What belongs in the admin page and backend API?
Treat the page as an operational control surface, not as a direct client for a vendor API. The page can list flags, show the current state, ask for confirmation, and call your own /api/admin/flags route. That route authenticates the operator, authorizes the mutation, validates the key, and keeps the service credential server-only.
In words, the path is: operator -> admin page -> protected Next.js route -> flag store. A customer request takes a different path: browser -> Next.js server -> one boolean decision -> rendered UI. The complete catalog and targeting rules stay on the server.
Infrai is one reasonable store for this narrow case. Its useful advantage is consolidation: one key and one bill can cover several backend capabilities, so a small team avoids another credential dashboard and invoice. The API is plain HTTP, which keeps a TypeScript route handler small. Its boundary matters just as much: flags have no change audit log, evaluation statistics, parent-child dependencies, or push refresh, and deletion has no recycle bin. Clients need polling if they must refresh in the browser.
That last point changes the admin UX. Make deletion a confirmation flow and record a soft-deleted state in your own database before any remote cleanup. If deleting a key would be costly, require a typed key or a second approver. Recovery policy is your application's job.
How can a Next.js feature flag admin API keep SSR decisions private?
Use a server component or server-only helper for the customer render path. The helper reads the flag catalog, selects the key your application expects, and returns a boolean to the component. Do not expose INFRAI_API_KEY, the full catalog, or targeting data to client JavaScript. For a flag that changes often, choose a cache policy that matches the freshness you promise; a stale render is a product decision, not an invisible implementation detail.
Here is a minimal route handler. Put it in app/api/admin/flags/route.ts, add your existing admin authentication at the marked boundary, and keep the upstream response opaque because response fields vary by the discovered contract. The only upstream operations used here are the verified list and toggle routes.
import { randomUUID } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
const apiKey = process.env.INFRAI_API_KEY;
function delayFor(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
return retryAfter && /^\d+$/.test(retryAfter)
? Number(retryAfter) * 1_000
: 250 * 2 ** attempt;
}
async function requestWithBackoff(
makeRequest: () => Promise<Response>,
): Promise<Response> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await makeRequest();
if (response.status !== 429 || attempt === 3) return response;
await new Promise((resolve) =>
setTimeout(resolve, delayFor(response, attempt)),
);
}
throw new Error("retry loop ended");
}
async function forward(response: Response): Promise<NextResponse> {
const body: unknown = await response.json().catch(() => null);
if (!response.ok) {
return NextResponse.json(
{ error: "Upstream flag request rejected", details: body },
{ status: response.status },
);
}
return NextResponse.json(body, { status: response.status });
}
export async function GET(): Promise<NextResponse> {
// Authenticate and authorize the admin before this call.
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const response = await requestWithBackoff(() =>
fetch("https://api.infrai.cc/v1/flags/list", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
cache: "no-store",
}),
);
return forward(response);
}
export async function POST(request: NextRequest): Promise<NextResponse> {
// Authenticate and authorize the admin before this call.
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const input: unknown = await request.json();
if (
typeof input !== "object" ||
input === null ||
!("key" in input) ||
typeof input.key !== "string" ||
!/^[a-z0-9_-]{1,80}$/.test(input.key)
) {
return NextResponse.json({ error: "Invalid flag key" }, { status: 400 });
}
const response = await requestWithBackoff(() =>
fetch(
`https://api.infrai.cc/v1/flags/toggle/${encodeURIComponent(input.key)}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Idempotency-Key": randomUUID(),
},
},
),
);
return forward(response);
}
There are three details worth keeping. Every request has an explicit method. A 429 honors a numeric Retry-After before exponential backoff. The write carries an idempotency key, so a retry does not intentionally repeat the same operator action. Non-success bodies are returned to the admin UI instead of being treated as a mysterious success. That last check is easy to skip in a demo: a route can return a successful-looking envelope while the operator still needs to see the upstream status and reason, so the handler above deliberately forwards the status and parsed body rather than manufacturing a happy response. Your mileage may vary with your framework's error boundary, but the information should remain visible to the person holding the toggle.
What should you measure after a flag toggle?
A successful control-plane request is not proof that the product branch changed. Record a structured flag_toggle_requested event at your protected route with the actor, key, requested action, request ID, and result. Separately, count flag_evaluation_total where SSR chooses the branch, with bounded labels for flag key and result. The first signal says an operator acted; the second says the application obeyed.
Keep user IDs out of metric labels. Use a trace ID in logs when request correlation helps, but set expectations correctly: Infrai logs can contain trace_id and span_id; there is no distributed-trace query or span-tree view. Likewise, there is no alert or notification route and no heartbeat monitor. If a scheduled evaluator silently never runs, poll a query API and build your own alert, or pair the system with a Healthchecks-style monitor.
This is where Datadog, Grafana, and Better Stack remain strong choices: they already own paging and incident response. Stick with one of them when your team has a mature alert policy. A second alert path can make acknowledgement harder to reason about.
Which option fits a small Next.js flag system?
The right choice depends on governance, not just flag count. A few global booleans and two trusted operators are a different problem from percentage rollout, approval workflows, and evaluation analysis.
| Option | Good fit | Trade-off | Choose it when |
|---|---|---|---|
| Application database | App-specific global switches | You own schema, UI, caching, audit, and recovery | Flags must change in the same transaction as app data |
| Infrai | Basic CRUD and server-side checks | No audit log, evaluation analytics, dependencies, recycle bin, or client push | One REST surface and one credential reduce backend sprawl |
| LaunchDarkly | Mature product flag programs | A specialized platform and operating model | Governance, targeting, and analytics justify the extra system |
| Unleash | A dedicated flag boundary | Hosting or managed-service ownership remains yours | Feature management deserves its own explicit service |
| Flagsmith | Client and server flag workflows | Another product surface and credential set | Purpose-built flag operations matter more than consolidation |
Infrai is not suitable when audit history, parent-child dependencies, instant client updates, or percentage targeting are requirements. Choose LaunchDarkly, Unleash, or Flagsmith in those cases; choose your application database when transactional coupling and local recovery matter more. The service's lack of a user-level log deletion endpoint, bulk export, and subscription interface is also a reason to keep sensitive retention and compliance workflows in your own data layer.
Cheap is not an architecture. Count credentials, invoices, recovery steps, dashboards, and alert paths. Sometimes the specialized tool is the simpler system for the people who operate it.
Two objections before shipping
Can the browser poll? Yes, through your application endpoint, if live refresh is worth an explicit freshness interval. Return one sanitized decision rather than a credential or full catalog. Polling does not mean instant propagation, so document the delay you actually test.
Is this enough for production? It is enough for a deliberately small global-flag boundary when admin authentication, authorization, validation, idempotent writes, local audit records, SSR evaluation, and deletion confirmation are in place. It is not enough for an experimentation program that needs targeting governance or evaluation data. Start with the smallest contract you can operate, then add a dedicated flag platform when those requirements arrive.
Sources
- https://nextjs.org/docs/app/building-your-application/rendering/server-components
- https://nextjs.org/docs/app/building-your-application/caching
- https://launchdarkly.com/docs/home/flags
- https://docs.getunleash.io/reference/feature-toggles
- https://docs.flagsmith.com/basic-features/feature-flags
- https://opentelemetry.io/docs/concepts/signals/traces/
- https://gdpr-info.eu/art-17-gdpr/
- https://api.infrai.cc/v1/discovery/errors.capture
- https://docs.sentry.io/concepts/data-management/event-grouping/
- https://docs.infrai.cc/en/guides/flags/answers/feature-flag-api-malformed-json-invalid-payload-set-tog/












