GUIDES

Production-grade guides for building with FABRK. Each guide explains architecture decisions, includes complete code, and covers what to watch out for in production.

[GUIDE INDEX]

[BUILD A DASHBOARD]

Build a complete admin dashboard with KPIs, charts, data tables, and sidebar navigation. This guide covers the full flow from project setup through responsive layout, and explains the design system rules that keep your UI consistent across all 18 themes.

[WHY THIS APPROACH]
FABRK components use the mode design system for theme-aware styling through CSS variables. This means your dashboard works with all 18 built-in themes without any code changes. The key rule: full borders (like border) always get mode.radius, while partial borders (like border-r on the sidebar) never do. Table cells also never get radius.

1. SCAFFOLD THE PROJECT

The dashboard template includes pre-configured sidebar, theme support, and example pages. If you already have an existing project, skip to step 2 and install the packages manually.

terminal
npx create-fabrk-app my-dashboard --template dashboard
cd my-dashboard
pnpm install

2. CREATE THE SIDEBAR LAYOUT

The sidebar uses a partial border (border-r) which means it does NOT get mode.radius. This is a critical design system rule: partial borders define edges, not containers, so rounded corners would look broken. Active nav items use a left border accent (border-l-2) which is also partial and stays sharp.

app/dashboard/layout.tsx
'use client'

import { cn } from '@/lib/utils'
import { mode } from '@fabrk/design-system'

const navItems = [
  { id: 'overview', label: 'OVERVIEW', href: '/dashboard' },
  { id: 'analytics', label: 'ANALYTICS', href: '/dashboard/analytics' },
  { id: 'users', label: 'USERS', href: '/dashboard/users' },
  { id: 'settings', label: 'SETTINGS', href: '/dashboard/settings' },
]

export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex min-h-screen">
      {/* Sidebar — partial border (border-r), NO mode.radius */}
      <aside className="hidden md:flex w-64 border-r border-border bg-card p-4 shrink-0 flex-col">
        <div className={cn('text-primary font-bold text-lg mb-8', mode.font)}>
          {'>'} MY APP
        </div>
        <nav className="space-y-1 flex-1">
          {navItems.map((item) => (
            <a
              key={item.id}
              href={item.href}
              className={cn(
                'block px-3 py-2 text-xs transition-colors',
                mode.font,
                'text-muted-foreground hover:text-foreground'
              )}
            >
              {item.label}
            </a>
          ))}
        </nav>
        <div className="text-xs text-muted-foreground border-t border-border pt-3">
          [v1.0.0]
        </div>
      </aside>
      <main className="flex-1 overflow-y-auto">{children}</main>
    </div>
  )
}
[RESPONSIVE TIP]
The sidebar uses hidden md:flex to collapse on mobile. For mobile navigation, use the MobileNav component from @fabrk/components which provides a slide-out drawer with the same nav items.

3. ADD KPI CARDS AND CHARTS

This is the main dashboard page. It combines KPI cards for at-a-glance metrics, a BarChart for categorical data, a LineChart for trends, and a DataTable for detailed records. Pay close attention to the chart props.

[CHART API EXPLAINED]
Both BarChart and LineChart require three props: data (array of objects), xAxisKey (string identifying which key in each data object maps to the X axis), and series (array of { dataKey, name } objects defining each line or bar). The dataKey must match a numeric key in your data objects. The name appears in tooltips and legends. Optional props include showLegend, showGrid, height, and formatter functions for axes and tooltips.
src/app/dashboard/page.tsx
'use client'

import { KPICard, Card, Badge, BarChart, LineChart, DataTable } from '@fabrk/components'
import { cn } from '@/lib/utils'
import { mode } from '@fabrk/design-system'

// KPI data — in production, fetch this from your API
const stats = [
  { title: 'REVENUE', value: '$48,290', trend: 12.5 },
  { title: 'USERS', value: '3,847', trend: 8.3 },
  { title: 'ORDERS', value: '1,024', trend: -2.1 },
  { title: 'UPTIME', value: '99.97%', trend: 0.1 },
]

// Bar chart data — each object has an X-axis key and numeric value keys.
// xAxisKey="day" tells the chart which field labels the X axis.
// series=[{ dataKey: 'revenue', name: 'Revenue' }] tells it which field(s) to plot as bars.
const revenueByDay = [
  { day: 'Mon', revenue: 6200, costs: 2100 },
  { day: 'Tue', revenue: 7800, costs: 2400 },
  { day: 'Wed', revenue: 5400, costs: 1900 },
  { day: 'Thu', revenue: 8200, costs: 2800 },
  { day: 'Fri', revenue: 9100, costs: 3100 },
  { day: 'Sat', revenue: 4300, costs: 1500 },
  { day: 'Sun', revenue: 3800, costs: 1200 },
]

// Line chart data — uses the same xAxisKey/series pattern.
// Multiple series overlay lines on the same chart.
const trafficTrend = [
  { date: 'Jan', pageViews: 12400, uniqueVisitors: 4200 },
  { date: 'Feb', pageViews: 15800, uniqueVisitors: 5100 },
  { date: 'Mar', pageViews: 14200, uniqueVisitors: 4800 },
  { date: 'Apr', pageViews: 18900, uniqueVisitors: 6300 },
  { date: 'May', pageViews: 22100, uniqueVisitors: 7600 },
  { date: 'Jun', pageViews: 19800, uniqueVisitors: 6900 },
]

// Table data
const recentUsers = [
  { name: 'Jason Poindexter', email: 'jason@example.com', role: 'Admin', status: 'Active', joined: '2025-01-15' },
  { name: 'Sarah Chen', email: 'sarah@example.com', role: 'Editor', status: 'Active', joined: '2025-02-03' },
  { name: 'Mike Torres', email: 'mike@example.com', role: 'Viewer', status: 'Invited', joined: '2025-02-20' },
  { name: 'Lisa Park', email: 'lisa@example.com', role: 'Editor', status: 'Active', joined: '2025-02-18' },
]

const columns = [
  { key: 'name', label: 'NAME', sortable: true },
  { key: 'email', label: 'EMAIL', sortable: true },
  { key: 'role', label: 'ROLE' },
  { key: 'status', label: 'STATUS' },
  { key: 'joined', label: 'JOINED', sortable: true },
]

