Admin area
ShipVeryFast ships an internal admin surface under /admin for configuring the app: analytics, pricing, deployment configs, the launch checklist, and user preferences. This page walks through every route, how access is gated by the ADMIN_EMAILS allowlist, and how each panel ties back to its backing library.
The middleware.ts matcher only requires that admin visitors are authenticated, it does not check who they are. The actual admin gate lives in libs/admin.ts (isAdmin) and is applied per route handler. Wire it into the admin pages before you ship. See Gating admin access below.
Admin routes under app/admin
Every admin page lives under app/admin/ and renders inside app/admin/layout.tsx, which composes an AdminSidebar, the shared DashboardTopbar, and a Toaster. The sidebar (components/admin/AdminSidebar.tsx) is the source of truth for the navigation order.
| Route | File | What it does |
|---|---|---|
/admin | app/admin/page.tsx | Overview: Dashboard, FeatureFlagsManager, ComponentPreviewer. |
/admin/analytics | app/admin/analytics/page.tsx | Analytics dashboard (AnalyticsClient). |
/admin/pricing | app/admin/pricing/page.tsx | Build and preview pricing plans (PricingClient). |
/admin/deployment | app/admin/deployment/page.tsx | Generate platform deploy configs (DeploymentClient). |
/admin/launch | app/admin/launch/page.tsx | Launch checklist + SEO readiness checker. |
/admin/preferences | app/admin/preferences/page.tsx | User preferences manager (UserPreferencesManager). |
Most pages are thin: the page.tsx exports metadata and renders a single client component (for example app/admin/pricing/page.tsx just returns <PricingClient />). The /admin overview and /admin/launch compose a few components directly.
Gating admin access with the ADMIN_EMAILS allowlist
Admin routes are matched by middleware.ts with '/admin/:path*', which runs them through NextAuth's withAuth, so an unauthenticated visitor is redirected to login. That only proves the user is signed in, not that they are an admin.
The real admin check is isAdmin(session) in libs/admin.ts. It compares the session email against a comma-separated allowlist read from the ADMIN_EMAILS environment variable. It fails closed: if ADMIN_EMAILS is unset or empty, nobody is treated as an admin.
// libs/admin.ts
export function isAdmin(session: Session | null): boolean {
if (!session?.user?.email) {
return false;
}
const allowlist = (process.env.ADMIN_EMAILS ?? '')
.split(',')
.map((e) => e.trim().toLowerCase())
.filter(Boolean);
if (allowlist.length === 0) {
return false; // fails closed when ADMIN_EMAILS is unset/empty
}
return allowlist.includes(session.user.email.toLowerCase());
}Matching is case-insensitive and whitespace around each address is trimmed, so ADMIN_EMAILS=" Admin@Example.com , second@example.com " authorizes both addresses. Set it in your environment:
# .env.local
ADMIN_EMAILS=admin@example.com,owner@yourdomain.comADMIN_EMAILS is read directly from process.env and is not part of the Zod schema in libs/config.ts, so it is not validated at startup, a missing value will not throw, it will simply deny everyone. It already exists today in app/api/security/audit/route.ts and app/api/security/alerts/route.ts, which call isAdmin(session) and return 403 when it is false.
As the JSDoc in libs/admin.tsnotes, the cleaner long-term gate is a NextAuth session callback that surfaces the user's role onto the session so you can check session.user.role === 'admin'. The email allowlist is the correct gate that requires no auth-config changes.
Pricing management UI and how it maps to pricingPlans.ts
/admin/pricing renders PricingClient, a plan builder with three modes toggled by buttons: the editor (PricingPlanEditor), a Preview of how the plan renders to users (PricingPlans plus an optional PricingComparison when comparisonEnabled is set), and a View Code mode that emits a copy-paste snippet of your plan as JSON.
The editor is seeded with createFreemiumPricingPlan() from libs/pricingPlans.ts, and every plan conforms to the PricingPlan interface defined there. That same file holds the canonical pricingPlans array (the free / pro / enterprise tiers) and the helpers your app actually consumes:
| Export | Purpose |
|---|---|
pricingPlans | The default Starter / Professional / Enterprise tiers. |
getPlanById(id) | Look up a single plan by its id. |
getPopularPlan() | Returns the plan flagged popular: true. |
getStripePriceId(planId) | Server-only: resolves the real Stripe price ID from env vars. |
createFreemiumPricingPlan() | Default seed plan used by the admin editor. |
Note that the admin editor is a design tool, not a persistence layer: in PricingClient, handleSavePlan currently logs the plan and shows an alert. To make a plan live, copy the JSON into pricingPlans.ts (or wire handleSavePlan to your own store). Real Stripe price IDs never get hardcoded, getStripePriceId reads STRIPE_PRICE_BASIC, STRIPE_PRICE_PRO, and STRIPE_PRICE_ENTERPRISE from the environment.
User preferences management
/admin/preferences renders UserPreferencesManager (the component), which is backed by libs/userPreferences.ts. That library exposes a singleton UserPreferencesManager class and a useUserPreferences() React hook. Preferences are persisted to localStorage under the key userPreferences and merged over DEFAULT_PREFERENCES on load, so a partial stored object never breaks the shape.
The UserPreferences type covers theme, isDarkMode, font, language, and nested notifications, layout, and accessibility groups. Use the hook to read and update:
import { useUserPreferences } from '@/libs/userPreferences';
function ThemeToggle() {
const { preferences, updatePreferences, updateNestedPreferences } =
useUserPreferences();
// top-level update
updatePreferences({ isDarkMode: true });
// nested update (typed to object-valued keys only)
updateNestedPreferences('notifications', { marketing: false });
return <span>{preferences.theme}</span>;
}Because storage is localStorage, preferences are per-browser, not synced to the database. The manager also exposes exportPreferences() / importPreferences() (JSON) and resetPreferences() if you want to add backup or sync on top.
The launch checklist panel
/admin/launch presents two tabs: Launch Checklist (LaunchChecklist) and SEO Checker (SeoReadinessChecker). The checklist is powered by libs/launchChecklist.ts, a browser-safe module (Edge-compatible, it stores state in localStorage under shipveryfast-launch-checklist, not on disk).
getDefaultChecklistItems() returns a curated set of items grouped by category. The category enum in ChecklistItemSchema is:
// libs/launchChecklist.ts, ChecklistItemSchema.category
category: z.enum([
'functionality',
'security',
'performance',
'accessibility',
'seo',
'legal',
'analytics',
'deployment',
]),Each item has a required flag and an optional validationFn. Progress is computed by calculateProgress() over the required items only, and a couple of items (seo-1, perf-1) can self-validate via validateChecklistItem(), which maps to validateMetaTags and validateLighthouseScore (the Lighthouse check is a mock you can swap for a real PageSpeed call). Helpers worth knowing:
| Function | Purpose |
|---|---|
createChecklist(name, description) | Create and persist a new checklist from the defaults. |
updateChecklistItem(id, updates) | Toggle/annotate an item; stamps dateCompleted on completion. |
generateLaunchReadinessReport() | Returns readiness %, required-item counts, and per-category summary. |
isReadyForLaunch() | true only when every required item is completed. |
Deployment panel and deploymentConfig.ts
/admin/deployment renders DeploymentClient, which wraps the DeploymentConfigGenerator component. It generates ready-to-commit platform config files from libs/deploymentConfig.ts. The supported targets are defined by the DeploymentPlatform enum:
| Platform | Generated file |
|---|---|
vercel | vercel.json |
netlify | netlify.toml |
railway | railway.json |
render | render.yaml |
The library validates configs with Zod (one schema per platform, e.g. VercelConfigSchema) and exposes per-platform generators (generateVercelConfig, generateNetlifyConfig, and so on). The single entry point is generateDeploymentConfig(config), which switches on config.platform and returns { fileName, content, type }. There is also generateGitHubActionsWorkflow(config) for a CI workflow. The panel is a generator, copy the output into your repo at the indicated filename.
Extending the admin layout safely
To add a panel, follow the existing pattern and keep the gate intact:
- Create
app/admin/your-panel/page.tsxexportingmetadataand a server component that renders your client component. - Add the route to the
navarray incomponents/admin/AdminSidebar.tsx(label,href, and a lucide icon) so it shows in the sidebar. - Enforce
isAdmin(session)for any data the panel reads or mutates, the/adminmatcher inmiddleware.tsonly guarantees authentication, not admin role.
For new server endpoints behind the admin UI, mirror app/api/security/audit/route.ts: get the session, call isAdmin(session), and return 403 when it is false. Keep secrets like Stripe price IDs in environment variables (read them server-side via helpers such as getStripePriceId) rather than embedding them in client components.
Next steps
- Security overview, how the admin gate fits the broader model.
- Payments, wiring the pricing plans you design here to Stripe.
- Deployment, using the configs the deployment panel generates.
