Handling Deep Links and Route Guards in Federated Apps #

A user pastes /billing/invoices/9 into a fresh tab, but the billing remote that owns that route has not loaded yet — and an auth guard must run and pass before the remote is allowed to mount.

That single sentence hides three race conditions. The route resolver runs before the owning chunk exists. The guard needs to decide “allowed or not” before the protected UI paints. And if the guard sends the user to login, the original deep URL has to survive the round-trip so the user lands where they meant to. This guide wires all three together in the host shell, building on the single-writer routing model where the shell owns navigation and remotes are mounted as data-driven children.

Prerequisites #

The order of operations matters more than any single API. The shell must parse → guard → load → mount, in that sequence, on the very first paint.

Deep-link load and guard sequence On cold load the shell parses the URL, runs the auth guard, lazy-loads the owning remote behind a loading boundary, then mounts it — or redirects to login preserving returnTo. Cold load /billing/9 Parse URL match owner Run guard auth / role Lazy-load remote chunk Mount remote view /login ?returnTo=/billing/9 guard fails
Cold deep-link load: parse, guard, lazy-load, mount — with a returnTo-preserving redirect when the guard fails.

Step 1 — Lazy-load the owning remote behind a boundary #

Wrap each remote import in React.lazy so the chunk is only fetched when its route actually renders. The component returned by dynamic runtime loading becomes a normal lazy component to the shell.

// shell/src/remotes.ts
import { lazy } from "react";

// loadRemote resolves a federated module at runtime.
export const BillingApp = lazy(() =>
  loadRemote<{ default: React.ComponentType }>("billing", "./App")
);

export const ReportsApp = lazy(() =>
  loadRemote<{ default: React.ComponentType }>("reports", "./App")
);

The lazy wrapper guarantees the remote’s JavaScript is not requested until the route matches — which is exactly what makes deep links work without bundling every remote into the shell.

Step 2 — Show a loading boundary while the chunk loads #

A cold deep link will always have a gap between “route matched” and “remote code arrived.” A Suspense boundary fills that gap so the user sees a deterministic loading state instead of a blank frame.

// shell/src/components/RemoteOutlet.tsx
import { Suspense } from "react";

export function RemoteOutlet({ children }: { children: React.ReactNode }) {
  return (
    <Suspense fallback={<div className="remote-loading" role="status">Loading…</div>}>
      {children}
    </Suspense>
  );
}

Keep the fallback at the route level, not the app root, so navigating between two already-loaded remotes never flashes a global spinner.

Step 3 — Resolve auth once, before any route renders #

The classic guard bug is checking isAuthenticated while it is still undefined. The shell must resolve auth state to a settled boolean before it renders the router, otherwise the guard runs against an unknown value and either flashes protected content or wrongly redirects.

// shell/src/auth/AuthProvider.tsx
import { createContext, useContext, useEffect, useState } from "react";

type AuthState = { status: "pending" | "authed" | "anon"; roles: string[] };
const AuthCtx = createContext<AuthState>({ status: "pending", roles: [] });

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [state, setState] = useState<AuthState>({ status: "pending", roles: [] });

  useEffect(() => {
    // verifySession reads the shared token store and validates it once.
    verifySession()
      .then((s) => setState({ status: "authed", roles: s.roles }))
      .catch(() => setState({ status: "anon", roles: [] }));
  }, []);

  return <AuthCtx.Provider value={state}>{children}</AuthCtx.Provider>;
}

export const useAuth = () => useContext(AuthCtx);

While status === "pending", the guard must render a neutral splash — never the remote and never a redirect.

Step 4 — Write the guard at the shell, not in the remote #

The guard belongs to the shell because the shell owns the URL and the auth context. It runs before the protected remote mounts, so an unauthorized user never downloads or executes the remote’s code.

// shell/src/auth/RequireAuth.tsx
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "./AuthProvider";

export function RequireAuth({
  roles,
  children,
}: {
  roles?: string[];
  children: React.ReactNode;
}) {
  const auth = useAuth();
  const location = useLocation();

  if (auth.status === "pending") {
    return <div className="auth-splash" role="status">Checking access…</div>;
  }

  if (auth.status === "anon") {
    // Preserve the intended URL so login can send the user back.
    const returnTo = location.pathname + location.search;
    return <Navigate to={`/login?returnTo=${encodeURIComponent(returnTo)}`} replace />;
  }

  if (roles && !roles.some((r) => auth.roles.includes(r))) {
    return <Navigate to="/forbidden" replace />;
  }

  return <>{children}</>;
}

Two details matter. replace keeps the failed deep link out of the back-button history. And the role check is separate from the auth check, so an authenticated-but-unauthorized user gets a 403, not a login loop.

Step 5 — Compose the guarded async route #

Now combine the pieces. The guard wraps the loading boundary, which wraps the lazy remote. The guard resolves first, so the lazy chunk is only requested once access is granted.