export default function DashboardPage() {
  return (
    <div className="p-6 space-y-6">
      {/* Header */}
      <div>
        <h1 className={cn('text-xl font-bold uppercase', mode.font)}>OVERVIEW</h1>
        <p className="text-sm text-muted-foreground">Your dashboard at a glance.</p>
      </div>

      {/* KPI row — responsive: 2 columns on mobile, 4 on desktop */}
      <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
        {stats.map((s) => (
          <KPICard key={s.title} title={s.title} value={s.value} trend={s.trend} />
        ))}
      </div>

      {/* Charts row — stacks vertically on mobile, side-by-side on desktop */}
      <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
        {/* BarChart: revenue + costs by day of week */}
        <Card className={cn('p-6 border border-border', mode.radius)}>
          <h3 className={cn('text-xs uppercase text-muted-foreground mb-4', mode.font)}>
            [WEEKLY REVENUE VS COSTS]
          </h3>
          <BarChart
            data={revenueByDay}
            xAxisKey="day"
            series={[
              { dataKey: 'revenue', name: 'Revenue' },
              { dataKey: 'costs', name: 'Costs' },
            ]}
            showLegend
            yAxisFormatter={(value) => `$${(value / 1000).toFixed(0)}k`}
            tooltipFormatter={(value) => `$${value.toLocaleString()}`}
          />
        </Card>

        {/* LineChart: traffic trend with two series */}
        <Card className={cn('p-6 border border-border', mode.radius)}>
          <h3 className={cn('text-xs uppercase text-muted-foreground mb-4', mode.font)}>
            [TRAFFIC TREND]
          </h3>
          <LineChart
            data={trafficTrend}
            xAxisKey="date"
            series={[
              { dataKey: 'pageViews', name: 'Page Views' },
              { dataKey: 'uniqueVisitors', name: 'Unique Visitors', dashed: true },
            ]}
            showLegend
            yAxisFormatter={(value) => `${(value / 1000).toFixed(0)}k`}
          />
        </Card>
      </div>

      {/* Data table — Card gets mode.radius because it has a full border */}
      <Card className={cn('border border-border', mode.radius)}>
        <div className="p-4 border-b border-border flex items-center justify-between">
          <h3 className={cn('text-xs uppercase text-muted-foreground', mode.font)}>
            [RECENT USERS]
          </h3>
          <Badge variant="secondary">[{recentUsers.length} TOTAL]</Badge>
        </div>
        <DataTable columns={columns} data={recentUsers} />
      </Card>
    </div>
  )
}

4. CHART VARIATIONS

Both chart components support extensive customization. Here are common patterns you will use in production dashboards.

chart variations
// Stacked bar chart — group bars by a shared stackId
<BarChart
  data={revenueByDay}
  xAxisKey="day"
  series={[
    { dataKey: 'revenue', name: 'Revenue', stackId: 'money' },
    { dataKey: 'costs', name: 'Costs', stackId: 'money' },
  ]}
  showLegend
/>

// Horizontal bar chart — useful for ranking/leaderboard views
<BarChart
  data={topPages}
  xAxisKey="page"
  series={[{ dataKey: 'views', name: 'Views' }]}
  horizontal
  height={400}
/>

// Color each bar differently by index (no series colors needed)
<BarChart
  data={categoryBreakdown}
  xAxisKey="category"
  series={[{ dataKey: 'value', name: 'Value' }]}
  colorByIndex
/>

// Line chart with stepped interpolation (good for pricing tiers, discrete values)
<LineChart
  data={pricingHistory}
  xAxisKey="month"
  series={[
    { dataKey: 'price', name: 'Price', type: 'step', showDots: true, dotSize: 6 },
  ]}
  yAxisFormatter={(v) => `$${v}`}
/>

// Card-wrapped chart using the built-in BarChartCard/LineChartCard
// These add a terminal-style header with code prefix and title
import { BarChartCard, LineChartCard } from '@fabrk/components'

<BarChartCard
  title="Revenue"
  code="0xA1"
  data={revenueByDay}
  xAxisKey="day"
  series={[{ dataKey: 'revenue', name: 'Revenue' }]}
/>

<LineChartCard
  title="Traffic"
  code="0xB2"
  description="Last 6 months"
  data={trafficTrend}
  xAxisKey="date"
  series={[{ dataKey: 'pageViews', name: 'Page Views' }]}
/>

5. ADD FEATURE FLAGS AND NOTIFICATIONS

Feature flags let you ship dark features and gradually roll them out. The notification center gives users real-time updates without leaving the dashboard.

feature flags + notifications
import { useFeatureFlag, useNotifications } from '@fabrk/core'
import { NotificationCenter } from '@fabrk/components'

function DashboardPage() {
  const { enabled: showAnalytics } = useFeatureFlag('advanced-analytics')
  const { manager, notifications } = useNotifications()

  return (
    <div className="p-6 space-y-6">
      {/* Header with notification center */}
      <div className="flex items-center justify-between">
        <h1 className={cn('text-xl font-bold uppercase', mode.font)}>OVERVIEW</h1>
        <NotificationCenter
          notifications={notifications}
          onMarkRead={(id) => manager.markRead(id, userId)}
          onMarkAllRead={() => manager.markAllRead()}
        />
      </div>

      <KPICards />
      <Charts />

      {/* Only render when feature flag is enabled */}
      {showAnalytics && <AdvancedAnalytics />}

      <RecentUsers />
    </div>
  )
}
[DESIGN SYSTEM RULES SUMMARY]
  • Full borders (border, border-2) ALWAYS get mode.radius
  • Partial borders (border-t, border-b, border-l, border-r) NEVER get mode.radius
  • Table cells (th, td) NEVER get mode.radius
  • Use design tokens (bg-card, text-foreground, border-border) not hardcoded colors
  • Headlines and labels are UPPERCASE, body text is sentence case
  • Buttons use > prefix: > SUBMIT

[AUTHENTICATION SETUP]

FABRK auth provides three layers: session-based auth (NextAuth), API key auth (SHA-256 hashed), and MFA (TOTP RFC 6238 with backup codes). This guide covers all three, including the middleware chain that protects your routes, and explains the security decisions behind each layer.

[ARCHITECTURE DECISIONS]
Why SHA-256 for API keys? API keys are hashed before storage using Web Crypto SHA-256. The raw key is returned exactly once at creation time. If your database is compromised, attackers cannot recover working keys from the hashes. We use Web Crypto (not Node.js crypto) for edge runtime compatibility.

Why the adapter pattern? All auth operations go through the AuthAdapter interface. You can swap NextAuth for Clerk, Auth0, or any custom provider by implementing the same interface. Your route handlers never change.

Why callback props on MFA components? Components like MfaSetupDialog accept callbacks (onSetup) instead of making API calls directly. This keeps the component package free of server dependencies and lets you control exactly how secrets are stored.

1. CONFIGURE AUTH

fabrk.config.ts
import { defineFabrkConfig } from '@fabrk/config'

export default defineFabrkConfig({
  auth: {
    adapter: 'nextauth',
    apiKeys: true,              // Enable API key auth
    mfa: true,                  // Enable TOTP MFA
    config: {
      providers: ['google', 'credentials'],
    },
  },

  // Rate limiting protects login endpoints from brute-force attacks
  security: {
    rateLimit: true,
  },
})

2. PROTECT ROUTES WITH MIDDLEWARE

FABRK provides three middleware wrappers: withAuth (session only), withApiKey (API key only), and withAuthOrApiKey (either). Each wraps your route handler and returns 401/403 automatically if auth fails. The API key middleware also supports scope checking.

src/app/api/dashboard/stats/route.ts
import { withAuth, withApiKey, withAuthOrApiKey } from '@fabrk/auth'
import { createNextAuthAdapter } from '@fabrk/auth'

const auth = createNextAuthAdapter({ /* your NextAuth config */ })

// Session-only: only browser users with active sessions
export const GET = withAuth(auth, async (req, session) => {
  // session has: { userId, email, role }
  const stats = await db.stats.findFirst({
    where: { teamId: session.userId },
  })
  return Response.json(stats)
})

