getting-startedBuild an admin UI

Build an admin UI

What this is: the path for shipping a new admin UI on top of Loop’s platform — internal staff admin, partner-owned admin, anything that needs to talk to platform services with structured auth + audit.

Who it’s for: internal teams porting from a legacy admin, partner engineers building their own admin on top of the Loop OAuth platform, future you adding a new admin surface.

Time: half a day to a deployable admin with 1–2 pages working end-to-end.

Before you start

Read these:

  1. Admin kit — what the kit provides, how pages compose
  2. Auth model — especially the platform-admin sign-in section
  3. Audit & PHI

Tooling you need:

  • Node 22+, pnpm 9+
  • AWS SSO logged in (aws sso login --profile theloopway)
  • Doppler logged in (for Cloudflare API token via platform-infra project)
  • A WorkOS account with an AuthKit application registered

Step 1 — Scaffold the app

cd ~/Dev/loop/platform
cp -r apps/_template-admin apps/your-admin-name  # or copy apps/platform-admin

(If _template-admin doesn’t exist yet, copy apps/platform-admin and rename — the template is a clone target.)

Edit apps/your-admin-name/package.json:

{
  "name": "@apps/your-admin-name",
  "dependencies": {
    "@platform/admin-kit": "workspace:*",
    "@platform/sdk-identity": "workspace:*",
    "@platform/sdk-content": "workspace:*",
    "next": "^14.2.0",
    "react": "^18.3.0"
  }
}

Pick the SDK packages your admin will actually call.

Step 2 — Wire the providers

apps/your-admin-name/src/components/providers.tsx:

"use client";
 
import { AdminProvider } from "@platform/admin-kit";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useMemo, useState } from "react";
 
import { SERVICE_BASE_URLS } from "../lib/config";
 
export function Providers({ children, session }: { children: ReactNode; session: AdminSession }) {
  const [queryClient] = useState(() => new QueryClient());
  return (
    <QueryClientProvider client={queryClient}>
      <AdminProvider session={session} baseUrls={SERVICE_BASE_URLS}>
        {children}
      </AdminProvider>
    </QueryClientProvider>
  );
}

The session comes from the WorkOS round-trip in step 3. Until then, session can be null and <AdminPage> will gate the route.

Step 3 — WorkOS sign-in flow

Copy the four files from apps/platform-admin:

  • src/middleware.ts — checks session cookie on every /admin/* request
  • src/app/sign-in/route.ts — redirects to WorkOS AuthKit with state cookie
  • src/app/callback/route.ts — exchanges code, resolves staff roles via services/identity, sets signed cookie
  • src/app/sign-out/route.ts — clears cookie

Adjust the WORKOS_* env-var names if your admin needs its own client. Most admins should share the platform-admin client unless they’re explicitly partner-owned.

See the WorkOS setup runbook for the dashboard config + secret values.

Step 4 — A first page

apps/your-admin-name/src/app/things/page.tsx:

"use client";
 
import { AdminPage, DataTable, TableSkeleton, useResourceList } from "@platform/admin-kit";
 
export default function ThingsPage() {
  const things = useResourceList("identity", { resource: "things" });
 
  return (
    <AdminPage title="Things" scope="admin:identity">
      <DataTable
        data={things.data ?? []}
        columns={[
          { header: "Name", accessor: "name" },
          { header: "Created", accessor: "created_at", format: "date" },
        ]}
        skeleton={<TableSkeleton rows={10} />}
      />
    </AdminPage>
  );
}

That’s a complete page. Loading state, error boundary, auth gate, scope check, audit-row rendering on the detail view — all from the kit.

Step 5 — SST infra

apps/your-admin-name/infra.ts:

export function yourAdminSite() {
  const hostname =
    $app.stage === "prod"
      ? "your-admin.platform.loop.health"
      : `your-admin.${$app.stage}.platform.loop.health`;
 
  const workosClientId = new sst.Secret("YOUR_ADMIN_WORKOS_CLIENT_ID", "");
  const workosApiKey = new sst.Secret("YOUR_ADMIN_WORKOS_API_KEY", "");
  const sessionCookieSecret = new sst.Secret("YOUR_ADMIN_SESSION_COOKIE_SECRET", "");
 
  return new sst.aws.Nextjs("your-admin", {
    path: "apps/your-admin-name",
    domain: {
      name: hostname,
      dns: sst.cloudflare.dns({ proxy: false }),
    },
    environment: {
      NEXT_PUBLIC_STAGE: $app.stage,
      WORKOS_CLIENT_ID: workosClientId.value,
      WORKOS_API_KEY: workosApiKey.value,
      WORKOS_REDIRECT_URI:
        $app.stage === "prod"
          ? "https://your-admin.platform.loop.health/callback"
          : $interpolate`https://your-admin.${$app.stage}.platform.loop.health/callback`,
      SESSION_COOKIE_SECRET: sessionCookieSecret.value,
    },
  });
}

Wire it into the root sst.config.ts alongside the other apps.

Step 6 — Set the secrets

# Generate cookie secret
openssl rand -base64 32  # paste below
 
# Set per-stage via Doppler-wrapped sst
doppler run --project platform-infra --config dev -- \
  pnpm sst secret set YOUR_ADMIN_WORKOS_CLIENT_ID 'client_...' --stage dev
doppler run --project platform-infra --config dev -- \
  pnpm sst secret set YOUR_ADMIN_WORKOS_API_KEY 'sk_test_...' --stage dev
doppler run --project platform-infra --config dev -- \
  pnpm sst secret set YOUR_ADMIN_SESSION_COOKIE_SECRET '<paste cookie secret>' --stage dev

The Doppler wrapper supplies CLOUDFLARE_API_TOKEN which SST needs for DNS.

Step 7 — Configure WorkOS

In the WorkOS dashboard, on your application:

  1. RedirectsRedirect URIs → add https://your-admin.dev.platform.loop.health/callback
  2. Authentication → Methods → enable at least one (Magic Auth is simplest)

See the WorkOS setup runbook for full steps.

Step 8 — Deploy + test

Every push to main auto-deploys dev. To deploy manually:

doppler run --project platform-infra --config dev -- pnpm sst deploy --stage dev

Then visit https://your-admin.dev.platform.loop.health — you should land on /sign-in, get redirected to WorkOS, complete Magic Auth, and arrive at your admin.

What you got for free

By following this path, your admin inherits:

  • WorkOS staff session with signed HttpOnly cookie
  • Bearer-token auth to any platform service via the SDK
  • Brand-scoped data access via useBrandContext
  • Inline audit log rendering on detail views
  • Tailwind v3 + shadcn primitives + Loop brand tokens
  • TanStack Query revalidation, optimistic updates, error boundaries
  • Convention-check enforcement (no raw fetch, no inline styles, no legacy @loop/*)

What you still need to think about

  • Which services your admin talks to. Pick @platform/sdk-* packages selectively.
  • Scopes. Every page that mutates should be wrapped in <RequireScope scope="admin:X">. Staff who lack the scope see a friendly denial, not a 401 in the dev console.
  • Brand scoping. If your admin operates across brands, use <BrandPicker>. If it’s brand-specific, hard-code the brand in the provider.
  • PHI awareness. Pages that surface PHI must use the audit-aware patterns described in Audit & PHI.