// shell/src/router.tsx
import { createBrowserRouter } from "react-router-dom";
import { BillingApp, ReportsApp } from "./remotes";
import { RemoteOutlet } from "./components/RemoteOutlet";
import { RequireAuth } from "./auth/RequireAuth";
import { NotFound } from "./components/NotFound";
import { Login } from "./pages/Login";

export const router = createBrowserRouter([
  { path: "/login", element: <Login /> },
  {
    path: "/billing/*",
    element: (
      <RequireAuth roles={["billing.read"]}>
        <RemoteOutlet>
          <BillingApp />
        </RemoteOutlet>
      </RequireAuth>
    ),
  },
  {
    path: "/reports/*",
    element: (
      <RequireAuth>
        <RemoteOutlet>
          <ReportsApp />
        </RemoteOutlet>
      </RequireAuth>
    ),
  },
  { path: "*", element: <NotFound /> },
]);

The trailing /* hands the remainder of the path to the remote’s own internal router, so /billing/invoices/9 deep-links straight into the billing remote once it mounts.

Step 6 — Complete the returnTo round-trip #

The login page reads returnTo, authenticates, then navigates back to the exact deep URL. Validate the target so an attacker cannot smuggle an off-site redirect through the query string.

// shell/src/pages/Login.tsx
import { useNavigate, useSearchParams } from "react-router-dom";

function safeReturnTo(raw: string | null): string {
  if (!raw) return "/";
  // Only allow same-origin, absolute internal paths.
  return raw.startsWith("/") && !raw.startsWith("//") ? raw : "/";
}

export function Login() {
  const [params] = useSearchParams();
  const navigate = useNavigate();
  const returnTo = safeReturnTo(params.get("returnTo"));

  async function onSubmit(credentials: Credentials) {
    await signIn(credentials);
    // Replace so the login page is not left in history.
    navigate(returnTo, { replace: true });
  }

  return <LoginForm onSubmit={onSubmit} />;
}

Step 7 — Handle unknown routes #

A deep link to a path no remote owns must resolve to a real 404, not a silent fallback to the index. The catch-all path: "*" route above renders NotFound. Make it return a 404 status when the shell is server-rendered, and keep it out of the guarded subtrees so an unknown URL never triggers a login redirect.

// shell/src/components/NotFound.tsx
export function NotFound() {
  return (
    <main role="main">
      <h1>404 — Page not found</h1>
      <p>That route is not owned by any application.</p>
      <a href="/">Return home</a>
    </main>
  );
}

Verification #

Confirm each behavior independently — the failure modes are easy to mask if you only test the happy path.

Refresh restores the right view. Sign in, navigate to /billing/invoices/9, then hard-refresh. The page should re-fetch the billing chunk and render invoice 9, not the billing index.

# Watch the network panel; the remoteEntry + billing chunk should load on refresh.
# Then assert in an integration test:
test("deep link hydrates the owning remote after refresh", async () => {
  setSession({ roles: ["billing.read"] });
  render(<RouterProvider router={router} />, { route: "/billing/invoices/9" });
  expect(await screen.findByText(/invoice 9/i)).toBeInTheDocument();
});

Guard blocks unauthorized access. Clear the session, request /billing/invoices/9, and assert the redirect carries the original path.

test("anonymous deep link redirects to login with returnTo", async () => {
  clearSession();
  render(<RouterProvider router={router} />, { route: "/billing/invoices/9" });
  await screen.findByLabelText(/password/i);
  expect(window.location.search).toContain(
    "returnTo=%2Fbilling%2Finvoices%2F9"
  );
});

returnTo works end to end. From that login page, submit valid credentials and confirm the browser lands back on /billing/invoices/9 with the remote mounted. Check the DevTools network panel: the billing chunk should appear only after sign-in succeeds, proving the guard ran first.

Troubleshooting #

Flash of unguarded content. The protected remote paints for a frame before the redirect fires. Diagnosis: the guard is treating pending auth as “allowed,” or the remote is mounted as a sibling of the guard instead of a child. Fix: render the splash while status === "pending" (Step 4) and make sure the lazy remote is nested inside RequireAuth, so it is never instantiated when access is denied.

Remote not loaded on first paint. Refreshing a deep link shows the index view or a blank panel. Diagnosis: the route matched before the remote chunk resolved, and there was no boundary to wait on, so React rendered nothing and the inner router fell back. Fix: keep the Suspense boundary at the route level (Step 2) and confirm the path uses /* so the remainder reaches the remote’s own router.

Guard race with the async remote. The chunk loads but the guard decision arrives late, briefly mounting then unmounting the remote. Diagnosis: verifySession() resolves after the lazy import has already started. Fix: gate rendering on settled auth — because RequireAuth returns the splash during pending, the <BillingApp /> lazy component is never reached, so its chunk request is deferred until auth resolves to authed.

Lost returnTo. After login the user lands on / instead of the deep URL. Diagnosis: the redirect was built without the current path, the query param was double-encoded, or the login navigation overwrote it. Fix: build returnTo from location.pathname + location.search (Step 4), encodeURIComponent exactly once, and read it back through safeReturnTo (Step 6) so a missing or malformed value falls back to / rather than crashing.