// API key only: for external integrations and CLI tools
// Requires 'read:stats' scope — returns 403 if the key lacks it
export const POST = withApiKey(auth, async (req, keyInfo) => {
  // keyInfo has: { id, name, scopes, userId, active, expiresAt }
  const body = await req.json()
  return Response.json({ received: body })
}, { requiredScopes: ['read:stats'] })

// Either session OR API key: flexible endpoints used by both web and API
export const PUT = withAuthOrApiKey(auth, async (req, { session, apiKey }) => {
  // Exactly one of session or apiKey will be defined
  const userId = session?.userId ?? apiKey?.userId
  if (!userId) {
    return new Response(JSON.stringify({ error: 'No user context' }), { status: 401 })
  }

  // For session auth, check role manually (scopes only apply to API keys)
  if (session && session.role !== 'admin') {
    return new Response(JSON.stringify({ error: 'Admin required' }), { status: 403 })
  }

  return Response.json({ ok: true })
})

3. GENERATE AND VALIDATE API KEYS

API keys use the format fabrk_live_xxxx (or fabrk_test_xxxx). The random part uses base62 encoding with rejection sampling to eliminate modulo bias. Keys are at least 16 bytes of entropy. The hash prefix (sha256:) is stored alongside the hex digest for future algorithm upgrades.

src/app/api/keys/route.ts
import { generateApiKey, hashApiKey, createApiKeyValidator } from '@fabrk/auth'
import { withAuth } from '@fabrk/auth'

const auth = createNextAuthAdapter({ /* config */ })

// POST /api/keys — Create a new API key (session-authenticated users only)
export const POST = withAuth(auth, async (req, session) => {
  const { name, scopes = ['*'] } = await req.json()

  // Generate a secure key — returns { key, prefix, hash }
  // key:    "fabrk_live_a1b2c3d4e5f6..." (shown to user ONCE)
  // prefix: "fabrk_live_a1b2c3"          (stored for display in settings)
  // hash:   "sha256:abcdef..."            (stored for validation)
  const { key, prefix, hash } = await generateApiKey({
    prefix: 'fabrk',
    environment: 'live',
    keyLength: 32,  // 32 bytes of entropy (default)
  })

  // Store the hash (NEVER the raw key) and metadata
  await db.apiKey.create({
    data: {
      hash,
      prefix,
      userId: session.userId,
      name,
      scopes,
      active: true,
      // Optional: set expiration for time-limited keys
      // expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
    },
  })

  // Return the raw key exactly once. After this, it cannot be recovered.
  return Response.json({
    key,
    prefix,
    name,
    message: 'Save this key now. It will not be shown again.',
  })
})

// DELETE /api/keys/[id] — Revoke an API key
export const DELETE = withAuth(auth, async (req, session) => {
  const id = new URL(req.url).searchParams.get('id')
  if (!id) {
    return new Response(JSON.stringify({ error: 'Missing key ID' }), { status: 400 })
  }

  // Ensure the user owns this key
  const key = await db.apiKey.findFirst({
    where: { id, userId: session.userId },
  })

  if (!key) {
    return new Response(JSON.stringify({ error: 'Key not found' }), { status: 404 })
  }

  await db.apiKey.update({
    where: { id },
    data: { active: false },
  })

  return Response.json({ revoked: true })
})
[PRODUCTION WARNING: EXPIRED KEY HANDLING]
When querying API keys by hash, always filter for expiration in the database query:WHERE hash = ? AND active = true AND (expires_at IS NULL OR expires_at > NOW())Checking active: true alone does not exclude expired keys. The FABRK validator handles this correctly, but if you write custom queries, include the expiration check.

4. ADD RATE LIMITING TO AUTH ENDPOINTS

Login and API key endpoints are prime targets for brute-force attacks. Apply rate limiting before authentication, not after.

src/app/api/auth/login/route.ts
import { createMemoryRateLimiter } from '@fabrk/security'

// In production, use UpstashRateLimiter for distributed rate limiting
// across multiple server instances. Memory rate limiter only works for
// single-server deployments.
const rateLimit = createMemoryRateLimiter({
  defaultMax: 5,              // 5 attempts
  defaultWindowSeconds: 300,  // per 5-minute window
})

export async function POST(req: Request) {
  // Rate limit by IP address
  const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
  const result = await rateLimit.check({
    identifier: ip,
    limit: 'login',
    max: 5,
    windowSeconds: 300,
  })

  if (!result.allowed) {
    return new Response(
      JSON.stringify({
        error: 'Too many login attempts. Try again later.',
        retryAfter: result.retryAfter,
      }),
      {
        status: 429,
        headers: {
          'Retry-After': String(result.retryAfter),
          'X-RateLimit-Limit': String(result.limit),
          'X-RateLimit-Remaining': String(result.remaining),
        },
      }
    )
  }

  // Proceed with authentication...
  const { email, password } = await req.json()
  const user = await authenticateUser(email, password)

  if (!user) {
    return new Response(
      JSON.stringify({ error: 'Invalid credentials' }),
      { status: 401 }
    )
  }

  // Reset rate limit on successful login
  await rateLimit.reset(ip, 'login')

  return Response.json({ session: user.sessionToken })
}

5. ENABLE MFA (TOTP + BACKUP CODES)

MFA uses TOTP (RFC 6238) with a 30-second window and 6-digit codes. The MfaSetupDialog component accepts a renderQrCode render prop so you can use any QR library (it is not bundled to keep the package small). Backup codes are hex-encoded from cryptographically random bytes.

src/app/settings/mfa.tsx
'use client'

import { useState } from 'react'
import { MfaSetupDialog, BackupCodesModal, MfaCard } from '@fabrk/components'

// QR code library is optional — pass it via render prop
import QRCode from 'qrcode.react'

export default function MFASettings({ user }: { user: { mfaEnabled: boolean } }) {
  const [mfaEnabled, setMfaEnabled] = useState(user.mfaEnabled)
  const [showSetup, setShowSetup] = useState(false)
  const [backupCodes, setBackupCodes] = useState<string[]>([])

  return (
    <div className="space-y-4">
      {/* Status card — shows current MFA state with enable/disable toggle */}
      <MfaCard
        enabled={mfaEnabled}
        onEnable={() => setShowSetup(true)}
        onDisable={async () => {
          const res = await fetch('/api/mfa/disable', { method: 'POST' })
          if (res.ok) setMfaEnabled(false)
        }}
      />

      {/* Setup dialog — walks the user through scanning a QR code */}
      <MfaSetupDialog
        open={showSetup}
        onOpenChange={setShowSetup}
        onSetup={async (secret) => {
          // POST the TOTP secret to your backend for storage
          const res = await fetch('/api/mfa/enable', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ secret }),
          })
          if (!res.ok) throw new Error('Failed to enable MFA')

          const { codes } = await res.json()
          setBackupCodes(codes)
          setMfaEnabled(true)
        }}
        renderQrCode={(uri) => (
          <QRCode value={uri} size={200} level="M" />
        )}
      />

      {/* Backup codes modal — shown once after MFA setup */}
      {backupCodes.length > 0 && (
        <BackupCodesModal
          codes={backupCodes}
          onRegenerate={async () => {
            const res = await fetch('/api/mfa/regenerate-backup-codes', { method: 'POST' })
            const { codes } = await res.json()
            setBackupCodes(codes)
          }}
        />
      )}
    </div>
  )
}

