Dashboard overview
How Papervine's control plane is laid out — the app host, the org/site switcher, and the per-site Overview home.
How Papervine's control plane is laid out — the app host, the org/site switcher, and the per-site Overview home.
The control plane is where you operate a Papervine site: connect a repo, watch syncs, read analytics, manage domains, and change settings. It is a separate surface from the rendered docs — see architecture for how the two planes split.
The control plane lives on its own host: app.papervine.io (app.localhost:3000 in
local development). Tenant docs render on subdomains or apex paths; the dashboard never
shares their namespace.
Why a dedicated host? Papervine is one Next.js app with a single route tree across
every host. A bare [org] segment at the apex root would shadow the docs catch-all —
every /guide-style docs path would resolve to the dashboard instead of a page. Keeping
the control plane on app. frees the apex and tenant namespaces entirely for docs.
The dashboard is URL-scoped, with a familiar app.<your-domain>/{org}/{site} shape. A site’s home is
the bare path app.papervine.io/:org/:site (in dev, app.localhost:3000/dev-org/starter).
The active site is the URL, not a cookie — so dashboard URLs are shareable, bookmarkable,
and work correctly across multiple tabs.
Behind the scenes the route files live at an invisible /app mount. middleware.ts
Host-rewrites app.* bare /:org/:site to /app/:org/:site — the same rewrite trick tenant
subdomains use to reach /sites/{slug}. It also bounces auth and stray /app hits on the
apex over to the app host, so the session cookie is always set on app..
When linking or redirecting within the control plane, use the public bare path for
links and redirects, and the internal /app path only for revalidatePath. Mixing
the two is a bug. Cross-context hops (apex → app, or a server-action redirect into a
rewritten path) must use a client hard-navigation — a soft RSC nav skips the Host
rewrite and lands on the apex. See gotchas.
An organization can own several sites. The top-left switcher selects the active site that per-site pages (Overview, Analytics, Settings) scope to. It lists every site you can access plus a New site action.
Because the active site is encoded in the URL, switching sites simply navigates: the switcher preserves the current sub-page, so jumping from one site’s Analytics to another’s keeps you on Analytics.
Each site shows a small avatar — a colored gradient square with the site’s initial. The color is derived from the site’s slug, so every site gets its own hue and two sites that share a first letter still look distinct, making the list easy to scan at a glance.
The control plane is built from a small set of shadcn/ui primitives in
src/components/ui/ — buttons, inputs, selects, cards, tables, dialogs, dropdown menus, and a
slide-in sheet. Each keeps shadcn’s structure but speaks the platform’s dark .db visual
language rather than stock shadcn colors, so the whole dashboard reads as one surface and none
of it leaks into the per-tenant docs renderer. Forms, the delete-confirmation dialog, and the
site switcher are all assembled from these primitives.
Interactive overlays — the dialog, the dropdown menu, the mobile drawer — render at the end of
the page (a portal), outside the .db shell, so they carry a lightweight .db-portal token
scope to keep the platform palette and fonts. See gotchas.
The dashboard adapts to the viewport:
The switcher and every page work the same way at both sizes — only the chrome rearranges.
The control plane has its own light and dark themes, separate from the per-tenant docs theme. A sun/moon toggle in the navigation footer flips between them, and your choice is remembered and applied before the page paints, so there’s no flash on reload.
The two themes are exact mirrors rather than two hand-built palettes: every translucent surface is drawn from a single “ink” channel that is white on the dark background and black on the light one, at the same opacity. So the dark theme is unchanged from before, and light is its reflection — the brand blue→violet accent and the status colors (green/red/amber) stay the same in both. Pop-over surfaces (menus, the delete-confirmation dialog, the mobile drawer) follow the theme too, even though they render outside the main shell — see gotchas.
The Overview is the per-site landing page — the default destination on entering a connected site. It is a consolidation surface: every panel is a window onto a system specified elsewhere (sync, the web editor, domains, routines), not a new capability. From top to bottom:
A time-of-day greeting with your first name (“Good afternoon, Jeff”). Cosmetic.
A scaled, real render of the tenant’s home page in an iframe, so you see your live site at a glance.
The site name and a Live status pill (green when the last deploy succeeded; degraded and failed states reuse the deployment status). Shows Last updated <relative time> by <author> from the latest deployment, plus quick actions: Sync (a manual sync, the same path as Projects’ manual sync) and Open editor. Below: the Domain, the repo, and the branch.
A dismissible CTA linking into Autopilot › Routines. Shows until the org has configured a routine.
The deployment/sync history, with a Live / Previews toggle (live deploys vs per-branch preview builds).
The feed is the same deployment-backed history the control plane already renders; the
Overview is its home. Each row shows:
A sync that lands while you are on the page appears without a reload — and resolves Building → Successful in place. Two mechanisms drive that, and the feed works on the slower one alone:
The durable deployment row is the source of truth either way: a sync inserts it as
building before the slow work and flips it to successful or failed at the end. Both
mechanisms simply prompt the client to re-read it.
The feed is a client component seeded with the server-rendered rows (first paint is
unchanged; SSR stays the source of truth). It refetches a bare /:org/:site/activity
JSON endpoint — authorized exactly like the page (session → membership → org-scoped site),
so there is no cross-tenant leak. The poll cadence is adaptive: a building row means a
sync is in flight, so it polls ~2.5s to catch the transition; once everything is settled it
idles at ~20s; a backgrounded tab pauses and refreshes on focus.
Updates arrive over a WebSocket using the Pusher protocol. Because serverless functions can’t hold a socket open, Papervine doesn’t run a socket server on its deploy target — instead it speaks a protocol that has both a server you can run yourself and a managed equivalent, swapped by environment (the same pattern as Postgres and object storage):
docker-compose.When a sync starts and finishes, the sync runner publishes a content-free ping on a
per-site private channel; the browser, subscribed to that channel, reacts by re-running
its normal authorized /activity fetch. No feed data ever transits the realtime host, and
subscriptions are gated by an auth endpoint that reuses the same org-membership check as the
page — a tenant can’t watch another’s channel.
Realtime is strictly optional. With its environment variables unset (for example in CI), nothing subscribes or publishes and the feed runs on the adaptive poll alone — exactly the behavior described above. A missing or broken realtime backend can never break a sync or a page.
An in-flight sync’s building pill counts up live (“Building 0:14”, with a pulsing dot),
so a running sync reads as active rather than a static label. This is purely client-side: the
row already carries its createdAt, so the feed ticks a 1s clock (only while something is
building) and renders the elapsed time. The counter is scoped to live runs — an older
building row is treated as a killed or orphaned run and drops the counter rather than
ticking to infinity.
The next step is intra-sync progress (per-file upload, streaming logs): now that the publish path exists, the sync runner can emit granular progress events on the same channel.
The dashboard is a client-navigated app: switching tabs fetches the next view in the background rather than reloading the page. If one of those fetches fails — a dropped connection, a momentary network blip — the affected view degrades to a recoverable card inside the dashboard, not a blank or broken page. The navigation rail and your session stay put; the content area shows a short explanation and a Try again button that re-loads just that view. For a transient hiccup, retrying simply works.
This is a route-level error boundary, scoped so a failure in one view never takes down the whole app. A failed background navigation used to be able to escalate into a full-screen crash; the boundary catches it first and keeps the chrome — light or dark — intact. The error is still reported for monitoring, but as a handled, recovered event.