Skip to content

Pass a rules config to withBridgeAuth in middleware.ts. The middleware handles navigation guards automatically, before a request ever reaches a page.

// middleware.ts
import { withBridgeAuth } from '@nebulr-group/bridge-nextjs/server';

export default withBridgeAuth({
  rules: [
    { match: '/', public: true },
    { match: new RegExp('^/auth($|/)'), public: true },
  ],
  defaultAccess: 'protected',
});

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

How it works:

OptionWhat it does
defaultAccessSets whether unmatched routes are 'public' or 'protected'.
rulesMarks individual paths as public. String rules match the exact path or any subpath; RegExp rules use pattern.test(pathname).
callbackPathThe OAuth callback path, always treated as public. Defaults to /auth/oauth-callback; it must resolve to a Route Handler built with createBridgeCallbackRoute (second tab above).

Redirects are handled automatically by the middleware: when a protected route is hit without a valid session, the user is sent to Bridge’s hosted login page, and the callback route stores the resulting session before redirecting them back in. For the full RouteRule shape, see the config reference.

Your app signs in withGuarded byWhat withBridgeAuth does
Hosted login (no loginRoute): the session is a cookiewithBridgeAuth, before the page loadsEnforces every route that needs a session: an unmatched route under defaultAccess: 'protected', and any rule without public: true under either defaultAccess. It verifies the session token: its PS256 signature against the Bridge JWKS (<apiBaseUrl>/auth/.well-known/jwks.json), issuer <apiBaseUrl>/auth, audience containing your appId, and expiry. A missing, forged, foreign or expired token counts as signed out: a page is redirected to login with the page they asked for remembered, an API request gets 401. If the check itself fails (for example the key set cannot be fetched), the request is denied.
The drop-in LoginForm (loginRoute set): tokens live in the browser<ProtectedRoute> in each protected page, and your APISteps aside. It cannot see a browser-held session, and redirecting would loop a signed-in user, so a request without a Bridge session cookie is let through. In development it logs a one-time warning saying so.

Neither route guard replaces server-side authorization. They decide what the browser is shown; every API route must still verify the user’s token itself. In SDK-auth mode the middleware is not a guard at all.

The middleware also removes any x-bridge-context header a client sends before the request reaches your app; the SDK only ever forwards one it built from the verified session.

Rules are matched against both the requested path and its normalised form (percent-decoding, duplicate slashes, ./.. segments, trailing slash); the stricter answer wins. Matching is case-sensitive, like Next.js routing.

In an SDK-auth app, wrap each protected page:

'use client';
import { ProtectedRoute } from '@nebulr-group/bridge-nextjs/client';

export default function DashboardPage() {
  return (
    <ProtectedRoute redirectTo="/auth/login">
      <Dashboard />
    </ProtectedRoute>
  );
}

<ProtectedRoute> shows a loading placeholder while auth state resolves, redirects to redirectTo (default '/') if the user isn’t authenticated, and renders children otherwise.

Sign-in and a feature flag on the same route

Section titled “Sign-in and a feature flag on the same route”

Next.js runs one middleware.ts, so withBridgeAuth and withFeatureFlags cannot both be its default export, and chaining them by hand drops what the first one decided. Put the flag on the route rule instead: withBridgeAuth checks the session first, then the flag, in one middleware.

// middleware.ts
import { withBridgeAuth } from '@nebulr-group/bridge-nextjs/server';

export default withBridgeAuth({
  rules: [
    { match: '/', public: true },
    { match: new RegExp('^/auth($|/)'), public: true },
    { match: '/beta', featureFlag: 'beta_feature' },                       // one flag
    { match: '/reports', featureFlag: { any: ['reports', 'analytics'] } }, // at least one
    { match: '/admin', featureFlag: { all: ['admin_ui', 'staff'] } },      // every one
  ],
});

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

A featureFlag rule:

  • requires a signed-in user first: signed out, a page goes to login (the deep link remembered), an API request gets 401;
  • evaluates the flag server-side for that user: off answers 403, never a redirect; an evaluation error also answers 403 (fail closed);
  • matches like any other rule (exact path or prefix, or a RegExp), and a rule’s redirectTo is not used here.

For a friendlier page than a 403, keep the route public in the middleware and gate the content with <FeatureFlag flagKey="beta_feature" upgrade> in the page (a plan-gated flag then offers the upgrade dialog). Use withFeatureFlags on its own only in an app with no sign-in middleware.

Someone who follows a link into a protected page — an emailed document link, a bookmark, a shared URL — lands on that page after signing in, not on your default route. This is on by default; you do not configure anything to get it.

How the target travels depends on which login you use:

ModeMechanismYour job
Hosted (no loginRoute)An httpOnly cookie, written by withAuth and consumed by createBridgeCallbackRouteNothing. It is automatic
SDK (you set loginRoute)?redirectUri= on your own login routeRead it after login — below

Next.js uses a cookie rather than sessionStorage because both ends of the hosted round-trip run on the server: withAuth is middleware and the callback is a route handler, so neither has a DOM. The cookie is httpOnly, sameSite=lax (so it survives the top-level navigation back from the identity provider), and short-lived.

Your login page owns the post-login navigation, so it has to read the target. Use readReturnTo — it validates the value for you:

'use client';
import { LoginForm, readReturnTo } from '@nebulr-group/bridge-nextjs/client';
import { useRouter, useSearchParams } from 'next/navigation';

export default function LoginPage() {
  const router = useRouter();
  const params = useSearchParams();

  return (
    <LoginForm
      onLogin={() => {
        // Falls back to your own default when there is no target, or when the
        // one supplied is not safe to navigate to.
        router.push(readReturnTo(params) ?? '/dashboard');
      }}
    />
  );
}

Your loginRoute is excluded automatically, so a bounce through the login page never comes back pointing at itself. Exclude the rest of your auth flow too:

<BridgeProvider
  config={{
    appId: '…',
    loginRoute: '/auth/login',
    returnTo: { exclude: [new RegExp('^/auth($|/)')] },
  }}
>

To send every login to the same place regardless of where the visitor was heading:

returnTo: { enabled: false }

A path that fails validation is treated the same way: your login page gets null and falls back to its own default.