6. MFA VERIFICATION IN API ROUTES

src/app/api/mfa/verify/route.ts
import { verifyTOTP } from '@fabrk/auth'

export async function POST(req: Request) {
  const { code, userId } = await req.json()

  const user = await db.user.findUnique({
    where: { id: userId },
    select: { mfaSecret: true, mfaEnabled: true },
  })

  if (!user?.mfaEnabled || !user.mfaSecret) {
    return new Response(
      JSON.stringify({ error: 'MFA not enabled' }),
      { status: 400 }
    )
  }

  // verifyTOTP checks the current and adjacent time windows
  // to handle clock drift (standard RFC 6238 tolerance)
  const valid = verifyTOTP(code, user.mfaSecret)

  if (!valid) {
    return new Response(
      JSON.stringify({ error: 'Invalid verification code' }),
      { status: 401 }
    )
  }

  // Mark session as MFA-verified
  await db.session.update({
    where: { userId },
    data: { mfaVerified: true, mfaVerifiedAt: new Date() },
  })

  return Response.json({ verified: true })
}
[PRODUCTION CHECKLIST: AUTH]
  • Generate NEXTAUTH_SECRET with openssl rand -base64 32
  • Set NEXTAUTH_URL to your production domain (not localhost)
  • Use Upstash rate limiter in production (memory limiter is single-server only)
  • API key expiration: set a TTL, or require periodic rotation
  • Audit log all auth events (login, logout, MFA enable/disable, key creation)
  • Use PrismaApiKeyStore in production, not in-memory store

[PAYMENTS INTEGRATION]

Integrate Stripe (or Polar, or Lemon Squeezy) with proper webhook verification, subscription lifecycle management, and plan switching. The adapter pattern means your route handlers work identically regardless of which payment provider you use.

[WHY THE ADAPTER PATTERN]
All payment providers implement the same PaymentAdapter interface from @fabrk/core. This means createCheckout, handleWebhook, getSubscription, and cancelSubscription all have the same signatures. To switch from Stripe to Polar, you change one line in your config. Your route handlers, webhook processing, and UI code stay exactly the same.

1. CONFIGURE THE PAYMENT ADAPTER

src/lib/payments.ts
import { createStripeAdapter } from '@fabrk/payments'

// Initialize once and export for use across route handlers.
// The adapter lazy-loads the stripe SDK on first use, so this
// is safe to import in edge functions.
export const payments = createStripeAdapter({
  secretKey: process.env.STRIPE_SECRET_KEY!,
  webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
  // apiVersion defaults to '2024-12-18.acacia'
})

// To switch providers, change one line:
// import { createPolarAdapter } from '@fabrk/payments'
// export const payments = createPolarAdapter({ ... })
//
// import { createLemonSqueezyAdapter } from '@fabrk/payments'
// export const payments = createLemonSqueezyAdapter({ ... })

2. CREATE A CHECKOUT SESSION

The checkout flow creates a Stripe Checkout session and redirects the user to the hosted payment page. Pass metadata to associate the payment with your internal records.

src/app/api/checkout/route.ts
import { payments } from '@/lib/payments'
import { withAuth } from '@fabrk/auth'

const auth = createNextAuthAdapter({ /* config */ })

export const POST = withAuth(auth, async (req, session) => {
  const { priceId, plan } = await req.json()

  if (!priceId || typeof priceId !== 'string') {
    return new Response(
      JSON.stringify({ error: 'Invalid price ID' }),
      { status: 400 }
    )
  }

  try {
    const checkout = await payments.createCheckout({
      priceId,
      customerId: session.stripeCustomerId,  // undefined for new customers
      customerEmail: session.email,           // fallback for new customers
      subscription: true,                     // create a subscription, not one-time payment
      trialDays: 14,                          // optional free trial
      successUrl: `${process.env.NEXTAUTH_URL}/dashboard?upgraded=true`,
      cancelUrl: `${process.env.NEXTAUTH_URL}/pricing`,
      metadata: {
        userId: session.userId,
        plan,
      },
    })

    // checkout.id   — Stripe session ID (for recovery)
    // checkout.url  — redirect URL for the user
    // checkout.raw  — full Stripe session object (if you need it)
    return Response.json({ url: checkout.url })
  } catch (err) {
    // Do not leak Stripe error details to the client
    console.error('Checkout creation failed:', err)
    return new Response(
      JSON.stringify({ error: 'Unable to create checkout session' }),
      { status: 500 }
    )
  }
})

3. HANDLE WEBHOOKS SECURELY

Webhook verification is critical. The FABRK Stripe adapter performs three security checks: (1) cryptographic signature verification via stripe.webhooks.constructEvent, (2) two-sided timestamp validation that rejects events older than 5 minutes AND events with future timestamps (this prevents replay attacks where an attacker sets a far-future timestamp to keep the event "valid" forever), and (3) idempotency checking to reject duplicate event IDs.

src/app/api/webhooks/stripe/route.ts
import { payments } from '@/lib/payments'

// IMPORTANT: Do not apply body parsing middleware to webhook routes.
// You need the raw body string for signature verification.
export async function POST(req: Request) {
  const body = await req.text()
  const signature = req.headers.get('stripe-signature')

  if (!signature) {
    return new Response(
      JSON.stringify({ error: 'Missing signature' }),
      { status: 400 }
    )
  }

  // handleWebhook performs all three security checks:
  // 1. Cryptographic signature verification
  // 2. Two-sided timestamp check (rejects > 5min old AND future-dated)
  // 3. Idempotency: rejects duplicate event IDs
  const result = await payments.handleWebhook(body, signature)

  if (!result.verified) {
    console.error('Webhook verification failed:', result.error)
    return new Response(
      JSON.stringify({ error: 'Webhook verification failed' }),
      { status: 400 }
    )
  }

  // Duplicate events are verified but should be skipped
  if (result.duplicate) {
    return Response.json({ received: true, duplicate: true })
  }

  const event = result.event!

  switch (event.type) {
    case 'checkout.session.completed': {
      const data = event.data as Record<string, any>
      await db.subscription.create({
        data: {
          userId: data.metadata?.userId,
          stripeSubscriptionId: data.subscription,
          stripeCustomerId: data.customer,
          plan: data.metadata?.plan ?? 'pro',
          status: 'active',
        },
      })

      // Also update user record with Stripe customer ID
      if (data.metadata?.userId && data.customer) {
        await db.user.update({
          where: { id: data.metadata.userId },
          data: { stripeCustomerId: data.customer },
        })
      }
      break
    }

    case 'customer.subscription.updated': {
      const data = event.data as Record<string, any>
      await db.subscription.update({
        where: { stripeSubscriptionId: data.id },
        data: {
          status: data.status,
          cancelAtPeriodEnd: data.cancel_at_period_end ?? false,
          currentPeriodEnd: data.current_period_end
            ? new Date(data.current_period_end * 1000)
            : undefined,
        },
      })
      break
    }

    case 'customer.subscription.deleted': {
      const data = event.data as Record<string, any>
      await db.subscription.update({
        where: { stripeSubscriptionId: data.id },
        data: { status: 'canceled' },
      })
      break
    }

    case 'invoice.payment_failed': {
      const data = event.data as Record<string, any>
      // Notify the user that their payment failed
      await db.subscription.update({
        where: { stripeCustomerId: data.customer },
        data: { status: 'past_due' },
      })
      // Send email notification
      break
    }
  }

  return Response.json({ received: true })
}

