The Matters.AI Partner Portal (apps/partners) is a standalone Next.js App Router application served at partners.matters.ai. It is the channel-partner surface (deal registration, content library, agreements, MDF, tier benefits) plus the internal staff admin console for managing those partners — both rendered through one unified shell. This page documents how the app is wired: route groups, the host + auth middleware, the server-side gate layer, the shell/sidebar, the theme system, the CSV-vs-Salesforce data source, and the tech stack.
Related pages: Overview · Data model · Authentication · RBAC · Salesforce integration · Operations.
Every request flows through three concerns before it reaches a page or route handler:
┌──────────────────────────────────────────────────────────────┐
│ middleware.ts (clerkMiddleware wrapper) │
│ 1. Host allowlist → 404 + noindex if host not allowed │
│ 2. Public-route bypass (sign-in, webhooks, health, …) │
│ 3. Clerk auth: no userId → /sign-in?redirect_url=… │
│ 4. Org required: no orgId (non-/api, non-/admin) → sign-in │
│ with ?reason=no_org │
│ 5. Stamp X-Request-ID; in multi-account mode set │
│ Cache-Control: no-store + Vary: Cookie │
└──────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Layout gate (server component) │
│ (portal) → requirePartnerSession() │
│ (admin) → requireInternalReadSession() │
│ per-page → requireAdminWriteSession / requireSuperAdmin… │
└──────────────────────────────────────────────────────────────┘
│
▼
Page / API route handler
Middleware enforces coarse gates (authenticated + has an org). The fine-grained authorization is done in server components by the gate layer — see Authentication and RBAC for the full treatment; this page summarizes which gate guards which surface.
Source: apps/partners/middleware.ts. It wraps Clerk's clerkMiddleware and runs on the matcher "/((?!_next/static|_next/image|favicon.ico).*)".
const ALLOWED_HOSTS = new Set(["partners.matters.ai"]);
// loopback (localhost / 127.0.0.1 / ::1) is also allowed for dev
Any request whose Host header is not allow-listed gets a bare 404 Not Found with X-Robots-Tag: noindex,nofollow. This keeps the app from responding on preview/proxy hostnames and from being indexed off-domain.
createRouteMatcher defines the routes that skip the auth gate entirely:
| Pattern | Why public |
|---|---|
/sign-in(.*) | Clerk sign-in flow |
/post-auth | neutral landing target after sign-in |
/pending | partner approval pending screen |
/access-denied | rejected / not-a-partner screen |
/request-access(.*) | self-serve access request |
/accept-invite(.*) | team-invite acceptance |
/directory | the public partner directory (indexable) |
/api/sf/callout | Salesforce → portal callout endpoint |
/api/webhooks/clerk | Clerk webhook (Svix-verified) |
/api/health | uptime probe |
/api/csp-report | CSP violation collector |
For every non-public route:
const { userId, orgId } = await auth().userId → redirect to /sign-in?redirect_url=<safeReturn> (the return path is validated to start with a single /, else falls back to /dashboard).orgId, and the path is not under /api and not an admin route → redirect to /sign-in?reason=no_org. Admin routes are exempt because internal staff are gated by requireInternalReadSession() on the @matters.ai org, not by partner-org membership; isAdminRoute matches /admin and /admin/(.*).Every response is stamped with a freshly generated X-Request-ID for traceability.
A master flag NEXT_PUBLIC_MULTI_ACCOUNT_ENABLED (read into MULTI_ACCOUNT_ENABLED) gates Clerk multi-session support. When on, authenticated responses get Cache-Control: no-store, must-revalidate and Vary: Cookie so a browser Back/forward never restores a previous account's RSC payload after an account switch. When off (the default), applyAuthCacheHeaders is a no-op and single-account responses are unchanged.
The app/ directory uses Next.js route groups so that three audiences share URL space but get different layouts and gates. Run find apps/partners/app -maxdepth 3 -type d to see the full tree; the shape is:
| Group | Layout / gate | URLs (group prefix is invisible) | Serves |
|---|---|---|---|
(portal) | app/(portal)/layout.tsx → requirePartnerSession() | /dashboard, /opportunities, /opportunities/new, /opportunities/[id], /content, /agreements, /mdf, /tier-benefits, /profile, /team, /notifications | Authenticated partner surfaces |
(admin) | app/(admin)/layout.tsx → requireInternalReadSession() | /admin, /admin/partners, /admin/accounts, /admin/opportunities, /admin/mdf, /admin/content, /admin/tiers, /admin/agreements, /admin/access-requests, /admin/activity | Internal staff admin console |
(auth) | no shared shell | /sign-in, /accept-invite, /access-denied, /pending, /request-access | Unauthenticated / pre-approval flows |
api | route handlers | see API surfaces | Server endpoints (upload, export, cron, webhooks, downloads) |
directory | standalone page | /directory | The public, SEO-indexable partner directory |
Two ungrouped routes sit at the app root: app/page.tsx (entry) and app/post-auth/page.tsx (the neutral landing target — it resolves the now-active account's kind via landingFor(...) and redirects, so no account ever transits another zone's gate; only meaningfully used when the multi-account flag is on).
(portal) and (admin) are split but share a shell#The admin console is not a separate top-nav app. Both groups render the same AppShell + Sidebar; the admin group simply reveals the gated Admin nav section to internal tiers. The split exists only so each group can run a different layout-level gate — partners through requirePartnerSession(), staff through requireInternalReadSession() — while keeping one navigation tree (SIDEBAR_NAV).
All authorization beyond "authenticated + has org" happens in server code under apps/partners/lib/auth/. There are four gates; their detailed role/capability semantics live on Authentication and RBAC, so this is a map of which gate guards which surface.
| Gate (file) | Admits | Guards |
|---|---|---|
requirePartnerSession() — gate.ts | Active partner contacts (and internal staff, who bypass partner_sessions when their org is MATTERS_CLERK_ORG_ID) | The whole (portal) group via its layout |
requireInternalReadSession() — account-gate.ts | Any internal tier with admin.read (internal_team_member / admin / super_admin) | The whole (admin) group via its layout |
requireAdminWriteSession() — admin-gate.ts | Internal tiers with admin.write (admin / super_admin) | Mutating admin actions (uploads, edits, deletes) inside admin pages/handlers |
requireSuperAdminSession() / getSuperAdminClearance() — super-admin-gate.ts | super_admin only (with a bootstrap floor for the SUPER_ADMIN_EMAILS allowlist or Matters.AI org admin) | Account-type assignment (/admin/accounts) and access requests (/admin/access-requests) |
Key behaviors grounded in the code:
gate.ts) reads the cached row from partner_sessions (Neon). On a first visit with no row, it auto-provisions by pulling status from the configured source (Salesforce or CSV) via getOrRefreshPartnerStatus(...). A pending status redirects to /pending; anything non-active and non-pending redirects to /access-denied. If the Salesforce lookup errors but a cached row already exists, the user is allowed through (CSV / no-SF-credentials fallback).account-gate.ts) resolves a canonical AccountType via resolveAccountType(...), trusting only a verified Clerk primary email for any privilege decision. The bootstrap floor (MATTERS_CLERK_ORG_ID org-admin or an email in SUPER_ADMIN_EMAILS, default eshank@matters.ai) can resolve super_admin even before any DB/Clerk state exists, so the portal can never lock itself out of approvals.account-type.ts) is a pure, no-I/O module. The seven account types (pending, rejected, prospect, partner, internal_team_member, admin, super_admin) map to capabilities (portal.read/write, content.read, admin.read/write, audit.read, superadmin) via a static MATRIX. can(type, cap) is the single check used by every gate.components/shell/AppShell.tsx is a client component that lays out the chrome and owns the desktop sidebar collapse state via SidebarCollapseProvider (components/shell/sidebar-collapse.tsx). On desktop the sidebar is a fixed, full-height left column whose width tracks the collapse state — SIDEBAR_OPEN_WIDTH (expanded) ↔ SIDEBAR_RAIL_WIDTH (collapsed icon rail); content reserves the same width as a left margin and both transition together, so the page reflows smoothly with no overlay. The collapse is toggled three ways: the navbar SidebarTrigger, the edge rail on the sidebar's right border, or ⌘/Ctrl + B; the choice persists to localStorage under pm-sidebar-collapsed. On mobile (max-width: 767px) collapse does not apply — the sidebar becomes the Aceternity MobileSidebar hamburger/slide-in overlay and the topbar stays sticky below it. Both layouts paint background: var(--pm-bg).
Both layouts compose the shell identically:
<AppShell
sidebar={<Sidebar session={session} unreadNotifications={…} isSuperAdmin={…} />}
topbar={<Navbar session={session} notifications={…} unreadNotifications={…} />}
>
{children}
</AppShell>
The (portal) layout fetches notifications + unread count + super-admin clearance in parallel (Promise.all) and passes them in. The (admin) layout synthesizes a minimal PartnerSession for an internal staff member (partnerType: "internal", partnerRole: "internal_admin") and passes unreadNotifications={0} (notifications are partner-scoped). When the multi-account flag is on, both layouts also mount <ActiveSessionWatcher> to detect account switches.
components/shell/Sidebar.tsx renders a three-level tree — group → item → optional children — driven entirely by components/shell/sidebar-nav.ts (SIDEBAR_NAV). Groups: Overview, Deals, Enablement, Account, Activity, Admin.
Visibility is filtered at render time by clearance flags on each node:
| Flag | Reveals to |
|---|---|
adminOnly | Partner-org admins (session.orgRole === "org:admin") |
internalOnly | Any internal Matters.AI tier (session.partnerType === "internal") |
superAdminOnly | Internal Super Admins only (isSuperAdmin) |
visibleGroups(...) filters items and child leaves, then drops any group/parent left with nothing visible — so a partner never even sees the Admin group, and within Admin a read-only internal user sees the internalOnly leaves but not the superAdminOnly ones (/admin/accounts, /admin/access-requests). Because Super Admin satisfies every flag, it sees the entire app.
Other sidebar behaviors: section headers are real collapse toggles; the group/parent containing the active route is force-expanded; manual group expand/collapse state persists to localStorage under pm-sidebar-groups. Active-leaf resolution uses resolveActiveHref(...), which picks the longest matching href so /opportunities/new wins over /opportunities. The notifications item opts into the unread-count badge via notificationBadge. The footer shows the user email, an Admin/Member badge, the tier, and a Sign-out form posting to /api/auth/sign-out.
Collapsed (icon-rail) mode. When the desktop sidebar is collapsed (useSidebarCollapse, §AppShell), Sidebar.tsx renders an icon rail: the logo reduces to the animated bolt, group headers are dropped, and each top-level item becomes a centered icon. Leaf items keep a native title tooltip and a small accent dot when they carry an unread badge; items with children render a hover/focus flyout — a portal-rendered pm-card (so it escapes the rail's overflow) listing the parent label + child links, with the active leaf highlighted. The footer collapses to an initials avatar whose flyout holds the email, badges, and Sign-out. The expanded grouped-tree rendering is unchanged.
The portal does not use the workspace @matters/theme ws-* tokens. It has its own design language under the pm- (Partner Manager) prefix.
<html>. app/layout.tsx sets data-pm-theme="corporate" and data-pm-mode="light" on the root element and loads fonts via Next.js next/font/google — Host Grotesk (--font-host-grotesk) for display/body and IBM Plex Mono (--font-ibm-plex-mono) for mono — then wraps children in PMThemeProvider. The body font is set with fontFamily: "var(--pm-font-body)".components/theme/AnimatedThemeToggler.tsx. It flips PMThemeProvider's mode inside document.startViewTransition(() => flushSync(toggleMode)) and animates a clip-path: circle() reveal of the new theme expanding from the button (Web Animations API on ::view-transition-new(root); the default cross-fade is disabled in globals.css). Browsers without the View Transitions API — or users with prefers-reduced-motion — get an instant, correct toggle.--pm-* variables (e.g. --pm-bg, --pm-surface, --pm-ink, --pm-accent, --pm-border, --pm-radius, --pm-font-mono), defined in styles/globals.css and switched by the data-pm-* attributes.T token object. components/aceternity/tokens.ts exports a typed T map (e.g. T.bg, T.ink, T.accent, T.accentAlpha8, T.radius, T.fontMono) whose values are just the var(--pm-*) strings. Components that build inline styles (the sidebar, navbar, Aceternity primitives) reference T.* instead of hardcoding colors, so the entire chrome re-skins when the data-pm-* attributes change.globals.css also ships component classes such as pm-badge / pm-badge-active / pm-badge-pending and pm-nav-link used directly in the shell.The portal also wires third-party widgets through the theme — lib/theme/clerkAppearance.ts styles Clerk components.
Partner status (active/inactive/pending), tier, and role come from one of two backends, selected at runtime:
fetchPartnerStatusFromSF(email)
└─ if isCsvMode() → fetchPartnerStatusFromCSV(email) // Neon partner_accounts, matched by website domain
└─ else → sfQuery(SOQL) // live Salesforce Contact + Account
lib/sf/client.ts) authenticates via the JWT-bearer OAuth flow (RS256-signed assertion using SF_PRIVATE_KEY / SF_CLIENT_ID / SF_USERNAME / SF_LOGIN_URL), caches the token, and runs SOQL against the partner Contact/Account objects (Partner_Status__c, Partner_Type__c, Partner_Tier__c, Portal_Active__c, Portal_Role__c).lib/sf/csv-mode.ts) resolves status from the Neon partner_accounts table by the user's email domain, for environments without Salesforce credentials.Either way the result is cached into the Neon partner_sessions row by getOrRefreshPartnerStatus(...) with a 15-minute TTL (CACHE_TTL_MS); stale rows are refreshed lazily on access and in bulk by the revalidation cron. The full integration — JWT setup, SOQL, the inbound /api/sf/callout processor, and the CSV seed path — is documented on Salesforce integration.
From apps/partners/package.json and config:
| Concern | Choice |
|---|---|
| Framework | Next.js 16 App Router (next ^16.2.9), React 19, reactStrictMode, poweredByHeader: false |
| Language | TypeScript 5.9, strict |
| Auth | Clerk (@clerk/nextjs ^6.39) — organizations + (optional) multi-session |
| Database | Neon serverless Postgres 18 via @neondatabase/serverless + Drizzle ORM (drizzle-orm ^0.45, casing: "snake_case"); lib/db/client.ts lazily builds a singleton Pool/drizzle from PARTNERS_NEON_DATABASE_URL and falls back to the ws WebSocket constructor in Node |
| Object storage | Cloudflare R2 via the AWS S3 SDK (@aws-sdk/client-s3, presigner); bucket R2_BUCKET_PARTNERS (default matters-partners), public host partners-assets.matters.ai |
| Rate limiting | Upstash Redis + @upstash/ratelimit (UPSTASH_REDIS_REST_URL / _TOKEN) |
Resend (resend ^4) with React Email components; from partners@matters.ai | |
| Errors / monitoring | Sentry (@sentry/nextjs, project partners, tunneled via /monitoring, Vercel cron monitors) |
| Webhooks | Svix for Clerk webhook verification |
| UI | Tailwind CSS v4, Framer Motion, HeroUI, lucide-react, custom Aceternity primitives, pm-* theme |
| Validation | Zod |
| Tests | Vitest (unit) + Playwright (e2e) |
next.config.ts applies a strict header set to every route (X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy, a locked-down Permissions-Policy, HSTS with preload, and COOP/CORP). It ships both an enforced Content-Security-Policy (allowing Clerk, Cloudflare Turnstile, Sentry ingest, and the partners-assets.matters.ai image host) and a looser CSP-Report-Only policy whose violations post to /api/csp-report.
dev runs on port 4005 (next dev --webpack); app sources .env.prod first. Database scripts wrap Drizzle Kit (db:generate / db:migrate / db:push / db:studio). Operational scripts: seed:csv, seed:tiers, and bootstrap:super-admin (the Clerk script that mints the first Super Admin). See Operations.
Route handlers under app/api/ (route.ts files):
| Route | Method intent | Who | Description |
|---|---|---|---|
/api/health | GET | public | Uptime probe |
/api/csp-report | POST | public | CSP violation sink |
/api/webhooks/clerk | POST | public (Svix-signed) | Clerk user/org webhook |
/api/sf/callout | POST | public (Salesforce) | Inbound Salesforce callout → portal sync |
/api/auth/sign-out | POST | authed | Sign-out form target |
/api/auth/account-event | POST | authed | Records account/session events |
/api/upload/logo | POST | partner | Company-logo upload to R2 |
/api/upload/content | POST | admin (write) | Content-library asset upload |
/api/upload/mdf-attachment | POST | partner | MDF claim attachment upload |
/api/content/download/[id] | GET | partner | Signed content download |
/api/agreements/download/[id] | GET | partner | Signed agreement download |
/api/export/opportunities | GET | partner | Export own opportunities (CSV) |
/api/export/admin/opportunities | GET | admin | Export all opportunities (CSV) |
/api/cron/revalidate-partner-status | GET | cron | Bulk-refresh stale partner_sessions from SF/CSV |
/api/cron/lead-lifecycle-sweep | GET | cron | Lead/deal lifecycle housekeeping |
| Route | Who can reach it | Description |
|---|---|---|
/ | any (entry) | Root entry |
/post-auth | authed | Neutral post-sign-in router (kind-aware redirect) |
/sign-in | public | Clerk sign-in |
/request-access | public | Self-serve access request |
/accept-invite | public | Team-invite acceptance |
/pending | public | Approval-pending screen |
/access-denied | public | Rejected / non-partner screen |
/directory | public (indexed) | Public partner directory (revalidate = 3600) |
/dashboard | active partner / internal | Portal home |
/opportunities, /opportunities/new, /opportunities/[id] | active partner | Deal registration + detail |
/content | active partner | Content library |
/agreements | partner-org admin | Agreements (nav adminOnly) |
/tier-benefits | active partner | Tier benefits |
/profile | active partner | Company profile |
/team | partner-org admin | Team management (adminOnly) |
/mdf | partner-org admin | MDF funds (adminOnly) |
/notifications | active partner | Notifications (unread badge) |
/admin | internal (read) | Admin console overview |
/admin/partners, /admin/opportunities, /admin/mdf, /admin/content, /admin/tiers, /admin/agreements, /admin/activity | internal (read; writes need admin.write) | Manage partners, deals, MDF, content, tiers, agreements, audit log |
/admin/accounts | Super Admin | Account-type assignment (superAdminOnly) |
/admin/access-requests | Super Admin | Approve/deny access requests (superAdminOnly) |
partner_sessions, partner_accounts, partner_profiles, opportunities, MDF, agreements, content, activity).