4. SUBSCRIPTION MANAGEMENT

After checkout, you need to handle plan switching, cancellation, and the billing portal. The adapter provides methods for all of these.

src/app/api/subscription/route.ts
import { payments } from '@/lib/payments'
import { withAuth } from '@fabrk/auth'

// GET /api/subscription — Get current subscription status
export const GET = withAuth(auth, async (req, session) => {
  const sub = await db.subscription.findFirst({
    where: { userId: session.userId, status: { in: ['active', 'trialing', 'past_due'] } },
  })

  if (!sub) {
    return Response.json({ subscription: null, plan: 'free' })
  }

  // Fetch live status from Stripe (includes current_period_end, cancel_at_period_end)
  const stripeInfo = await payments.getSubscription(sub.stripeSubscriptionId)

  return Response.json({
    subscription: {
      id: sub.id,
      plan: sub.plan,
      status: stripeInfo?.status ?? sub.status,
      currentPeriodEnd: stripeInfo?.currentPeriodEnd,
      cancelAtPeriodEnd: stripeInfo?.cancelAtPeriodEnd ?? false,
    },
  })
})

// POST /api/subscription/cancel — Cancel at end of billing period
export const POST = withAuth(auth, async (req, session) => {
  const sub = await db.subscription.findFirst({
    where: { userId: session.userId, status: 'active' },
  })

  if (!sub) {
    return new Response(
      JSON.stringify({ error: 'No active subscription' }),
      { status: 404 }
    )
  }

  // Cancel at period end — user keeps access until the billing period expires
  await payments.cancelSubscription(sub.stripeSubscriptionId, {
    atPeriodEnd: true,
  })

  await db.subscription.update({
    where: { id: sub.id },
    data: { cancelAtPeriodEnd: true },
  })

  return Response.json({ canceled: true, effectiveDate: sub.currentPeriodEnd })
})

5. BILLING PORTAL FOR SELF-SERVICE

Instead of building your own invoice and payment method management UI, redirect users to the Stripe Billing Portal. It handles updating payment methods, downloading invoices, and changing plans.

src/app/api/billing-portal/route.ts
import { payments } from '@/lib/payments'
import { withAuth } from '@fabrk/auth'

export const POST = withAuth(auth, async (req, session) => {
  if (!session.stripeCustomerId) {
    return new Response(
      JSON.stringify({ error: 'No billing account found' }),
      { status: 404 }
    )
  }

  // Creates a one-time-use URL to the Stripe billing portal
  const portalUrl = await payments.createPortalSession(
    session.stripeCustomerId,
    `${process.env.NEXTAUTH_URL}/dashboard/settings`  // return URL
  )

  return Response.json({ url: portalUrl })
})

6. PRICING PAGE UI

src/app/pricing/page.tsx
'use client'

import { useState } from 'react'
import { PricingCard } from '@fabrk/components'

const plans = [
  {
    name: 'FREE',
    price: '$0',
    period: '/month',
    features: ['5 projects', '1GB storage', 'Community support'],
    priceId: null,
  },
  {
    name: 'PRO',
    price: '$29',
    period: '/month',
    features: ['Unlimited projects', '100GB storage', 'Priority support', 'API access'],
    highlighted: true,
    priceId: process.env.NEXT_PUBLIC_STRIPE_PRO_PRICE_ID,
  },
  {
    name: 'ENTERPRISE',
    price: 'Custom',
    features: ['Everything in Pro', 'SSO/SAML', 'Dedicated support', 'SLA', 'Custom integrations'],
    priceId: null,
  },
]

export default function PricingPage() {
  const [loading, setLoading] = useState<string | null>(null)

  async function handleSelect(plan: typeof plans[0]) {
    if (!plan.priceId) {
      // Free plan needs no checkout; Enterprise goes to contact form
      if (plan.name === 'ENTERPRISE') {
        window.location.href = '/contact'
      }
      return
    }

    setLoading(plan.name)

    try {
      const res = await fetch('/api/checkout', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          priceId: plan.priceId,
          plan: plan.name.toLowerCase(),
        }),
      })

      if (!res.ok) {
        const error = await res.json()
        throw new Error(error.message ?? 'Checkout failed')
      }

      const { url } = await res.json()
      window.location.href = url
    } catch (err) {
      console.error('Checkout error:', err)
      // Show error toast to user
    } finally {
      setLoading(null)
    }
  }

  return (
    <div className="max-w-4xl mx-auto py-12 px-6">
      <h1 className="text-2xl font-bold uppercase text-center mb-2">PRICING</h1>
      <p className="text-sm text-muted-foreground text-center mb-8">
        Start free. Upgrade when you need more.
      </p>
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        {plans.map((plan) => (
          <PricingCard
            key={plan.name}
            title={plan.name}
            price={plan.price}
            period={plan.period}
            features={plan.features}
            highlighted={plan.highlighted}
            onSelect={() => handleSelect(plan)}
          />
        ))}
      </div>
    </div>
  )
}
[PRODUCTION CHECKLIST: PAYMENTS]
  • Use mode: 'test' during development, 'live' in production
  • Register your webhook URL in Stripe Dashboard (Settings > Webhooks)
  • Handle invoice.payment_failed to notify users of billing issues
  • Store Stripe customer IDs on your user records for portal session creation
  • Use cancelSubscription(id, { atPeriodEnd: true }) so users keep access until period ends
  • Test webhook signatures locally with stripe listen --forward-to localhost:3000/api/webhooks/stripe
  • Webhook replay protection: the adapter rejects events older than 5 minutes and future-dated events

[AI INTEGRATION]

An agent is a loop. It reads your message, decides whether to call a tool, runs the tool, and repeats — up to 25 times — before sending a final reply. You define the agent in TypeScript, expose it over HTTP, and the browser receives tokens as they arrive, like a chat message coming in letter by letter.

[ARCHITECTURE OVERVIEW]
The agent system lives inside the fabrk package, in layers:
  • defineAgent — sets the model, tools, system prompt, budget, guardrails, and memory
  • runAgentLoop — the observe → think → act loop, up to 25 iterations
  • SSE route handler — sends the loop output to the browser one event at a time, via /api/agents/:name
  • useAgent hook — React hook that sends a message and reads the stream back
  • Memory stores — InMemoryMemoryStore for per-session history, SemanticMemoryStore for vector search
  • Guardrails — functions that inspect or block content before the LLM sees it, and after
  • Testing — MockLLM + createTestAgent let you test agents with no API keys

1. DEFINE AN AGENT

Start with defineAgent. Put this file anywhere in your project — the route handler imports it directly. List tool names in tools. Those tools must be registered in the tool registry before the first request arrives.

src/agents/assistant.ts
import { defineAgent } from 'fabrk'
import { defineTool, textResult } from 'fabrk'

// 1. Define a tool
export const searchDocs = defineTool({
  name: 'search_docs',
  description: 'Search the documentation for a query',
  schema: {
    type: 'object',
    properties: {
      query: { type: 'string', description: 'Search query' },
    },
    required: ['query'],
  },
  handler: async ({ query }) => {
    // Your implementation here
    const results = await doSearch(String(query))
    return textResult(results)
  },
})

// 2. Define the agent
export const assistantAgent = defineAgent({
  model: 'claude-sonnet-4-5-20250929',    // any provider model string
  fallback: ['gpt-4o'],                    // optional fallback chain
  systemPrompt: 'You are a helpful documentation assistant. Use search_docs to find accurate answers.',
  tools: ['search_docs'],                  // tool names registered at startup
  stream: true,                            // emit text-delta events
  auth: 'optional',                        // 'required' | 'optional' | 'none'
  budget: {
    daily: 10,          // $10/day for this agent
    perSession: 0.50,   // $0.50 per conversation
    alertThreshold: 0.8,
  },
  memory: true,         // enable InMemoryMemoryStore
  generationOptions: {
    maxTokens: 4096,
    temperature: 0.3,
  },
})
[ALL defineAgent FIELDS]
model — required LLM model string. fallback — ordered list of backup models. systemPrompt — prepended to every request as a system message. tools — tool names from the registry. stream — default true, set false for batch response. auth — default 'none'. budget — daily/perSession/perUser/perTenant caps in USD. memorytrue or an AgentMemoryConfig object. inputGuardrails / outputGuardrails — validation pipelines. handoffs — agent names this agent can hand off to. outputSchema — JSON Schema for structured output.

2. WIRE THE SSE ROUTE

Create a route file and pass your agent definition to handleAgentRequest. It validates the incoming messages, runs the agent loop, and sends each event back to the browser over a persistent HTTP connection. In a FABRK project, this file lives at app/api/assistant/route.ts.

app/api/assistant/route.ts
import { handleAgentRequest } from 'fabrk'
import { assistantAgent, searchDocs } from '@/agents/assistant'

// Register tools once at module load
import { registerTool } from 'fabrk'
registerTool(searchDocs)

export async function POST(req: Request) {
  return handleAgentRequest(req, 'assistant', assistantAgent)
}

// handleAgentRequest does the following:
// 1. Parses { messages } from the request body
// 2. Validates message count (max 200) and content length (max 100K chars/message)
// 3. Resolves registered tools for this agent
// 4. Runs the agent loop as an async generator
// 5. Streams each AgentLoopEvent as SSE: data: {...}\n\n
// 6. Adds all required security headers to the response
[SSE EVENT TYPES]
Your browser receives these events, in this order: text-delta — one chunk of text as the model writes it. tool-call — the tool name and input, before the tool runs. tool-result — the tool output and how long it took. usage — token counts and dollar cost for this turn. done — end of turn, with optional structured output. If something goes wrong: error with a message string.

3. CONNECT THE FRONTEND WITH useAgent

useAgent handles the connection for you. Call send() with a message. The hook opens a stream, appends tokens to messages as they arrive, and tracks tool calls in real time. Pass the agent name — it must match the route segment.

app/chat/page.tsx
'use client'

import { useAgent } from 'fabrk/client'
import { cn } from '@/lib/utils'
import { mode } from '@fabrk/design-system'

export default function ChatPage() {
  const {
    send,          // (content: string | AgentContentPart[]) => Promise<void>
    stop,          // () => void — aborts in-progress stream
    messages,      // AgentMessage[] — grows with each user+assistant turn
    isStreaming,   // boolean
    cost,          // number — cumulative USD cost this session
    usage,         // { promptTokens: number, completionTokens: number }
    error,         // string | null
    toolCalls,     // AgentToolCall[] — calls with optional output+durationMs
  } = useAgent('assistant')  // matches the route at /api/agents/assistant

  return (
    <div className="flex flex-col h-screen max-w-2xl mx-auto">
      <div className="border-b border-border p-3 flex items-center justify-between">
        <span className={cn('text-xs font-bold', mode.font)}>[ASSISTANT]</span>
        <span className="text-xs text-muted-foreground">
          ${cost.toFixed(4)} · {usage.promptTokens + usage.completionTokens} tokens
        </span>
      </div>

      <div className="flex-1 overflow-y-auto p-4 space-y-3">
        {messages.map((msg, i) => (
          <div key={i} className={cn(
            'text-sm p-3 border',
            mode.radius,
            msg.role === 'user'
              ? 'border-primary text-foreground ml-8'
              : 'border-border bg-card text-foreground mr-8'
          )}>
            <div className="text-xs text-muted-foreground mb-1 uppercase">
              [{msg.role}]
            </div>
            {typeof msg.content === 'string'
              ? msg.content
              : msg.content.map((p, j) =>
                  p.type === 'text' ? <span key={j}>{p.text}</span> : null
                )}
          </div>
        ))}

        {isStreaming && (
          <div className="text-xs text-muted-foreground animate-pulse">[THINKING...]</div>
        )}

        {toolCalls.map((tc, i) => (
          <div key={i} className={cn('text-xs border border-border p-2', mode.radius, mode.font)}>
            <span className="text-primary">[TOOL] {tc.name}</span>
            {tc.output && (
              <span className="text-muted-foreground"> → {tc.durationMs}ms</span>
            )}
          </div>
        ))}

        {error && (
          <div className={cn('text-xs text-destructive border border-destructive p-2', mode.radius)}>
            [ERROR] {error}
          </div>
        )}
      </div>

      <div className="border-t border-border p-3 flex gap-2">
        <input
          className={cn('flex-1 bg-background border border-border text-sm px-3 py-2', mode.radius)}
          placeholder="Ask anything..."
          onKeyDown={(e) => {
            if (e.key === 'Enter' && !isStreaming) {
              send(e.currentTarget.value)
              e.currentTarget.value = ''
            }
          }}
          disabled={isStreaming}
        />
        {isStreaming
          ? <button onClick={stop} className={cn('text-xs border border-border px-3 py-2', mode.radius)}>{'> STOP'}</button>
          : null
        }
      </div>
    </div>
  )
}

4. ADDING MEMORY

By default your agent forgets everything between requests. Add memory so it can refer back to earlier messages. InMemoryMemoryStore keeps per-thread history — up to 1,000 threads, 500 messages each. When you need to search across past conversations, SemanticMemoryStore wraps any base store and adds vector similarity search.

per-session memory
// memory: true on defineAgent enables InMemoryMemoryStore automatically.
// For explicit control, pass an AgentMemoryConfig:

import { defineAgent } from 'fabrk'

export const agent = defineAgent({
  model: 'claude-sonnet-4-5-20250929',
  tools: [],
  memory: {
    maxMessages: 50,           // keep last 50 messages per thread
    compression: {             // auto-compress when thread grows large
      enabled: true,
      triggerAt: 40,           // compress when thread hits 40 messages
      keepRecent: 10,          // always preserve the last 10 messages
      summarize: async (messages) => {
        // Your LLM call to summarize old messages into a single string
        return summarizeWithLLM(messages)
      },
    },
  },
})
semantic memory — cross-session search
import { SemanticMemoryStore, InMemoryMemoryStore } from 'fabrk'
import { OpenAIEmbeddingProvider } from '@fabrk/ai'

// Build a SemanticMemoryStore on top of any base store
const baseStore = new InMemoryMemoryStore()
const embeddingProvider = new OpenAIEmbeddingProvider({
  model: 'text-embedding-3-small',
})
const memoryStore = new SemanticMemoryStore(baseStore, {
  embeddingProvider,
  topK: 5,         // return up to 5 similar messages
  threshold: 0.7,  // cosine similarity threshold
})

// Search across all threads:
const results = await memoryStore.search('user preference for dark mode', {
  agentName: 'assistant',   // optional: scope to one agent
  limit: 5,
  // Expand each match with surrounding context:
  messageRange: { before: 2, after: 2 },
})

// Inject results into the system prompt:
const contextBlock = results
  .map((m) => `[${m.role}] ${m.content}`)
  .join('\n')

// Pass to agent via systemPrompt or as an extra user message

5. GUARDRAILS

A guardrail is a function that inspects content and decides whether to let it through. Write one with the signature (content: string, ctx) => GuardrailResult. Your guardrails run on every input before the LLM sees it, and on every output before the client sees it. Use AsyncGuardrail when you need to call an external moderation API.

production guardrail setup
import {
  defineAgent,
  maxLength,
  denyList,
  piiRedactor,
  requireJsonSchema,
} from 'fabrk'
import type { Guardrail, AsyncGuardrail } from 'fabrk'

// Custom guardrail — block requests asking for competitor info
const noCompetitorMentions: Guardrail = (content, ctx) => {
  const competitors = ['acme-corp', 'rival-saas']
  const lower = content.toLowerCase()
  for (const c of competitors) {
    if (lower.includes(c)) {
      return { pass: false, reason: `Competitor mention blocked: ${c}` }
    }
  }
  return { pass: true }
}

// Async guardrail — call an external moderation API
const moderationCheck: AsyncGuardrail = async (content, ctx) => {
  const result = await callModerationAPI(content)
  if (result.flagged) {
    return { pass: false, reason: `Moderation: ${result.categories.join(', ')}` }
  }
  return { pass: true }
}

export const agent = defineAgent({
  model: 'claude-sonnet-4-5-20250929',
  tools: [],
  inputGuardrails: [
    maxLength(10_000),          // block inputs over 10K chars
    denyList([/\bpassword\b/i, /\bsecret\b/i]), // block forbidden patterns
    piiRedactor(),              // redact emails, phone numbers, SSNs in place
    noCompetitorMentions,       // custom sync guardrail
  ],
  outputGuardrails: [
    maxLength(50_000),          // cap output size
    // async guardrails attach the same way — the loop awaits them
  ],
})
[GUARDRAIL BEHAVIOR]
Guardrails run in array order. Return { pass: false } with no replacement and the loop stops immediately with an error event. Return a replacement string and the next guardrail runs on that string instead. piiRedactor() works this way — it rewrites content in place and lets the request continue.

6. TESTING AN AGENT

You don't need an API key to test an agent. MockLLM intercepts LLM calls and returns whatever you configure, matched by message pattern. createTestAgent runs a real agent loop around the mock — your tool handlers, guardrails, and stop conditions all execute exactly as they would in production.

agent.test.ts
import { describe, it, expect } from 'vitest'
import { mockLLM, createTestAgent, defineTool, textResult } from 'fabrk'

const weatherTool = defineTool({
  name: 'get_weather',
  description: 'Get current weather for a city',
  schema: {
    type: 'object',
    properties: { city: { type: 'string' } },
    required: ['city'],
  },
  handler: async ({ city }) => textResult(`Weather in ${city}: 72°F, sunny`),
})

describe('assistant agent', () => {
  it('calls get_weather when asked about weather', async () => {
    const mock = mockLLM()
      // When the user message contains "weather", call the tool
      .onMessage(/weather/)
      .callTool('get_weather', { city: 'San Francisco' })
      // After tool result, respond with a final text message
      .setDefault('The weather in San Francisco is 72°F and sunny.')

    const agent = createTestAgent({
      tools: [weatherTool],
      mock,
      stream: false,
    })

    const result = await agent.send('What is the weather in San Francisco?')

    // Assert the tool was called with the right input
    expect(result.toolCalls).toHaveLength(1)
    expect(result.toolCalls[0].name).toBe('get_weather')
    expect(result.toolCalls[0].input).toEqual({ city: 'San Francisco' })

    // Assert the final text response
    expect(result.content).toContain('72°F')

    // Assert total LLM call count (1 for tool decision + 1 for final answer)
    expect(mock.callCount).toBe(2)
  })

  it('returns error event when input guardrail blocks content', async () => {
    const mock = mockLLM().setDefault('ok')
    const agent = createTestAgent({ tools: [], mock })

    // The denyList guardrail blocks "password" — assert the error event is emitted
    const result = await agent.send('Tell me the password')
    const errorEvent = result.events.find((e) => e.type === 'error')
    expect(errorEvent).toBeDefined()
  })
})
[PRODUCTION CHECKLIST: AGENTS]
  • Set budget.daily and budget.perSession — without these, a runaway agent has no spending limit
  • Add a maxLength() input guardrail — oversized inputs are one of the most common prompt injection vectors
  • Use auth: 'required' on any agent that touches user data
  • Validate tool input inside your handler — the JSON schema check covers required fields and types, not your business rules
  • The loop hard-caps at 25 iterations; set maxIterations lower for simple agents that shouldn't need many steps
  • Tool output truncates at 50,000 chars before going back to the LLM — keep responses concise

[DEPLOYMENT]

Deploy your FABRK app to production with proper security, monitoring, and environment management. This guide covers Vercel deployment, staging vs production config, health check endpoints, CI/CD pipelines, and a comprehensive security checklist.

1. ENVIRONMENT VARIABLE MANAGEMENT

Keep three separate environment files. Never commit .env.production to git. Use your deployment platform's secret manager for production secrets.

.env.local (development)
# Auth
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=dev-secret-do-not-use-in-production

# Payments (Stripe test mode)
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PRO_PRICE_ID=price_...

# AI
ANTHROPIC_API_KEY=sk-ant-...
AI_DAILY_BUDGET=10

# Email (console adapter in dev — prints to terminal)
RESEND_API_KEY=

# Database
DATABASE_URL=postgresql://localhost:5432/myapp_dev
.env.staging
# Auth
NEXTAUTH_URL=https://staging.myapp.com
NEXTAUTH_SECRET=  # Set via deployment platform secrets

# Payments (Stripe test mode — still test keys in staging)
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

# AI (lower budget for staging)
AI_DAILY_BUDGET=5

# Database (separate staging database)
DATABASE_URL=postgresql://...staging-db-url...
[SECRETS MANAGEMENT]
Never store production secrets in files. Use your platform's secrets manager:
  • Vercel: vercel env add VARIABLE_NAME or Project Settings > Environment Variables
  • Railway: Variables tab in project dashboard
  • AWS: Secrets Manager or Parameter Store
For NEXTAUTH_SECRET, generate a strong random value: openssl rand -base64 32

2. DEPLOY TO VERCEL

terminal
# Install Vercel CLI
npm i -g vercel

# Link project and deploy
vercel

# Set production environment variables
vercel env add NEXTAUTH_SECRET --environment production
vercel env add NEXTAUTH_URL --environment production
vercel env add DATABASE_URL --environment production
vercel env add STRIPE_SECRET_KEY --environment production
vercel env add STRIPE_WEBHOOK_SECRET --environment production
vercel env add ANTHROPIC_API_KEY --environment production
vercel env add AI_DAILY_BUDGET --environment production
vercel env add RESEND_API_KEY --environment production

# Deploy to production
vercel --prod

3. DATABASE SETUP

database deployment
# Run Prisma migrations on your production database
pnpm dlx prisma migrate deploy

# For serverless environments (Vercel, AWS Lambda), use connection pooling.
# Without pooling, each invocation opens a new connection and you'll hit
# the database connection limit quickly.
DATABASE_URL="postgresql://user:pass@host:5432/db?pgbouncer=true&connection_limit=1"

# If using Neon, Supabase, or PlanetScale, connection pooling is built-in.
# Just use the pooled connection string from their dashboard.

# Recommended providers:
#   Neon       — Serverless PostgreSQL, free tier, auto-scaling
#   Supabase   — PostgreSQL + realtime + auth + storage
#   PlanetScale — MySQL with branching (useful for staging)

4. HEALTH CHECK ENDPOINT

Add a health check endpoint that your monitoring service can ping. Check database connectivity and any critical external services.

src/app/api/health/route.ts
export const dynamic = 'force-dynamic'

export async function GET() {
  const checks: Record<string, 'ok' | 'error'> = {}
  let healthy = true

  // Check database connectivity
  try {
    await db.$queryRaw`SELECT 1`
    checks.database = 'ok'
  } catch {
    checks.database = 'error'
    healthy = false
  }

  // Check Stripe configuration
  try {
    checks.payments = payments.isConfigured() ? 'ok' : 'error'
  } catch {
    checks.payments = 'error'
    healthy = false
  }

  return Response.json(
    {
      status: healthy ? 'healthy' : 'degraded',
      timestamp: new Date().toISOString(),
      checks,
      version: process.env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) ?? 'unknown',
    },
    { status: healthy ? 200 : 503 }
  )
}

5. PRODUCTION CONFIG

The key differences between development and production config. In development, FABRK uses in-memory stores, console email adapter, and test mode payments. In production, switch to persistent stores, real email delivery, and live payments.

fabrk.config.ts — production overrides
import { defineFabrkConfig } from '@fabrk/config'

export default defineFabrkConfig({
  // Switch payments to live mode
  payments: {
    adapter: 'stripe',
    mode: 'live',
    config: {
      secretKey: process.env.STRIPE_SECRET_KEY!,
      webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
    },
  },

  // Use real email delivery (not console adapter)
  email: {
    adapter: 'resend',
    from: 'notifications@yourdomain.com',
  },

  // Enable all security features
  security: {
    csrf: true,
    csp: true,
    rateLimit: true,
    auditLog: true,
    headers: true,       // Adds HSTS, X-Content-Type-Options, etc.
    cors: {
      origins: ['https://yourdomain.com'],
    },
  },

  // Auth with real providers
  auth: {
    adapter: 'nextauth',
    apiKeys: true,
    mfa: true,
  },

  // AI with production budget
  ai: {
    costTracking: true,
    budget: {
      daily: Number(process.env.AI_DAILY_BUDGET ?? 50),
      monthly: 1000,
    },
  },
})

6. CI/CD PIPELINE

A GitHub Actions pipeline that runs type checking, tests, linting, and bundle size tracking on every pull request, then auto-deploys on merge to main.

.github/workflows/ci.yml
name: CI

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm

      - run: pnpm install --frozen-lockfile
      - run: pnpm type-check
      - run: pnpm lint
      - run: pnpm test
      - run: pnpm build

  bundle-size:
    runs-on: ubuntu-latest
    needs: validate
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm

      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - run: pnpm size  # Fails if any package exceeds its size limit

  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm

      - run: pnpm install --frozen-lockfile
      - run: pnpm audit || true  # Surface vulnerabilities without blocking

7. SECURITY HEADERS

@fabrk/framework automatically adds security headers via buildSecurityHeaders()on all SSR responses. You can also configure them in your fabrk.config.ts.

fabrk.config.ts — security headers
// Enable security headers in your config
security: {
  headers: true,   // Adds all 6 security headers automatically
  csrf: true,
  csp: true,
}

// Headers applied to all responses:
//   X-Content-Type-Options: nosniff
//   X-Frame-Options: DENY
//   X-XSS-Protection: 1; mode=block
//   Referrer-Policy: strict-origin-when-cross-origin
//   Permissions-Policy: camera=(), microphone=(), geolocation=()
//   Strict-Transport-Security: max-age=63072000; includeSubDomains; preload

8. MONITORING AND OBSERVABILITY

monitoring setup
// Use your health check endpoint with an uptime monitor:
// - UptimeRobot (free tier): ping /api/health every 5 minutes
// - Better Uptime: monitors + incident pages
// - Checkly: synthetic monitoring with Playwright

// For error tracking, add Sentry or similar:
// npm install @sentry/node
// Follow Sentry docs for your runtime

// For AI cost monitoring, query the cost tracker:
const todaysCost = await costTracker.getTodaysCost()
const featureCosts = await costTracker.getFeatureCosts({
  startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // last 7 days
})
// Returns: [{ feature, totalCost, callCount, avgCostPerCall, successRate, lastUsed }]

// For database monitoring with Prisma:
// Add query logging in development
// prisma.$on('query', (e) => { console.log(`[${e.duration}ms] ${e.query}`) })
[PRODUCTION CHECKLIST]
  • Auth: Set NEXTAUTH_URL to production domain, generate strong NEXTAUTH_SECRET
  • Payments: Switch to mode: 'live', register webhook URL in Stripe dashboard
  • Email: Switch from console adapter to resend
  • Database: Enable connection pooling for serverless, run prisma migrate deploy
  • Rate limiting: Use Upstash Redis (distributed) instead of memory rate limiter
  • Stores: Use PrismaTeamStore, PrismaAuditStore, PrismaCostStore instead of in-memory
  • Security headers: Enable HSTS, CSP, CORS with production origins
  • Audit logging: Enable for compliance (GDPR, SOC 2)
  • AI budget: Set AI_DAILY_BUDGET env var, monitor feature-level costs
  • Health checks: Add /api/health endpoint, configure uptime monitoring
  • Error tracking: Set up Sentry or equivalent for production error visibility
  • Bundle size: Run pnpm size in CI to catch size regressions
  • Secrets: All secrets in platform secret manager, never in code or env files committed to git
[STAGING VS PRODUCTION]
Key differences to configure between environments:
  • Stripe: Use sk_test_ keys in staging, sk_live_ in production. Register separate webhook endpoints for each.
  • Database: Separate databases with the same schema. Run migrations on staging first.
  • AI budget: Lower budget in staging to avoid accidental spend ($5-10/day vs $50/day in prod).
  • Email: Keep console adapter in staging to avoid sending real emails to test users.
  • Rate limits: More lenient in staging for testing, stricter in production.