# Achromatic combined corpus > Production-ready Next.js 16 SaaS starter kits with Better Auth, Stripe, organizations and 50+ components. Choose Prisma or Drizzle. For narrower context, prefer [Prisma](https://www.achromatic.dev/llms-prisma.txt), [Drizzle](https://www.achromatic.dev/llms-drizzle.txt) or [blog](https://www.achromatic.dev/llms-blog.txt). ## Current product facts The following claims describe the current products and are generated from the site's shared product configuration. ### Current starter kits - [Pro Next.js + Prisma + Better Auth](https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma) - [Pro Next.js + Drizzle + Better Auth](https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle) ### Included capabilities - Better Auth authentication with email and password, passkeys, Google OAuth and TOTP two-factor authentication - Stripe subscriptions, one-time payments, per-seat pricing, credits, paywalls and webhooks - Organizations, team invitations, roles and access control - PostgreSQL with either Prisma or Drizzle ORM - Components built with shadcn/ui and Tailwind CSS - Transactional email with Resend and React Email - AI integration with the Vercel AI SDK ### License and access - Price: 180 USD, paid once - The purchase includes both current starter kits and future updates made available under the license - The license covers unlimited projects for one licensed individual, team or organization - Repository invitations are sent to the GitHub username supplied at checkout - Expired invitations, restoration requests and teammate access are handled through [support](https://www.achromatic.dev/contact) # Prisma documentation ## App Config **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/admin-panel/app-config **Description**: Inspect the configuration values loaded by the current deployment. The App Config page at `/dashboard/admin/app-config` is a read-only viewer. It does not edit configuration, save values to the database or keep a change history. The parent admin layout restricts the page to users whose platform role is `admin`. ## Configuration Sections `components/admin/app-config/app-config-table.tsx` imports four configuration objects and displays each one in a tab: - **App** from `config/app.config.ts` - **Auth** from `config/auth.config.ts` - **Billing** from `config/billing.config.ts` - **Storage** from `config/storage.config.ts` These are the values resolved by the configuration modules for the current deployment. ## Displayed Fields The table flattens nested objects into dot-separated keys. Each row contains: - **Key** - The configuration path, such as `pagination.defaultLimit` - **Type** - The JavaScript value type - **Value** - The resolved value Arrays remain on one row. Object items inside an array are displayed as JSON. Each value has a copy button. The page does not display field descriptions, update timestamps or a change author. It also does not include search, filters, pagination or row selection. ## Change Configuration Edit the corresponding file in `config/` and update any environment variables that file reads. Validate the application locally, then deploy the code and environment changes. The App Config page will reflect the values loaded by the new deployment. There is no App Config tRPC procedure or database table in the shipped kit. ## Security The table is rendered by a Client Component. Do not add secrets to the imported configuration objects. Keep server-only credentials in server environment variables and avoid exposing them through client-imported config modules. ## Related Documentation - [Configuration](/docs/starter-kits/pro-nextjs-prisma/configuration) - Configure the application - [App Configuration](/docs/starter-kits/pro-nextjs-prisma/configuration/app) - Review the app config structure - [Environment Variables](/docs/starter-kits/pro-nextjs-prisma/codebase/environment-variables) - Manage deployment values --- ## Credits **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/admin-panel/credits **Description**: View and adjust organization credit balances from the admin Organizations page. The shipped kit manages credits from `/dashboard/admin/organizations`. It does not include a standalone `/dashboard/admin/credits` route or an `admin.credit` tRPC router. ## Included Admin Controls The Organizations table shows the current credit balance for each organization. Admins can: - Search for an organization by name - Filter organizations by credit balance - Open **Adjust credits** from an organization's row menu - Add or subtract credits with a required description The balance filters are `zero`, `low`, `medium` and `high`. Their ranges are defined by `admin.organization.list` in `trpc/routers/admin/admin-organization-router.ts`. ## List Organization Balances Credit balances are returned by the existing organization list procedure: ```tsx filename="components/admin/organizations/organizations-table.tsx" lineNumbers const { data, isPending } = trpc.admin.organization.list.useQuery({ limit: 25, offset: 0, query: '', sortBy: 'name', sortOrder: 'asc', filters: { balanceRange: ['low'] } }); const organizations = data?.organizations ?? []; // Each organization includes `credits`, the current balance. ``` The list procedure does not return an admin-wide credit transaction history. ## Adjust Organization Credits Use the procedure called by `components/admin/organizations/adjust-credits-modal.tsx`: ```tsx lineNumbers const adjustCredits = trpc.admin.organization.adjustCredits.useMutation(); adjustCredits.mutate({ organizationId, amount: 500, description: 'Support credit' }); ``` A positive amount adds credits and a negative amount subtracts credits. The amount cannot be zero. The description must contain between 1 and 500 characters. The mutation records the adjustment through `lib/billing/credits.ts` and returns `newBalance` plus `transactionId`. It also stores the acting admin's ID and email in the transaction metadata. ## Not Included The current kit does not include: - A separate admin credits page - `trpc.admin.credit.list`, `trpc.admin.credit.get` or `trpc.admin.credit.listTransactions` - An admin-wide credit transaction browser - User-level credit balances The credit system is organization-scoped. The `trpc.organization.credit.getTransactions` procedure is available to the active organization through `protectedOrganizationProcedure`. It is not a global admin query. ## Related Documentation - [Organizations](/docs/starter-kits/pro-nextjs-prisma/admin-panel/organizations) - Use the shipped admin interface - [Credits](/docs/starter-kits/pro-nextjs-prisma/billing/credits) - Understand the organization credit system - [Billing Overview](/docs/starter-kits/pro-nextjs-prisma/billing/overview) - Review the billing architecture --- ## Notifications **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/admin-panel/notifications **Description**: Send targeted or broadcast in-app notifications and understand the user notification center. The starter kit includes a database-backed notification center for signed-in users and an administrative workflow for sending and reviewing notifications. ## What Ships Users receive a notification bell in the expanded application sidebar and in the mobile navigation drawer. The popover includes: - An unread count on the bell - **All** and **Unread** tabs - Information, success and warning states - Expandable message content - Optional internal action links - Individual and bulk mark-as-read actions - Loading, empty and recoverable error states The bell is hidden when the desktop sidebar is collapsed so it does not compete with the compact navigation rail. Platform administrators also receive `/dashboard/admin/notifications`. The page provides search, type and read-state filters, pagination, a notification details sheet, row selection and confirmed bulk deletion. ## Send a Notification 1. Sign in with a platform `admin` account. 2. Open **Admin Panel → Notifications**. 3. Select **Send notification**. 4. Choose one active user or all active users. 5. Enter a title, message and type. 6. Optionally add an internal application path such as `/dashboard/settings?tab=billing`. 7. Review the audience in the confirmation dialog and send. Banned users are excluded from recipient search and broadcasts. Broadcasts are inserted in batches inside a database transaction. Action URLs must be internal paths. The shared `getSafeRedirectPath` utility rejects external, protocol-relative and malformed values before creation, and the notification center validates the stored path again before navigation. ## Database Model Each recipient gets one notification row. It stores: - `userId` for the recipient - optional `createdById` for the administrator who sent it - `title`, `message` and `type` - optional `actionUrl` - nullable `readAt` - `createdAt` and `updatedAt` The schema indexes the recipient with creation time for chronological listing and the recipient with read time for unread queries. Deleting a user cascades their notifications. Deleting a creator preserves delivered notifications and sets `createdById` to `NULL`. Apply the checked-in migration before running the updated application: ```sh filename="Terminal" lineNumbers npm run db:migrate ``` No new environment variable is required. ## User Procedures The `notification` tRPC router exposes: | Procedure | Purpose | | -------------------------- | --------------------------------------- | | `notification.list` | List the current user's recent messages | | `notification.unreadCount` | Count the current user's unread rows | | `notification.get` | Read one owned notification | | `notification.markRead` | Mark one owned row as read | | `notification.markAllRead` | Mark all current-user rows as read | Every database condition includes `ctx.user.id`. A caller cannot read or change another user's notification by supplying its ID. ## Admin Procedures The `admin.notification` router uses `protectedAdminProcedure` and exposes: | Procedure | Purpose | | ------------------------------- | ---------------------------------- | | `admin.notification.list` | Search and filter delivery history | | `admin.notification.recipients` | Find active recipients | | `admin.notification.create` | Send to one user or broadcast | | `admin.notification.bulkDelete` | Delete up to 100 selected rows | Deleting a notification removes it from the recipient's notification center. The admin interface confirms destructive row and bulk actions before calling the mutation. ## Create Notifications from Application Code Product events can create notification rows directly in server-only code. Keep the same boundaries as the admin workflow: 1. Resolve recipients from trusted server state. 2. Validate an action with `getSafeRedirectPath` or store `NULL`. 3. Insert one row per recipient. 4. Keep external email or push delivery in a separate queue or integration. The shipped release is an in-app, database-backed system. It does not provide real-time push delivery. Add polling, server-sent events or a realtime provider only when the product requires live arrival. ## Customize the Notification Center The main files are: - `components/notifications/notification-center.tsx` - `components/notifications/notification-icon.tsx` - `components/admin/notifications/admin-notifications.tsx` - `components/admin/notifications/create-notification-modal.tsx` - `components/admin/notifications/notification-details-modal.tsx` - `schemas/notification-schemas.ts` - `trpc/routers/notification/index.ts` - `trpc/routers/admin/admin-notification-router.ts` Keep the user router scoped to the authenticated user and keep management procedures behind `protectedAdminProcedure` when changing the presentation or adding notification types. ## Related Guides - [Admin Panel overview](/docs/starter-kits/pro-nextjs-prisma/admin-panel/overview) - [Admin users](/docs/starter-kits/pro-nextjs-prisma/admin-panel/users) - [Permissions and access control](/docs/starter-kits/pro-nextjs-prisma/authentication/permissions) - [Database migrations](/docs/starter-kits/pro-nextjs-prisma/database/migrations) --- ## Organizations **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/admin-panel/organizations **Description**: View organizations and use the billing controls included in the admin table. The Organizations page at `/dashboard/admin/organizations` is the shipped admin interface for organization records, subscription summaries and credit balances. ## Table Contents Each row displays: - Organization name and logo - Member count - Latest subscription status and a plan label derived from the stored Stripe price ID - Current credit balance - Pending invitation count - Creation date The list does not include an organization owner, slug, payment history or full credit transaction history. ## Search, Filters and Sorting Search matches organization names. The table can filter by: - Member count - Creation date - Subscription status - Credit balance range Sorting is supported for name and creation date. The schema accepts `membersCount`, but the current Prisma router falls back to name sorting for that value. ```tsx filename="components/admin/organizations/organizations-table.tsx" lineNumbers const { data, isPending } = trpc.admin.organization.list.useQuery({ limit: 25, offset: 0, query: '', sortBy: 'name', sortOrder: 'asc', filters: { membersCount: ['1-5'], subscriptionStatus: ['active'], balanceRange: ['low'], createdAt: ['this-month'] } }); ``` ## Row Actions The row menu includes these actions: - **Adjust credits** calls `trpc.admin.organization.adjustCredits` - **Open in Stripe** opens an active subscription in the Stripe Dashboard - **Cancel subscription** calls `trpc.admin.organization.cancelSubscription` with `immediate: false` - **Sync from Stripe** calls `trpc.admin.organization.syncFromStripe` for the selected organization - **Delete** calls `trpc.admin.organization.delete` Cancellation is available only for an active subscription that is not already scheduled to cancel. The local subscription record is updated by the Stripe webhook. ## Bulk Actions Selected organizations can be: - Exported to CSV - Exported to Excel - Synchronized with Stripe The current table does not include bulk deletion, bulk credit adjustments or bulk subscription cancellation. ## Returned Billing Fields `trpc.admin.organization.list` returns one latest subscription summary and one credit balance per organization. Relevant fields include: - `subscriptionId` - `subscriptionStatus` - `subscriptionPlan` - `cancelAtPeriodEnd` - `trialEnd` - `credits` ## Related Documentation - [Subscription controls](/docs/starter-kits/pro-nextjs-prisma/admin-panel/subscriptions) - Manage the latest organization subscription - [Credit controls](/docs/starter-kits/pro-nextjs-prisma/admin-panel/credits) - View and adjust organization credits - [Organizations Overview](/docs/starter-kits/pro-nextjs-prisma/organizations/overview) - Understand organization features --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/admin-panel/overview **Description**: Learn which admin pages and organization management controls ship with the kit. The admin area is available only to users whose platform role is `admin`. The current kit ships four admin pages: - `/dashboard/admin/users` - `/dashboard/admin/organizations` - `/dashboard/admin/notifications` - `/dashboard/admin/app-config` Subscription and credit controls are part of the Organizations page. There is no standalone `/dashboard/admin/subscriptions` or `/dashboard/admin/credits` page. ## Access the Admin Area 1. Assign the `admin` platform role to a user. See [Admin UI](/docs/starter-kits/pro-nextjs-prisma/authentication/superadmin). 2. Sign in with that account. 3. Open the Admin entry from the organization switcher. It links to `/dashboard/admin/users`. `app/(saas)/dashboard/(sidebar)/admin/layout.tsx` checks the server session. A non-admin user is redirected to `/dashboard`. ## Shipped Features ### Users The Users page provides the account management actions documented in the [Users guide](/docs/starter-kits/pro-nextjs-prisma/admin-panel/users). ### Organizations The Organizations page includes: - Search, pagination and filters - Subscription and credit balance summaries - CSV and Excel exports - Stripe synchronization for selected organizations - Credit adjustments for one organization - Subscription cancellation at period end - Organization deletion See the [Organizations guide](/docs/starter-kits/pro-nextjs-prisma/admin-panel/organizations) for the exact fields and procedures. ### Notifications The Notifications page provides targeted and broadcast in-app messages, delivery history, filters, details and confirmed bulk deletion. Signed-in users read their own messages from the notification center in the application sidebar. See the [Notifications guide](/docs/starter-kits/pro-nextjs-prisma/admin-panel/notifications) for the database model, routes and customization points. ### App Config The App Config page displays values from the app, authentication, billing and storage configuration files. It is read-only. Change configuration in the corresponding files and redeploy the application. ## Admin tRPC Routers The admin router registers only these namespaces: ```ts filename="trpc/routers/admin/index.ts" lineNumbers export const adminRouter = createTRPCRouter({ notification: adminNotificationRouter, organization: adminOrganizationRouter, user: adminUserRouter }); ``` Admin billing actions therefore use `trpc.admin.organization`. The kit does not register `trpc.admin.subscription` or `trpc.admin.credit`. ## Next Steps - [Users](/docs/starter-kits/pro-nextjs-prisma/admin-panel/users) - Manage accounts - [Organizations](/docs/starter-kits/pro-nextjs-prisma/admin-panel/organizations) - Manage organizations and billing summaries - [Notifications](/docs/starter-kits/pro-nextjs-prisma/admin-panel/notifications) - Send and review in-app notifications - [Subscriptions](/docs/starter-kits/pro-nextjs-prisma/admin-panel/subscriptions) - Use the shipped subscription controls - [Credits](/docs/starter-kits/pro-nextjs-prisma/admin-panel/credits) - Use the shipped credit controls - [App Config](/docs/starter-kits/pro-nextjs-prisma/admin-panel/app-config) - Inspect runtime configuration --- ## Subscriptions **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/admin-panel/subscriptions **Description**: Monitor and manage organization subscriptions from the admin Organizations page. The shipped kit manages subscriptions from `/dashboard/admin/organizations`. It does not include a standalone `/dashboard/admin/subscriptions` route or an `admin.subscription` tRPC router. ## Included Admin Controls The Organizations table shows the latest subscription for each organization. Admins can: - Search for an organization by name - Filter organizations by subscription status - Open an active subscription in Stripe - Schedule an active subscription to cancel at the end of its billing period - Sync subscription and order data from Stripe The table derives its displayed plan label from the text before the first underscore in the stored Stripe price ID and also displays the subscription status. It does not resolve that value against configured plan names or provide revenue analytics or payment history. ## List Subscription Summaries Subscription summaries are returned by the existing organization list procedure: ```tsx filename="components/admin/organizations/organizations-table.tsx" lineNumbers const { data, isPending } = trpc.admin.organization.list.useQuery({ limit: 25, offset: 0, query: '', sortBy: 'name', sortOrder: 'asc', filters: { subscriptionStatus: ['active', 'trialing'] } }); const organizations = data?.organizations ?? []; // Each result includes subscriptionId, subscriptionStatus, // subscriptionPlan and cancelAtPeriodEnd. ``` There is no `trpc.admin.subscription.list` procedure. The list returns the most recent stored subscription for each organization instead of every subscription record. ## Cancel a Subscription The row action uses the admin organization router: ```tsx lineNumbers const cancelSubscription = trpc.admin.organization.cancelSubscription.useMutation(); cancelSubscription.mutate({ subscriptionId, immediate: false }); ``` The shipped interface always passes `immediate: false`, which schedules the subscription to cancel at the end of its current period. The procedure also accepts `immediate: true` for custom admin interfaces. Stripe webhooks update the local subscription record after cancellation. ## Sync Billing Data from Stripe Use the sync procedure with one or more organization IDs: ```tsx lineNumbers const syncFromStripe = trpc.admin.organization.syncFromStripe.useMutation(); syncFromStripe.mutate({ organizationIds: [organizationId] }); ``` This syncs subscriptions and one-time orders for the selected organizations. The input accepts between 1 and 1,000 organization IDs. The Organizations table supports both a row action and a bulk action for this procedure. ## Not Included The current kit does not include: - A separate admin subscriptions page - `trpc.admin.subscription.list`, `trpc.admin.subscription.cancel` or `trpc.admin.subscription.syncFromStripe` - A global subscription table with revenue or churn reporting - Admin payment history or bulk subscription cancellation Organization owners and organization admins manage their own billing from `/dashboard/organization/settings?tab=subscription` through the `trpc.organization.subscription` router. ## Related Documentation - [Organizations](/docs/starter-kits/pro-nextjs-prisma/admin-panel/organizations) - Use the shipped admin interface - [Subscriptions](/docs/starter-kits/pro-nextjs-prisma/billing/subscriptions) - Understand subscription billing - [Webhooks](/docs/starter-kits/pro-nextjs-prisma/billing/webhooks) - Keep local billing data synchronized --- ## Users **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/admin-panel/users **Description**: Manage users, ban/unban accounts, and view user details in the admin panel. The Users section of the admin panel allows you to manage all users in your application, including viewing user details, filtering users, banning/unbanning accounts, and exporting user data. ## Features ### View All Users The users table displays all registered users with the following information: - **Name and Email** - User identification - **Role** - User role (user or admin) - **Verification Status** - Whether the email is verified - **Ban Status** - Whether the user is banned - **Created Date** - When the user account was created ### Filter Users You can filter users by multiple criteria: - **Search Query** - Search by name or email - **Role** - Filter by user role (user, admin) - **Email Verification** - Filter by verification status (verified, pending) - **Ban Status** - Filter by ban status (banned, active) - **Creation Date** - Filter by when the account was created (today, this week, this month, older) ### Ban/Unban Users You can ban users temporarily or permanently: ```tsx filename="components/admin/users/ban-user-modal.tsx" lineNumbers import { banUserAdminSchema } from '@/schemas/admin-user-schemas'; // Ban user with optional expiration date const form = useZodForm({ schema: banUserAdminSchema, defaultValues: { userId, reason: '', expiresAt: undefined // Omit the date for a permanent ban } }); ``` **Ban Options:** - **Permanent Ban** - Omit `expiresAt` or set it to `undefined` - **Temporary Ban** - Set `expiresAt` to a future date to automatically unban the user - **Ban Reason** - Provide a reason for the ban (stored for audit purposes) ### Export Users Export user data to CSV format for analysis or backup: ```ts filename="trpc/routers/admin/admin-user-router.ts" lineNumbers exportSelectedToCsv: protectedAdminProcedure .input(exportUsersAdminSchema) .mutation(async ({ input }) => { const users = await prisma.user.findMany({ where: { id: { in: input.userIds } }, select: { id: true, name: true, email: true, emailVerified: true, role: true, banned: true, onboardingComplete: true, twoFactorEnabled: true, createdAt: true, updatedAt: true, }, }); const Papa = await import('papaparse'); const csv = Papa.unparse(users); return csv; }), ``` ## Using the Admin Users API ### List Users ```tsx filename="components/admin/users/users-table.tsx" lineNumbers import { trpc } from '@/trpc/client'; export function UsersTable() { const { data, isPending } = trpc.admin.user.list.useQuery({ limit: 25, offset: 0, query: '', // Optional search query sortBy: 'name', // 'name' | 'email' | 'role' | 'createdAt' sortOrder: 'asc', // 'asc' | 'desc' filters: { role: ['user'], // Optional role filter emailVerified: ['verified'], // Optional: 'verified' | 'pending' banned: ['active'], // Optional: 'active' | 'banned' createdAt: ['today'] // Optional: 'today' | 'this-week' | 'this-month' | 'older' } }); return (
{data?.users.map((user) => (
{user.name} - {user.email}
))}
); } ``` ### Ban a User ```tsx filename="components/admin/users/ban-user-modal.tsx" lineNumbers const banUser = trpc.admin.user.banUser.useMutation({ onSuccess: () => { toast.success('User banned successfully'); utils.admin.user.list.invalidate(); } }); const handleBan = (data: BanUserInput) => { banUser.mutate({ userId: user.id, reason: data.reason, expiresAt: data.expiresAt }); }; ``` ### Unban a User ```tsx filename="components/admin/users/users-table.tsx" lineNumbers const unbanUser = trpc.admin.user.unbanUser.useMutation({ onSuccess: () => { toast.success('User unbanned successfully'); utils.admin.user.list.invalidate(); } }); const handleUnban = (userId: string) => { unbanUser.mutate({ userId }); }; ``` ## User Management Best Practices ### When to Ban Users Ban users when they: - Violate the terms of service - Engage in abusive behavior - Attempt to exploit the system - Show signs of fraudulent activity ### Temporary vs Permanent Bans - **Temporary bans** - Use for first-time violations or minor infractions - **Permanent bans** - Use for serious violations or repeat offenders - Always provide a clear reason for the ban ### User Data Privacy - Only export user data when necessary - Ensure compliance with applicable data protection regulations - Store exported data securely - Delete exported files after use ## Related Documentation - [Admin UI](/docs/starter-kits/pro-nextjs-prisma/authentication/superadmin) - Learn how to create admin users - [Permissions and Access Control](/docs/starter-kits/pro-nextjs-prisma/authentication/permissions) - Understand user roles and permissions --- ## AI **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/ai-integration **Description**: Learn how to leverage the built-in AI features including chatbots and LLM integration. The Pro Next.js Prisma starter kit ships an organization-scoped chat built with **AI SDK 7**, the direct **OpenAI provider**, tRPC history and usage-based credits. This page describes the code in the repository. Sections labeled as custom examples are additions you can build yourself. ## Overview The AI system is built with a hybrid architecture to support high-performance streaming while maintaining a type-safe tRPC API for CRUD operations. | Feature | Technology | Reason | | ------------------- | ------------- | -------------------------------- | | Streaming responses | API Route | tRPC doesn't support streaming | | Chat CRUD | tRPC | Type-safe, cached queries | | State management | Vercel AI SDK | `useChat` hook handles streaming | ## Configuration Add your OpenAI API key to the `.env` file to enable the AI features. ```ini filename=".env" lineNumbers OPENAI_API_KEY=sk-... ``` ## Streaming Endpoint The complete shipped route lives at `app/api/ai/chat/route.ts`. It authenticates the request, validates the selected model, checks organization access and credits, persists the response and returns a text stream. The reduced example below shows the same message normalization and stream protocol without the product-specific billing flow. ```typescript filename="app/api/ai/example/route.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { streamText, type ModelMessage } from 'ai'; import { z } from 'zod/v4'; const messageSchema = z .object({ role: z.enum(['user', 'assistant', 'system']), content: z.string().optional(), parts: z .array( z.object({ type: z.string(), text: z.string().optional() }) ) .optional() }) .passthrough(); const requestSchema = z.object({ messages: z.array(messageSchema) }); function toModelMessages( messages: z.infer[] ): ModelMessage[] { return messages.map((message) => { const content = message.content ?? message.parts?.find((part) => part.type === 'text')?.text ?? ''; switch (message.role) { case 'system': return { role: 'system', content }; case 'assistant': return { role: 'assistant', content }; default: return { role: 'user', content }; } }); } export async function POST(req: Request) { const body = requestSchema.parse(await req.json()); const result = streamText({ model: openai('gpt-4o-mini'), messages: toModelMessages(body.messages) }); return result.toUIMessageStreamResponse({ onError: () => 'AI is temporarily unavailable. Please try again later.' }); } ``` `DefaultChatTransport` and `toUIMessageStreamResponse()` are the matched pair used by the shipped chat. The UI message protocol carries sanitized failures as well as generated text. Normalize message parts before passing them to `streamText`, as the shipped route does. ## UI Components We provide a complete suite of components to build a premium AI chat experience. ### Main Chat Component The `AiChat` component provides a full conversation interface with a history sidebar. ```tsx filename="app/(saas)/dashboard/(sidebar)/organization/chatbot/page.tsx" lineNumbers import { redirect } from 'next/navigation'; import { AiChat } from '@/components/ai/ai-chat'; import { getOrganizationById, getSession } from '@/lib/auth/server'; export default async function ChatbotPage() { const session = await getSession(); const organizationId = session?.session.activeOrganizationId; if (!organizationId) redirect('/dashboard'); const organization = await getOrganizationById(organizationId); if (!organization) redirect('/dashboard'); return ; } ``` ### Custom Hook For more control, you can use the `useChat` hook directly from the Vercel AI SDK. ```tsx filename="components/my-custom-ai.tsx" lineNumbers 'use client'; import { useState, type FormEvent } from 'react'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; type MyCustomAIProps = { chatId: string; organizationId: string; }; export function MyCustomAI({ chatId, organizationId }: MyCustomAIProps) { const [input, setInput] = useState(''); const { messages, sendMessage, status } = useChat({ id: chatId, transport: new DefaultChatTransport({ api: '/api/ai/chat', body: { chatId, organizationId } }) }); const isSending = status === 'submitted' || status === 'streaming'; function handleSubmit(event: FormEvent) { event.preventDefault(); const text = input.trim(); if (!text) return; setInput(''); sendMessage({ role: 'user', parts: [{ type: 'text', text }] }); } return (

{messages.length} messages

setInput(event.target.value)} disabled={isSending} />
); } ``` ## Custom Example: Tool Calling The shipped route does not register tools. You can add a tool definition like this and pass it to `streamText`. The UI message protocol can carry tool parts, but the client must render and handle each tool state. ```typescript filename="lib/ai/find-leads-tool.ts" lineNumbers import { tool } from 'ai'; import { z } from 'zod/v4'; import { prisma } from '@/lib/db'; export const findLeadsTool = tool({ description: 'Find leads in the database', inputSchema: z.object({ query: z.string() }), execute: async ({ query }) => { return await prisma.lead.findMany({ where: { name: { contains: query, mode: 'insensitive' } } }); } }); ``` --- ## AI **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/ai **Description**: Learn how to leverage the built-in AI features including chatbots and LLM integration. The Pro Next.js Prisma starter kit ships an organization-scoped chat built with **AI SDK 7**, the direct **OpenAI provider**, tRPC history and usage-based credits. This page describes the code in the repository. Sections labeled as custom examples are additions you can build yourself. ## Overview The AI system is built with a hybrid architecture to support high-performance streaming while maintaining a type-safe tRPC API for CRUD operations. | Feature | Technology | Reason | | ------------------- | ------------- | -------------------------------- | | Streaming responses | API Route | tRPC doesn't support streaming | | Chat CRUD | tRPC | Type-safe, cached queries | | State management | Vercel AI SDK | `useChat` hook handles streaming | ## Configuration Add your OpenAI API key to the `.env` file to enable the AI features. ```ini filename=".env" lineNumbers OPENAI_API_KEY=sk-... ``` ## Streaming Endpoint The complete shipped route lives at `app/api/ai/chat/route.ts`. It authenticates the request, validates the selected model, checks organization access and credits, persists the response and returns a text stream. The reduced example below shows the same message normalization and stream protocol without the product-specific billing flow. ```typescript filename="app/api/ai/example/route.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { streamText, type ModelMessage } from 'ai'; import { z } from 'zod/v4'; const messageSchema = z .object({ role: z.enum(['user', 'assistant', 'system']), content: z.string().optional(), parts: z .array( z.object({ type: z.string(), text: z.string().optional() }) ) .optional() }) .passthrough(); const requestSchema = z.object({ messages: z.array(messageSchema) }); function toModelMessages( messages: z.infer[] ): ModelMessage[] { return messages.map((message) => { const content = message.content ?? message.parts?.find((part) => part.type === 'text')?.text ?? ''; switch (message.role) { case 'system': return { role: 'system', content }; case 'assistant': return { role: 'assistant', content }; default: return { role: 'user', content }; } }); } export async function POST(req: Request) { const body = requestSchema.parse(await req.json()); const result = streamText({ model: openai('gpt-4o-mini'), messages: toModelMessages(body.messages) }); return result.toUIMessageStreamResponse({ onError: () => 'AI is temporarily unavailable. Please try again later.' }); } ``` `DefaultChatTransport` and `toUIMessageStreamResponse()` are the matched pair used by the shipped chat. The UI message protocol carries sanitized failures as well as generated text. Normalize message parts before passing them to `streamText`, as the shipped route does. ## UI Components We provide a complete suite of components to build a premium AI chat experience. ### Main Chat Component The `AiChat` component provides a full conversation interface with a history sidebar. ```tsx filename="app/(saas)/dashboard/(sidebar)/organization/chatbot/page.tsx" lineNumbers import { redirect } from 'next/navigation'; import { AiChat } from '@/components/ai/ai-chat'; import { getOrganizationById, getSession } from '@/lib/auth/server'; export default async function ChatbotPage() { const session = await getSession(); const organizationId = session?.session.activeOrganizationId; if (!organizationId) redirect('/dashboard'); const organization = await getOrganizationById(organizationId); if (!organization) redirect('/dashboard'); return ; } ``` ### Custom Hook For more control, you can use the `useChat` hook directly from the Vercel AI SDK. ```tsx filename="components/my-custom-ai.tsx" lineNumbers 'use client'; import { useState, type FormEvent } from 'react'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; type MyCustomAIProps = { chatId: string; organizationId: string; }; export function MyCustomAI({ chatId, organizationId }: MyCustomAIProps) { const [input, setInput] = useState(''); const { messages, sendMessage, status } = useChat({ id: chatId, transport: new DefaultChatTransport({ api: '/api/ai/chat', body: { chatId, organizationId } }) }); const isSending = status === 'submitted' || status === 'streaming'; function handleSubmit(event: FormEvent) { event.preventDefault(); const text = input.trim(); if (!text) return; setInput(''); sendMessage({ role: 'user', parts: [{ type: 'text', text }] }); } return (

{messages.length} messages

setInput(event.target.value)} disabled={isSending} />
); } ``` ## Custom Example: Tool Calling The shipped route does not register tools. You can add a tool definition like this and pass it to `streamText`. The UI message protocol can carry tool parts, but the client must render and handle each tool state. ```typescript filename="lib/ai/find-leads-tool.ts" lineNumbers import { tool } from 'ai'; import { z } from 'zod/v4'; import { prisma } from '@/lib/db'; export const findLeadsTool = tool({ description: 'Find leads in the database', inputSchema: z.object({ query: z.string() }), execute: async ({ query }) => { return await prisma.lead.findMany({ where: { name: { contains: query, mode: 'insensitive' } } }); } }); ``` --- ## Chatbot **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/ai/chatbot **Description**: Build AI-powered chatbots with streaming responses and conversation history. The starter kit ships a complete organization chatbot with text streaming, conversation history, model selection and credit accounting. The repository uses AI SDK 7. Custom examples on this page are labeled so they are not confused with shipped files. ## Overview The chatbot uses: - **Vercel AI SDK** - For streaming responses and state management - **tRPC** - For type-safe chat CRUD operations - **OpenAI** - For the LLM backend (configurable) ## Streaming Endpoint The shipped `app/api/ai/chat/route.ts` authenticates and validates each request, checks organization ownership and credit balance, normalizes UI message parts, calls OpenAI, deducts the actual credit cost and saves the response. This reduced example keeps the same UI message protocol and normalization but omits model selection and credit accounting. ```typescript filename="app/api/ai/chat/route.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { streamText, type ModelMessage } from 'ai'; import { assertUserIsOrgMember, getSession } from '@/lib/auth/server'; import { prisma } from '@/lib/db'; type ChatRequest = { messages: Array<{ role: 'user' | 'assistant' | 'system'; content?: string; parts?: Array<{ type: string; text?: string }>; }>; chatId: string; organizationId: string; }; function toModelMessages(messages: ChatRequest['messages']): ModelMessage[] { return messages.map((message) => { const content = message.content ?? message.parts?.find((part) => part.type === 'text')?.text ?? ''; switch (message.role) { case 'system': return { role: 'system', content }; case 'assistant': return { role: 'assistant', content }; default: return { role: 'user', content }; } }); } export async function POST(req: Request) { const session = await getSession(); if (!session) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } const { messages: uiMessages, chatId, organizationId }: ChatRequest = await req.json(); await assertUserIsOrgMember(organizationId, session.user.id); const messages = toModelMessages(uiMessages); const result = streamText({ model: openai('gpt-4o-mini'), messages, async onFinish({ text }) { // Save assistant's response to the database const updatedMessages = [ ...messages, { role: 'assistant' as const, content: text } ]; await prisma.aiChat.updateMany({ where: { id: chatId, organizationId }, data: { messages: JSON.stringify(updatedMessages) } }); } }); return result.toUIMessageStreamResponse({ onError: () => 'AI is temporarily unavailable. Please try again later.' }); } ``` `DefaultChatTransport` on the client must stay paired with `toUIMessageStreamResponse()` on the server. Do not pass the hook's raw `UIMessage[]` to `streamText`; normalize its `parts` to model content first, as the shipped route does. ## UI Components ### Main Chat Component The `AiChat` component provides a full conversation interface with a history sidebar. ```tsx filename="app/(saas)/dashboard/(sidebar)/organization/chatbot/page.tsx" lineNumbers import { AiChat } from '@/components/ai/ai-chat'; import { getSession } from '@/lib/auth/server'; export default async function AiPage() { const session = await getSession(); const organizationId = session?.session.activeOrganizationId; if (!organizationId) { return
No active organization
; } return ; } ``` ### Custom Component This optional component shows the AI SDK 7 transport and input APIs in a self-contained example. The shipped `AiChat` component has additional history, billing and error handling behavior. ```tsx filename="components/my-custom-ai.tsx" lineNumbers 'use client'; import { useState, type FormEvent } from 'react'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; import { MessageResponse } from '@/components/ai/message'; type MyCustomAIProps = { chatId: string; organizationId: string; }; export function MyCustomAI({ chatId, organizationId }: MyCustomAIProps) { const [input, setInput] = useState(''); const { messages, sendMessage, status } = useChat({ id: chatId, transport: new DefaultChatTransport({ api: '/api/ai/chat', body: { chatId, organizationId } }) }); const isSending = status === 'submitted' || status === 'streaming'; function handleSubmit(event: FormEvent) { event.preventDefault(); const text = input.trim(); if (!text) return; setInput(''); sendMessage({ role: 'user', parts: [{ type: 'text', text }] }); } return (
{messages.map((message) => { const text = message.parts .filter((part) => part.type === 'text') .map((part) => part.text) .join(''); return (
{message.role}:{' '} {message.role === 'assistant' ? ( {text} ) : ( {text} )}
); })}
setInput(event.target.value)} placeholder="Type a message..." disabled={isSending} />
); } ``` ## Conversation History Chats are stored in the database and can be retrieved via tRPC: ```typescript filename="trpc/routers/organization/organization-ai-router.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { prisma } from '@/lib/db'; export const organizationAiRouter = createTRPCRouter({ listChats: protectedOrganizationProcedure .input( z .object({ limit: z.number().min(1).max(100).optional().default(20), offset: z.number().min(0).optional().default(0) }) .optional() ) .query(async ({ ctx, input }) => { const limit = input?.limit ?? 20; const offset = input?.offset ?? 0; // Uses raw SQL for efficient querying without loading full message arrays const chats = await prisma.$queryRaw< Array<{ id: string; title: string | null; pinned: boolean; createdAt: Date; firstMessageContent: string | null; }> >` SELECT id, title, pinned, created_at as "createdAt", CASE WHEN messages IS NOT NULL AND messages::jsonb != '[]'::jsonb THEN (messages::jsonb->0->>'content') ELSE NULL END as "firstMessageContent" FROM ai_chat WHERE organization_id = ${ctx.organization.id}::uuid ORDER BY pinned DESC, created_at DESC LIMIT ${limit} OFFSET ${offset} `; return { chats }; }), getChat: protectedOrganizationProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ ctx, input }) => { const chat = await prisma.aiChat.findFirst({ where: { id: input.id, organizationId: ctx.organization.id } }); if (!chat) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Chat not found' }); } return { chat: { ...chat, messages: chat.messages ? JSON.parse(chat.messages) : [] } }; }), createChat: protectedOrganizationProcedure .input(z.object({ title: z.string().optional() }).optional()) .mutation(async ({ ctx, input }) => { const chat = await prisma.aiChat.create({ data: { organizationId: ctx.organization.id, title: input?.title || 'New Chat', messages: JSON.stringify([]) } }); return { chat }; }), deleteChat: protectedOrganizationProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ input, ctx }) => { await prisma.aiChat.deleteMany({ where: { id: input.id, organizationId: ctx.organization.id } }); }) }); ``` ## Custom Example: Tool Calling The shipped chat route does not register tools. This custom route expects `ModelMessage[]`, not the `UIMessage[]` returned by `useChat`. Protect it with the same authentication and organization checks as the shipped route before using it in production. ```typescript filename="app/api/ai/chat/route.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { streamText, type ModelMessage } from 'ai'; import { z } from 'zod/v4'; import { prisma } from '@/lib/db'; export async function POST(req: Request) { const { messages }: { messages: ModelMessage[] } = await req.json(); const result = streamText({ model: openai('gpt-4o-mini'), messages, tools: { findLeads: { description: 'Find leads in the database by name', inputSchema: z.object({ query: z.string().describe('The search query') }), execute: async ({ query }) => { const leads = await prisma.lead.findMany({ where: { name: { contains: query, mode: 'insensitive' } }, take: 10 }); return leads; } } } }); return result.toTextStreamResponse(); } ``` ## Custom Example: Generation Settings The shipped route only accepts model IDs from `chatModels` in `config/billing.config.ts`. For a separate fixed-model helper, use the AI SDK 7 `maxOutputTokens` setting: ```typescript filename="lib/ai/generate-short-reply.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { generateText } from 'ai'; export async function generateShortReply(prompt: string) { const { text } = await generateText({ model: openai('gpt-4o-mini'), prompt, temperature: 0.7, maxOutputTokens: 1000 }); return text; } ``` ## Custom Example: Error Handling This custom route also accepts `ModelMessage[]`. A client using `useChat` must normalize its message parts first or switch to the UI message protocol. ```typescript filename="app/api/ai/chat/route.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { streamText, type ModelMessage } from 'ai'; export async function POST(req: Request) { try { const { messages }: { messages: ModelMessage[] } = await req.json(); const result = streamText({ model: openai('gpt-4o-mini'), messages }); return result.toTextStreamResponse(); } catch (error) { console.error('AI chat error:', error); return Response.json( { error: 'Failed to process chat request' }, { status: 500 } ); } } ``` ## Custom Rate Limiting The starter kit does not ship a generic `@/lib/rate-limit` module. It checks organization credit balance before generation and deducts actual usage after generation. If you need request-frequency limits too, add a durable rate-limit provider and enforce it after authentication. ## Best Practices 1. **Stream responses** - Always use streaming for better UX 2. **Save conversations** - Store chat history in the database 3. **Implement rate limiting** - Control API costs 4. **Handle errors** - Provide user-friendly error messages 5. **Use tools wisely** - Add tools for database queries and external APIs 6. **Monitor usage** - Track token usage and costs --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/ai/overview **Description**: Learn about the built-in AI features powered by the Vercel AI SDK. The Pro Next.js Prisma starter kit ships an organization-scoped chat powered by **AI SDK 7** and the direct **OpenAI provider**. It includes text streaming, tRPC conversation history, model selection and usage-based credits. ## Architecture The AI system is built with a hybrid architecture to support high-performance streaming while maintaining a type-safe tRPC API for CRUD operations. | Feature | Technology | Reason | | ------------------- | ------------- | -------------------------------- | | Streaming responses | API Route | tRPC doesn't support streaming | | Chat CRUD | tRPC | Type-safe, cached queries | | State management | Vercel AI SDK | `useChat` hook handles streaming | ## Configuration Add your OpenAI API key to the `.env` file to enable the AI features. ```env filename=".env" lineNumbers OPENAI_API_KEY=sk-... ``` Getting Your API Key Create an API key at{' '} OpenAI Platform . Make sure to keep it secure and never commit it to version control. OPENAI_API_KEY must contain a secret key created in the OpenAI Platform. OpenAI keys commonly begin with sk-. A value beginning with pk_test_ is a Stripe publishable test key and cannot authenticate an OpenAI request. Keep the OpenAI key server-only and never add a NEXT_PUBLIC_ prefix. A ChatGPT Plus, Pro or Team subscription does not include OpenAI API usage. The OpenAI Platform project that owns the key must have API billing enabled, available credit and a budget that permits requests. ## Shipped Provider The repository installs `@ai-sdk/openai` and configures OpenAI models in `config/billing.config.ts`. Anthropic, Google, Mistral and other provider packages are not included. Install and configure another provider package before using it in custom code. See the [AI SDK provider directory](https://ai-sdk.dev/providers/ai-sdk-providers) for provider-specific installation and configuration. ## Verify the Integration After adding `OPENAI_API_KEY`, restart the development server and verify the complete chat flow: 1. Create or select an organization with a positive credit balance. 2. Open **AI Chatbot**, create a new chat and send a short prompt with **GPT-4o Mini**. 3. Confirm assistant text streams into the page. 4. Reload the chat and confirm both messages were persisted. 5. Open **Settings → Credits** and confirm a usage transaction was recorded. An HTTP `200` from `/api/ai/chat` alone does not prove generation succeeded. Streaming responses send their headers before the provider has finished. If no assistant text appears, verify the server-side key, OpenAI quota and access to the selected model, then inspect the server logs for the provider error. ### No assistant response Use the provider error in the development server log to identify the failing layer: | Provider error | Meaning | What to check | | ------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `invalid_api_key` or HTTP `401` | OpenAI rejected the credential | Create a server key in the intended OpenAI Platform project, replace `OPENAI_API_KEY` and restart the development server | | `insufficient_quota` or HTTP `429` with a quota message | The key is valid but its project cannot spend | Enable API billing, add credit and confirm the project budget allows requests | | `model_not_found` or HTTP `404` | The project cannot use the selected model | Select a model listed in `config/billing.config.ts` that is available to the project | | `insufficient_credits` or HTTP `402` from Achromatic | The organization does not have enough application credits | Add credits in the application, then retry the message | OpenAI API keys are scoped to a project. When you create a replacement key, verify that the selected project is the same project where API billing and the budget are configured. Never paste a real key into an issue, support message or client-side environment variable. Rotate any key that has been shared. The shipped route logs the original provider failure on the server and sends a reviewed quota, credential, model, rate-limit or generic message through the UI stream. Raw provider responses stay out of the browser because they can contain sensitive request details. --- ## Prompting **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/ai/prompting **Description**: Learn how to use LLMs for text generation, completion and prompting. The starter kit ships the AI chat described in the [Chatbot guide](/docs/starter-kits/pro-nextjs-prisma/ai/chatbot). The examples on this page are custom additions you can build with the same AI SDK 7 and OpenAI packages already installed in the repository. ## Basic Text Generation Generate text using the `generateText` function: ```typescript filename="lib/ai/generate.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { generateText } from 'ai'; export async function generateSummary(content: string) { const { text } = await generateText({ model: openai('gpt-4o-mini'), prompt: `Summarize the following content in 3 sentences:\n\n${content}` }); return text; } ``` ## Server Actions Use AI in Server Actions: ```typescript filename="app/actions/generate-content.ts" lineNumbers 'use server'; import { openai } from '@ai-sdk/openai'; import { generateText } from 'ai'; export async function generateBlogPost(topic: string) { const { text } = await generateText({ model: openai('gpt-4o-mini'), prompt: `Write a blog post about: ${topic}`, maxOutputTokens: 2000 }); return text; } ``` ## Structured Outputs Generate structured JSON outputs: ```typescript filename="lib/ai/generate-structured.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { generateText, Output } from 'ai'; import { z } from 'zod'; const ProductSchema = z.object({ name: z.string(), description: z.string(), price: z.number(), features: z.array(z.string()) }); export async function generateProduct(productType: string) { const { output } = await generateText({ model: openai('gpt-4o-mini'), output: Output.object({ schema: ProductSchema }), prompt: `Generate a product specification for: ${productType}` }); return output; } ``` ## Prompt Templates Create reusable prompt templates: ```typescript filename="lib/ai/prompts.ts" lineNumbers export const prompts = { summarize: (content: string) => `Summarize the following content in 3 sentences:\n\n${content}`, translate: (text: string, targetLanguage: string) => `Translate the following text to ${targetLanguage}:\n\n${text}`, extractKeywords: (content: string) => `Extract 5 key keywords from the following content:\n\n${content}`, generateTitle: (content: string) => `Generate a compelling title for the following content:\n\n${content}` }; ``` Usage: ```typescript filename="lib/ai/use-prompts.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { generateText } from 'ai'; import { prompts } from './prompts'; export async function summarizeContent(content: string) { const { text } = await generateText({ model: openai('gpt-4o-mini'), prompt: prompts.summarize(content) }); return text; } ``` ## System Prompts Use system prompts to guide model behavior: ```typescript filename="lib/ai/generate-with-system.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { generateText } from 'ai'; export async function generateResponse(userInput: string) { const { text } = await generateText({ model: openai('gpt-4o-mini'), system: 'You are a helpful assistant that provides concise, accurate answers.', prompt: userInput }); return text; } ``` ## Temperature and Sampling Control randomness and creativity: ```typescript filename="lib/ai/generate-creative.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { generateText } from 'ai'; // Creative writing (higher temperature) export async function generateCreativeStory(prompt: string) { const { text } = await generateText({ model: openai('gpt-4o-mini'), prompt, temperature: 0.9, // More creative maxOutputTokens: 1000 }); return text; } // Factual content (lower temperature) export async function generateFactualContent(prompt: string) { const { text } = await generateText({ model: openai('gpt-4o-mini'), prompt, temperature: 0.2, // More deterministic maxOutputTokens: 500 }); return text; } ``` ## Streaming Text Generation Stream text generation for better UX: ```typescript filename="app/api/ai/generate/route.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { streamText } from 'ai'; export async function POST(req: Request) { const { prompt } = await req.json(); const result = streamText({ model: openai('gpt-4o-mini'), prompt }); return result.toTextStreamResponse(); } ``` Client-side usage: ```tsx filename="components/streaming-generator.tsx" lineNumbers 'use client'; import { useCompletion } from '@ai-sdk/react'; import { MessageResponse } from '@/components/ai/message'; export function StreamingGenerator() { const { completion, input, handleInputChange, handleSubmit, isLoading } = useCompletion({ api: '/api/ai/generate', streamProtocol: 'text' }); return (
{completion}
); } ``` ## Custom Provider Packages The starter kit installs the OpenAI provider only. To add another provider, follow its current instructions in the [AI SDK provider directory](https://ai-sdk.dev/providers/ai-sdk-providers), install the provider package and choose a model that the provider currently supports. Provider packages and model IDs are intentionally not hard-coded here because they are not part of the shipped repository. ## Error Handling Handle API errors gracefully: ```typescript filename="lib/ai/generate-safe.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { generateText } from 'ai'; export async function generateTextSafely(prompt: string) { try { const { text } = await generateText({ model: openai('gpt-4o-mini'), prompt }); return { success: true, text }; } catch (error) { console.error('AI generation error:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } ``` ## Best Practices 1. **Use appropriate models** - Choose models based on task complexity 2. **Set temperature wisely** - Lower for factual, higher for creative 3. **Limit token usage** - Set `maxOutputTokens` to control costs 4. **Use system prompts** - Guide model behavior with system messages 5. **Handle errors** - Always wrap AI calls in try-catch 6. **Cache results** - Cache expensive generations when possible 7. **Monitor usage** - Track token usage and costs --- ## Authentication **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/authentication **Description**: Learn how to manage user authentication and authorization with Better Auth. The Pro Next.js Prisma starter kit uses **Better Auth** for robust and flexible authentication. It supports various providers, multi-factor authentication, and organization-based access control. ## Setup Authentication is primarily configured in `lib/auth/index.ts`. Follow the [Setup](/docs/starter-kits/pro-nextjs-prisma/setup) to set up the basic environment variables. ```typescript filename="lib/auth/index.ts" lineNumbers import { betterAuth } from 'better-auth'; import { prismaAdapter } from 'better-auth/adapters/prisma'; import { prisma } from '@/lib/db'; import { env } from '@/lib/env'; export const auth = betterAuth({ database: prismaAdapter(prisma, { provider: 'postgresql' }), emailAndPassword: { enabled: true }, socialProviders: { google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET } } // ... other configurations }); ``` ## Client Usage On the client side, use the `authClient` and the `useSession` hook to access user information. ```typescript filename="hooks/use-session.tsx" lineNumbers import { authClient } from '@/lib/auth/client'; const { data: session, isPending } = authClient.useSession(); ``` ## Protecting Routes ### API Routes Better Auth handles authentication through API routes at `app/api/auth/[...all]/route.ts`. This route proxies all authentication requests to Better Auth. ### Page Protection Routes are protected by checking the session in Server Components. Use `getSession()` from `@/lib/auth/server` to verify authentication. ### Server-side (tRPC) For API routes, use `protectedProcedure` or `protectedOrganizationProcedure` in your tRPC routers. ```typescript filename="trpc/routers/organization/organization-router.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; export const organizationRouter = createTRPCRouter({ get: protectedOrganizationProcedure.query(async ({ ctx }) => { // Current organization is available in ctx.organization return ctx.organization; }) }); ``` ## Organizations Multi-tenancy is built-in. Users can create, join, and switch between organizations. ### Creating an Organization ```typescript filename="components/organization-switcher.tsx" lineNumbers await authClient.organization.create({ name: 'My New Company' }); ``` ### Inviting Members ```typescript filename="lib/actions/organization.ts" lineNumbers await authClient.organization.inviteMember({ email: 'teammate@example.com', role: 'member' }); ``` ## Admin Features Platform admins can manage users and organizations through a dedicated admin panel. ### Impersonation Admins can impersonate users for debugging purposes. ```typescript filename="lib/actions/admin.ts" lineNumbers await authClient.admin.impersonateUser({ userId: 'user-id-to-impersonate' }); ``` ### Banning Users Admins can ban users permanently or for a specific duration. ```typescript filename="trpc/routers/admin/admin-user-router.ts" lineNumbers import { banUserAdminSchema } from '@/schemas/admin-user-schemas'; import { createTRPCRouter, protectedAdminProcedure } from '@/trpc/init'; export const adminUserRouter = createTRPCRouter({ banUser: protectedAdminProcedure .input(banUserAdminSchema) .mutation(async ({ input, ctx }) => { // Ban user logic return { success: true }; }) }); ``` --- ## OAuth Providers **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/authentication/oauth **Description**: Learn how to set up and configure OAuth providers. The starter kit comes with Google OAuth pre-configured, but you can easily add additional OAuth providers like Facebook, GitHub or any provider supported by Better Auth. ## Plan OAuth URLs for Every Environment OAuth providers compare the callback URL in each request with the URLs saved in their developer console. Register every environment where people will complete sign-in: | Environment | Application origin | Google callback URL | | ----------------- | -------------------------------- | --------------------------------------------------------- | | Local development | `http://localhost:3000` | `http://localhost:3000/api/auth/callback/google` | | Staging | `https://staging.yourdomain.com` | `https://staging.yourdomain.com/api/auth/callback/google` | | Production | `https://yourdomain.com` | `https://yourdomain.com/api/auth/callback/google` | The kit derives Better Auth's `baseURL` from `getBaseUrl()` in `lib/utils.ts`. On Vercel, preview deployments use `NEXT_PUBLIC_VERCEL_BRANCH_URL`. Production uses `NEXT_PUBLIC_SITE_URL` when it is set, then falls back to Vercel's generated URL. Google requires an exact registered callback URL. A new branch preview can have a new hostname, so arbitrary preview URLs are not a reliable place to test OAuth. Use a stable staging domain for repeatable pre-production testing or add the exact preview callback URL before testing that deployment. Use separate OAuth clients for local or staging work and production when your provider supports it. Store each client secret only in that environment and never expose it through a NEXT_PUBLIC_ variable. ## Google OAuth (Pre-configured) Google OAuth is already set up in the starter kit. To enable it: ### 1. Create Google OAuth Credentials 1. Visit the [Google Cloud Console](https://console.cloud.google.com/) 2. Create a new project or select an existing one 3. Navigate to **APIs & Services** > **Credentials** 4. Click **Create Credentials** > **OAuth client ID** 5. Configure the OAuth consent screen if you haven't already 6. Select **Web application** as the application type 7. Add authorized JavaScript origins: - `http://localhost:3000` (for development) - `https://yourdomain.com` (for production) 8. Add authorized redirect URIs: - `http://localhost:3000/api/auth/callback/google` (for development) - `https://yourdomain.com/api/auth/callback/google` (for production) 9. Copy the **Client ID** and **Client Secret** ### 2. Configure Environment Variables Add the credentials to your `.env` file: ```env filename=".env" lineNumbers GOOGLE_CLIENT_ID=your-google-client-id GOOGLE_CLIENT_SECRET=your-google-client-secret ``` ### 3. Verify Configuration The Google provider is already configured in `lib/auth/index.ts`: ```typescript filename="lib/auth/index.ts" lineNumbers socialProviders: { google: { prompt: "select_account", clientId: env.GOOGLE_CLIENT_ID ?? "", clientSecret: env.GOOGLE_CLIENT_SECRET ?? "", scope: ["email", "profile"], }, }, ``` The sign-in and sign-up pages render providers from `lib/auth/oauth-providers.tsx`. Google is included there by default. If the credentials are not ready, set `enableSocialLogin` to `false` in `config/auth.config.ts` so users are not shown a button that cannot complete authentication. ### 4. Test Both Environments 1. Restart the development server after changing `.env`. 2. Sign in with a Google account and confirm the callback returns to `/dashboard`. 3. Confirm a first-time Google user is created and an existing user follows the account-linking behavior you intend. 4. Repeat the flow on the final HTTPS production domain. Preview and production deployments need callback URLs accepted by the provider before they can complete OAuth. ## Adding Additional OAuth Providers To add a new OAuth provider (e.g., Facebook, GitHub), follow these steps: ### 1. Get Provider Credentials Create an application with your chosen OAuth provider and obtain the Client ID and Client Secret. ### 2. Add Environment Variables Add the provider credentials to your `.env` file: ```env filename=".env" lineNumbers FACEBOOK_CLIENT_ID=your-facebook-client-id FACEBOOK_CLIENT_SECRET=your-facebook-client-secret ``` ### 3. Update Auth Configuration Add the provider to `lib/auth/index.ts`: ```typescript filename="lib/auth/index.ts" lineNumbers import { betterAuth } from 'better-auth'; export const auth = betterAuth({ // ... other config account: { accountLinking: { enabled: true, trustedProviders: ['google', 'facebook'] // Add new provider here } }, socialProviders: { google: { // ... existing Google config }, facebook: { clientId: env.FACEBOOK_CLIENT_ID ?? '', clientSecret: env.FACEBOOK_CLIENT_SECRET ?? '' } } }); ``` ### 4. Update Environment Schema Add the new variables to `lib/env.ts`: ```typescript filename="lib/env.ts" lineNumbers server: { // ... existing variables FACEBOOK_CLIENT_ID: z.string().optional(), FACEBOOK_CLIENT_SECRET: z.string().optional(), }, ``` ```typescript filename="lib/env.ts" lineNumbers runtimeEnv: { // ... existing variables FACEBOOK_CLIENT_ID: process.env.FACEBOOK_CLIENT_ID, FACEBOOK_CLIENT_SECRET: process.env.FACEBOOK_CLIENT_SECRET, }, ``` ### 5. Add Provider to the UI Registry Add the provider's display name and icon to `lib/auth/oauth-providers.tsx`. The existing sign-in and sign-up cards iterate over this registry, so you do not need to create another button component: ```tsx filename="lib/auth/oauth-providers.tsx" lineNumbers export const oAuthProviders = { google: { name: 'Google', icon: GoogleIcon }, facebook: { name: 'Facebook', icon: FacebookIcon } } as const; ``` The registry key must match the provider key passed to Better Auth. Keep `enableSocialLogin` enabled in `config/auth.config.ts` when at least one listed provider is fully configured. ## Supported Providers Better Auth supports many OAuth providers out of the box: - Google - Facebook - GitHub - Discord - Apple - Microsoft - And many more... For configuration details and social sign-in examples, see the [Better Auth OAuth documentation](https://better-auth.com/docs/concepts/oauth). ## Account Linking The starter kit has account linking enabled, which allows users to connect multiple OAuth providers to the same account. This is configured in the `accountLinking` section: ```typescript filename="lib/auth/index.ts" lineNumbers account: { accountLinking: { enabled: true, trustedProviders: ["google"], // Providers that can be linked }, }, ``` When a user signs in with a trusted provider using the same verified email address, Better Auth can link the accounts. Only add providers you trust to verify email ownership. --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/authentication/overview **Description**: Learn more about authentication in the starter kit. Authentication is a core part of any SaaS application. The Pro Next.js Prisma starter kit uses [Better Auth](https://better-auth.com/) to handle authentication and provides all the necessary UI for the authentication flow. Why choose Better Auth? Better Auth is a modern, type-safe authentication solution built for Next.js. It provides a simple API, excellent TypeScript support, and works seamlessly with server components and server actions. The starter kit comes with pre-configured providers, helper methods and extensions. ## Included authentication flows - Email and password registration with email verification - Password reset and email-address changes - Passwordless passkey sign-in with biometric or device-PIN verification - Google OAuth and trusted account linking - Database-backed sessions with device revocation - TOTP two-factor authentication with failed-attempt lockout - Organization invitations and active-organization sessions - Administrator bans and impersonation ### Two-factor authentication Users with a credential account can enroll an authenticator app from **Dashboard → Settings → Security**. Enrollment requires the current password, then a valid six-digit TOTP code. After enrollment, password sign-in redirects to `/auth/verify` until the TOTP challenge succeeds. Better Auth stores TOTP secrets and backup codes in the `two_factor` table. The current schema also tracks whether setup was verified, failed verification attempts and the lockout expiry. Apply committed database migrations when upgrading Better Auth so these security fields exist before deploying the new application code. The included UI verifies TOTP codes for password sign-in. It does not display recovery codes during enrollment or accept a recovery code on the verification page, even though Better Auth stores backup-code data. Google OAuth and other passwordless sign-in methods are not automatically sent through the TOTP challenge either. Add and test those flows before presenting backup-code recovery or universal 2FA enforcement as supported product behavior. ### Passkeys Users can register, rename and remove passkeys from **Dashboard → Settings → Security**, then choose **Sign in with passkey** on the authentication page. The included WebAuthn policy requires user verification through a biometric or device PIN before Better Auth creates a session. A verified passkey completes the passwordless sign-in flow without an additional TOTP challenge. Password sign-in still follows the user's configured two-factor flow. Read the [passkey guide](/docs/starter-kits/pro-nextjs-prisma/authentication/passkeys) for configuration, migrations, security boundaries and testing guidance. Fields such as twoFactorEnabled, banned,{' '} banReason and onboardingComplete are marked with{' '} input: false. Do not expose them through the generic client user update API. Change them through an authenticated server procedure or the corresponding Better Auth endpoint. --- ## Passkeys **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/authentication/passkeys **Description**: Configure passwordless passkey sign-in and account-managed WebAuthn credentials. [Passkeys](https://www.passkeys.com/) let users authenticate with the same biometric, device PIN or external security key they use to unlock a trusted authenticator. The starter kit integrates Better Auth's [passkey plugin](https://better-auth.com/docs/plugins/passkey) with registration, sign-in and account-management UI. ## Included flow - **Sign in with passkey** appears below the configured OAuth providers. - Signed-in users manage passkeys from **Dashboard → Settings → Security**. - A user can register multiple passkeys, give each one a recognizable name, rename it later and remove it with confirmation. - Registration and authentication require WebAuthn user verification. - Browser ceremony errors are mapped to actionable messages instead of a generic authentication failure. The feature is enabled by default: ```typescript filename="config/auth.config.ts" lineNumbers export const authConfig = { // ... other settings enablePasskeys: true }; ``` Setting `enablePasskeys` to `false` removes the sign-in and account-management UI and stops registering the passkey server plugin. It therefore disables the corresponding Better Auth endpoints as well. ## Database migration Passkey metadata is stored in the `passkey` table. The table records the public credential, counter, authenticator information, optional display name and the owning user. Private key material never leaves the user's authenticator. New downloads include the ORM-specific migration. Existing projects must apply it before deploying the passkey-enabled application: ```bash filename="Terminal" npm run db:migrate ``` No new environment variable is required. ## Require user verification The server registers the plugin with `userVerification: 'required'` and checks the authentication result before creating a session: ```typescript filename="lib/auth/index.ts" lineNumbers passkey({ authenticatorSelection: { userVerification: 'required' }, authentication: { afterVerification: async ({ verification }) => { if (!verification.authenticationInfo.userVerified) { throw new APIError('UNAUTHORIZED', { code: 'PASSKEY_USER_VERIFICATION_REQUIRED', message: 'Verify your identity with a PIN or biometric to use this passkey.' }); } } } }); ``` This protects against accepting a ceremony that proves possession of an authenticator without proving the person using it. Keep both the WebAuthn option and the server-side result check when adapting the integration. ## Passkeys and TOTP A user-verified passkey is treated as the complete passwordless sign-in method. It does not redirect to a second TOTP challenge. Password sign-in still follows the user's configured Better Auth two-factor flow. This distinction avoids asking for two independent possession checks during a single passkey ceremony while preserving TOTP for password-based authentication. If your product requires a separate step-up challenge for a sensitive action, implement and test that policy around the action instead of assuming every authentication method passes through the password hook. ## HTTPS and relying-party scope WebAuthn requires a secure context in production. Browsers allow `localhost` during development, but deployed passkeys are scoped to their relying party and origin. Test registration and authentication on the same production domain your customers will use. Changing domains later can prevent existing credentials from matching the new relying party. Plan custom domains and authentication subdomains before relying on passkeys as the only recovery path. ## Test the complete ceremony The starter kits include a Playwright test backed by Chromium's virtual WebAuthn authenticator. It verifies registration, naming, rename, sign-out, passwordless sign-in and deletion. It also switches user verification off and confirms the server rejects the ceremony before retrying with verification enabled. Run the focused test with: ```bash filename="Terminal" npm run with-dev-env -- playwright test tests/e2e/passkeys.spec.ts --project=chromium ``` Keep a normal password or recovery strategy available while evaluating browser, platform-authenticator and security-key support for your customer base. --- ## Permissions and Access Control **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/authentication/permissions **Description**: Learn how to protect pages and display UI based on user roles or permissions. The [tRPC endpoint protection guide](/docs/starter-kits/pro-nextjs-prisma/trpc/protect-endpoint) explains how to enforce access control in your API procedures. In this guide we will show you how you can protect pages and display UI based on the user's role or permissions. ## Protect a route (server side) ### For authenticated users To protect a route to be only accessible for authenticated users, you can simply get the session in the RSC component and check if the user is authenticated. _Note: When you are inside the `/app/(saas)/dashboard` directory, you don't need to check if the user is authenticated, because the session is verified in the middleware._ ```tsx filename="app/dashboard/page.tsx" lineNumbers import { redirect } from 'next/navigation'; import { getSession } from '@/lib/auth/server'; export default async function MyProtectedPage() { const session = await getSession(); if (!session) { return redirect('/auth/sign-in'); } return
My protected page
; } ``` ### For specific roles More interesting is to check if the user has the necessary permissions to access the page. For example, you can make a page only accessible for users with the `admin` role. ```tsx filename="app/admin/page.tsx" lineNumbers import { redirect } from 'next/navigation'; import { getSession } from '@/lib/auth/server'; export default async function MyAdminPage() { const session = await getSession(); if (session?.user.role !== 'admin') { return redirect('/app'); } return
This page is only accessible for admins
; } ``` ### For active or specific subscription Or if you want to check for an active subscription, you can do the following: ```tsx filename="app/premium/page.tsx" lineNumbers import { redirect } from 'next/navigation'; import { getSession } from '@/lib/auth/server'; import { getActivePlanForOrganization, requirePaidPlan, requireSpecificPlan } from '@/lib/billing/guards'; export default async function MyPremiumPage() { const session = await getSession(); if (!session?.activeOrganizationId) { return redirect('/app'); } // Check for any paid plan try { const { planId } = await requirePaidPlan(session.activeOrganizationId); // User has a paid plan } catch { return redirect('/app'); // or show a message to the user that they need to subscribe to the premium plan } // Or check for a specific plan try { const { planId } = await requireSpecificPlan(session.activeOrganizationId, [ 'pro', 'enterprise' ]); // User has pro or enterprise plan } catch { return (
This page is only accessible for users with a pro subscription
); } return (
This page is only accessible for users with an active subscription
); } ``` ### For organization role You can also check if a user has a specific role inside the current organization. For example, you might want to add features that are only available for organization owners or admins. ```tsx filename="app/[organizationSlug]/settings/page.tsx" lineNumbers import { redirect } from 'next/navigation'; import { getOrganizationById, getSession } from '@/lib/auth/server'; import { prisma } from '@/lib/db'; export default async function MyOrganizationPage({ params }: { params: Promise<{ organizationSlug: string }>; }) { const { organizationSlug } = await params; const session = await getSession(); // First, find organization by slug const org = await prisma.organization.findUnique({ where: { slug: organizationSlug }, select: { id: true } }); if (!org) { redirect('/app'); } // Then get full organization with members const organization = await getOrganizationById(org.id); if (!organization) { redirect('/app'); } const membership = organization.members.find( (member) => member.userId === session?.user.id ); if ( !membership || (membership.role !== 'admin' && membership.role !== 'owner') ) { return
This page is only accessible for organization admins
; } return
This page is only accessible for organization admins
; } ``` ## Display UI based on permissions (client side) On client side, you can use the `authClient.useSession()` hook to get the session and then check if the user has the necessary permissions. ### For authenticated users ```tsx filename="components/protected-component.tsx" lineNumbers 'use client'; import { authClient } from '@/lib/auth/client'; export function MyComponent() { const { data: session } = authClient.useSession(); if (!session) { return
You need to be logged in to access this page
; } return
You are logged in
; } ``` Security Note You always want to check the permission on the server side first to avoid any security issues. ### For specific roles ```tsx filename="components/admin-component.tsx" lineNumbers 'use client'; import { authClient } from '@/lib/auth/client'; export function MyComponent() { const { data: session } = authClient.useSession(); if (session?.user.role !== 'admin') { return
This page is only accessible for admins
; } return
This page is only accessible for admins
; } ``` ### For active or specific subscription ```tsx filename="components/premium-component.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function MyComponent() { const { data: subscriptionStatus } = trpc.organization.subscription.getStatus.useQuery(); if (!subscriptionStatus?.enabled) { return
Billing is not enabled
; } if (!subscriptionStatus.activePlan) { return
You don't have an active subscription
; } if ( subscriptionStatus.activePlan.planId !== 'pro' && subscriptionStatus.activePlan.planId !== 'enterprise' ) { return
You need to subscribe to the pro plan to access this page
; } return
You have an active subscription
; } ``` ### For organization role ```tsx filename="components/organization-component.tsx" lineNumbers 'use client'; import { authClient } from '@/lib/auth/client'; export function MyComponent() { const { data: activeOrganization } = authClient.useActiveOrganization(); const { data: session } = authClient.useSession(); if (!activeOrganization || !session) { return
No active organization
; } // Find the user's membership in the active organization const membership = activeOrganization.members.find( (member) => member.userId === session.user.id ); // Check if user is admin or owner const isOrganizationAdmin = membership && (membership.role === 'admin' || membership.role === 'owner'); if (!isOrganizationAdmin) { return
This page is only accessible for organization admins
; } if (membership.role !== 'owner') { return
This page is only accessible for organization owners
; } return
This page is only accessible for organization admins
; } ``` --- ## User and Session **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/authentication/sessions **Description**: Learn how to access the user and session in your application. ## Accessing the user and session You can access the user and session in your application using the `useSession` hook on the client or `getSession` function on the server. ### Client-side ```tsx filename="components/user-profile.tsx" lineNumbers 'use client'; import { authClient } from '@/lib/auth/client'; export function UserProfile() { const { data: session, isPending } = authClient.useSession(); if (isPending) return
Loading...
; if (!session) return
Not authenticated
; return (

Hello, {session.user.name}!

Email: {session.user.email}

); } ``` Both `user` and `session` can be `null` if the user is not authenticated, but if you use the hook inside a `/dashboard/...` route, they should always be defined. The `user` object contains the information of the authenticated user and the `session` object contains the session data. ```typescript filename="types.ts" lineNumbers type Session = { id: string; userId: string; createdAt: Date; updatedAt: Date; expiresAt: Date; token: string; ipAddress?: string | null; userAgent?: string | null; impersonatedBy?: string | null; activeOrganizationId?: string | null; }; type User = { id: string; createdAt: Date; updatedAt: Date; email: string; emailVerified: boolean; name: string; image?: string | null; role: 'admin' | 'user'; onboardingComplete: boolean; twoFactorEnabled?: boolean; banned?: boolean; }; ``` `activeOrganizationId` is selected by the organization plugin and is used by organization-protected pages and tRPC procedures. Treat it as context, not as authorization on its own: protected procedures also verify that the current user is still a member of that organization. The security and administration fields on `User` are returned to the client but are not accepted by the generic client update API. This prevents a browser from marking onboarding complete, enabling two-factor authentication or changing an account ban without the appropriate server-side flow. ### Wait until the session has been loaded In some cases you might want to wait until the session has been loaded before accessing the user and session. For this there is an `isPending` property that you can use. ```tsx filename="components/loading-example.tsx" lineNumbers 'use client'; import { authClient } from '@/lib/auth/client'; export function MyComponent() { const { data: session, isPending } = authClient.useSession(); if (!isPending && !session) { return
Not authenticated
; } if (isPending) { return
Loading...
; } return
Hello, {session.user.name}!
; } ``` ## Reload session If for some reason you need to reload the session, for example when you changed some property of the user like its name or role, you can use the `refetch` function. ```tsx filename="components/reload-session.tsx" lineNumbers 'use client'; import { authClient } from '@/lib/auth/client'; export function MyComponent() { const { data: session, refetch } = authClient.useSession(); const handleReload = async () => { await refetch(); }; return (

Hello, {session?.user.name}!

); } ``` ## Get session on server To use the session on the server, e.g. in a React Server Component, you can use the `getSession` function. ```tsx filename="app/dashboard/page.tsx" lineNumbers import { redirect } from 'next/navigation'; import { getSession } from '@/lib/auth/server'; export default async function DashboardPage() { const session = await getSession(); if (!session) { redirect('/auth/sign-in'); } return
User name: {session.user.name}
; } ``` ### Via tRPC context In tRPC procedures, the session is automatically available in the context: ```typescript filename="trpc/routers/example.ts" lineNumbers import { createTRPCRouter, protectedProcedure } from '@/trpc/init'; export const exampleRouter = createTRPCRouter({ getProfile: protectedProcedure.query(async ({ ctx }) => { // Session is available in ctx.session, user is in ctx.user const user = ctx.user; return user; }) }); ``` ## Session strategy User sessions are securely stored in the database. This allows active sessions to be managed in the security settings (i.e. you can log out all devices) and changes take effect immediately. --- ## Admin UI **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/authentication/superadmin **Description**: Learn how to create admin users and access the admin UI. The starter kit comes with an admin role and a UI for managing users and organizations out of the box. The only thing you need to do is to create a new user and assign it the admin role. ## Create admin user via database You can create an admin user directly in the database. First, start Prisma Studio: ```bash filename="Terminal" lineNumbers npx prisma studio ``` Then: 1. Navigate to the `User` table 2. Create a new user or find an existing user 3. Set the `role` field to `admin` 4. Save the changes ## Assign admin role to existing user If you have already created a user and want to make it an admin, you can update the database entry directly. ### Using Prisma Studio 1. Start Prisma Studio: ```bash filename="Terminal" lineNumbers npx prisma studio ``` 2. Select the `User` table and find the user you want to make an admin 3. Click on the `role` field and change it to `admin` 4. Save the changes ### Using SQL You can also update the role directly using SQL: ```sql filename="update-user-role.sql" lineNumbers UPDATE "User" SET role = 'admin' WHERE email = 'admin@example.com'; ``` ## Next Steps Once you've created an admin user, you can: - **Access the Admin Panel** - Log in and navigate to the Admin section (see [Admin Panel Overview](/docs/starter-kits/pro-nextjs-prisma/admin-panel/overview)) - **Manage your application** - Use the admin panel to manage users, organizations, subscriptions and more --- ## Inngest **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/background-tasks/inngest **Description**: Integrate Inngest with your application for event-driven background workflows. [Inngest](https://www.inngest.com) is a developer-first platform for building reliable background jobs, scheduled functions, and event-driven workflows. It provides a simple API for creating durable functions that automatically retry on failure. The starter kits do not include the Inngest SDK, credentials, functions or API route. This page is an optional implementation recipe. Add it only when its event-driven execution model fits your product. Inngest makes it easy to build reliable background jobs with automatic retries, scheduling, and event-driven workflows. Functions are defined in your codebase and run on Inngest's infrastructure, giving you the reliability of a queue with the simplicity of writing regular functions. ## Setup Visit [Inngest](https://www.inngest.com) and create a free account. Create a new app and note down your signing key. Add your Inngest credentials to your environment variables: ```env filename=".env" lineNumbers INNGEST_EVENT_KEY=your_event_key_here INNGEST_SIGNING_KEY=your_signing_key_here ``` Register both server-only variables in `lib/env.ts` so the client example below typechecks and missing production credentials fail validation: ```diff filename="lib/env.ts" lineNumbers server: { // Existing variables... + INNGEST_EVENT_KEY: z.string().min(1), + INNGEST_SIGNING_KEY: z.string().min(1) } ``` Expose them to the validator in the same file: ```diff filename="lib/env.ts" lineNumbers runtimeEnv: { // Existing variables... + INNGEST_EVENT_KEY: process.env.INNGEST_EVENT_KEY, + INNGEST_SIGNING_KEY: process.env.INNGEST_SIGNING_KEY } ``` Keep these values server-only and use separate Inngest environments and keys for development, previews and production. ## Install dependencies This recipe uses the stable Inngest TypeScript SDK v3 API. Pin that major so a future v4 upgrade does not silently invalidate the examples: ```bash filename="Terminal" lineNumbers npm install inngest@^3 ``` ## Configure Inngest Create an Inngest client: ```typescript filename="lib/inngest.ts" lineNumbers import { EventSchemas, Inngest } from 'inngest'; import * as z from 'zod'; const schemas = new EventSchemas().fromSchema({ 'user/data.process': z.object({ userId: z.string(), operation: z.enum(['export', 'analyze', 'cleanup']) }) }); export const inngest = new Inngest({ id: 'your-app-id', schemas }); ``` The SDK reads `INNGEST_EVENT_KEY` from the server environment when sending and the Next.js handler uses `INNGEST_SIGNING_KEY` to authenticate incoming requests. The event schema gives `inngest.send()` and `event.data` one shared contract with runtime validation. ## Create your first function Create functions in a `lib/inngest/functions` directory: ```typescript filename="lib/inngest/functions/process-user-data.ts" lineNumbers import { inngest } from '@/lib/inngest'; export const processUserData = inngest.createFunction( { id: 'process-user-data' }, { event: 'user/data.process' }, async ({ event, step }) => { const { userId, operation } = event.data; await step.run('process-data', async () => { console.log('Processing user data', { userId, operation }); switch (operation) { case 'export': // Export user data await new Promise((resolve) => setTimeout(resolve, 2000)); return { success: true, result: 'Data exported to CSV' }; case 'analyze': // Analyze user data await new Promise((resolve) => setTimeout(resolve, 5000)); return { success: true, result: { totalActions: 156, avgSessionTime: '4m 32s' } }; case 'cleanup': // Cleanup user data await new Promise((resolve) => setTimeout(resolve, 3000)); return { success: true, result: 'Removed 23 obsolete records' }; default: throw new Error(`Unknown operation: ${operation}`); } }); } ); ``` Create a scheduled function: ```typescript filename="lib/inngest/functions/daily-cleanup.ts" lineNumbers import { inngest } from '@/lib/inngest'; export const dailyCleanup = inngest.createFunction( { id: 'daily-cleanup' }, { cron: '0 2 * * *' }, // Daily at 2 AM async ({ step }) => { await step.run('cleanup-logs', async () => { console.log('Cleaning up old logs'); await new Promise((resolve) => setTimeout(resolve, 5000)); return { logsCleaned: true }; }); await step.run('cleanup-temp-files', async () => { console.log('Cleaning up temporary files'); await new Promise((resolve) => setTimeout(resolve, 3000)); return { tempFilesCleaned: true }; }); await step.run('generate-reports', async () => { console.log('Generating daily reports'); await new Promise((resolve) => setTimeout(resolve, 8000)); return { reportsGenerated: true }; }); } ); ``` ## Register functions Create an API route to serve your Inngest functions: ```typescript filename="app/api/inngest/route.ts" lineNumbers import { serve } from 'inngest/next'; import { inngest } from '@/lib/inngest'; import { dailyCleanup } from '@/lib/inngest/functions/daily-cleanup'; import { processUserData } from '@/lib/inngest/functions/process-user-data'; export const { GET, POST, PUT } = serve({ client: inngest, functions: [processUserData, dailyCleanup] }); ``` ## Triggering functions ### From an API route ```typescript filename="app/api/tasks/process-user-data/route.ts" lineNumbers import { NextRequest, NextResponse } from 'next/server'; import * as z from 'zod'; import { getSession } from '@/lib/auth/server'; import { inngest } from '@/lib/inngest'; const processUserDataSchema = z.object({ operation: z.enum(['export', 'analyze', 'cleanup']) }); export async function POST(request: NextRequest) { const session = await getSession(); if (!session) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const body = await request.json(); const { operation } = processUserDataSchema.parse(body); await inngest.send({ name: 'user/data.process', data: { userId: session.user.id, operation } }); return NextResponse.json({ success: true, message: 'Background task started successfully' }); } ``` ### From a server action ```typescript filename="app/actions/user-actions.ts" lineNumbers 'use server'; import { getSession } from '@/lib/auth/server'; import { inngest } from '@/lib/inngest'; export async function processUserData( operation: 'export' | 'analyze' | 'cleanup' ) { const session = await getSession(); if (!session) { throw new Error('Unauthorized'); } try { await inngest.send({ name: 'user/data.process', data: { userId: session.user.id, operation } }); return { success: true }; } catch (error) { console.error('Failed to trigger background task:', error); throw new Error('Failed to start background task'); } } ``` The authenticated user ID is derived from the server session. Do not accept a user or organization owner from the browser and treat it as authorization. For organization work, verify membership before publishing and send only a stored job ID or the minimum identifiers the worker needs. ## Monitoring and debugging Visit the [Inngest Dashboard](https://app.inngest.com) to monitor your functions: - View function execution logs and performance metrics - Track success and failure rates - Monitor function duration and step execution - Replay failed functions - Set up alerts for function failures ## Best practices ### Use step functions for reliability Break your function into steps using `step.run()` to make it more reliable and debuggable: ```typescript await step.run('step-name', async () => { // This step will be retried independently if it fails return await processData(); }); ``` ### Use descriptive function IDs ```typescript // ✅ Good { id: 'user-data-export-csv'; } // ❌ Not so good { id: 'task1'; } ``` ### Handle errors gracefully ```typescript await step.run('process', async () => { try { return await processData(); } catch (error) { console.error('Processing failed:', error); throw error; // Re-throw to trigger retry } }); ``` ## Next steps With Inngest integrated into your application, you can now: - **Build reliable background jobs** with automatic retries - **Schedule recurring tasks** with cron expressions - **Create event-driven workflows** that respond to events - **Compose complex workflows** using step functions Ready to explore more? Check out the official documentation for advanced features like function composition, event filtering, and more. --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/background-tasks/overview **Description**: Learn about background tasks & cron jobs and how they can power your application. Background work lets an HTTP request acknowledge an operation before all of its work finishes. Use it for jobs that need retries, scheduling or more execution time than the request path should consume. The starter kits do not install or configure a background-task provider by default. The guides in this section show patterns you can add after choosing a provider. Install only the SDK you plan to operate. ## Choose the simplest execution model | Requirement | Recommended starting point | Why | | ------------------------------------------------ | ---------------------------------------------------- | ----------------------------------------------------------- | | The user needs the result immediately | Keep it in the request | The response can report success or failure directly | | Deliver an HTTP message later or on a schedule | QStash | HTTP delivery, signing and retries fit serverless routes | | Run durable steps with retries and observability | Trigger.dev, Inngest or Vercel Workflow | The provider records progress outside the request process | | Run a small recurring operation | A provider schedule that invokes a protected handler | Scheduling remains outside the web process | | Run a persistent in-process worker | A separately operated worker service | Serverless application instances are not persistent workers | Do not add a queue only because a function is asynchronous. A short operation that must succeed before the response is often clearer and safer when it stays in the request. ## Good background-task candidates - Generate exports, reports or media after accepting a request. - Send batches of notifications with provider rate limits. - Synchronize data with an external service and retry transient failures. - Process a webhook after its signature and minimum payload are validated. - Run scheduled cleanup against records that are safe to process repeatedly. - Execute a durable multi-step workflow where progress must survive a restart. Keep authentication, authorization decisions and ordinary interactive database queries in the request path. Run schema migrations as a controlled release step, not as a background job. ## Design the job before choosing a provider Every job should define: 1. **Identity**: a stable job or idempotency key. 2. **Tenant scope**: the organization or user that owns the operation. 3. **Input contract**: a small validated payload containing identifiers rather than large or sensitive objects. 4. **Retry behavior**: which failures are transient and how many attempts are safe. 5. **Completion state**: where the application records pending, successful and failed outcomes. 6. **Operations**: logs, alerts and a documented way to replay or cancel work. Retries, timeouts and provider redelivery can run the same task more than once. Make writes idempotent with a unique operation key, database constraint or transactional state transition. A queue does not make a non-idempotent operation safe automatically. ## Secure the producer and worker - Authorize the user before publishing a job. - Derive organization access from the authenticated session instead of trusting an organization ID supplied by the browser. - Verify provider signatures on public task endpoints. - Store provider tokens and signing keys as server-only environment variables. - Re-check permissions in the worker when delayed execution could outlive the user's membership or access. - Avoid putting access tokens, full customer records or other unnecessary secrets in queue payloads and logs. ## Provider guides These guides are alternatives, not steps that must all be completed: Start with one provider and one narrow job. Verify success, retry, duplicate delivery and permanent failure paths before moving business-critical work out of the request. --- ## Upstash QStash **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/background-tasks/qstash **Description**: Integrate Upstash QStash with your application for serverless-first background task processing. [Upstash QStash](https://upstash.com/docs/qstash/quickstarts/vercel-nextjs) delivers signed HTTP requests with retries, delays and schedules. It fits a serverless Next.js application because the worker is an ordinary route handler, not a persistent process. Add QStash only when its HTTP delivery model fits your job. The starter kit does not include the SDK, credentials, task tables or task routes. ## Install the SDK ```sh filename="Terminal" lineNumbers npm install @upstash/qstash ``` ## Register server-only variables Copy the token and both signing keys from the Upstash console: ```env filename=".env" lineNumbers QSTASH_TOKEN=replace-me QSTASH_CURRENT_SIGNING_KEY=replace-me QSTASH_NEXT_SIGNING_KEY=replace-me ``` Add the variables to the `server` schema in `lib/env.ts`: ```typescript filename="lib/env.ts" lineNumbers server: { // Existing variables... QSTASH_TOKEN: z.string().min(1), QSTASH_CURRENT_SIGNING_KEY: z.string().min(1), QSTASH_NEXT_SIGNING_KEY: z.string().min(1), QSTASH_URL: z.string().url().optional() } ``` Expose them to the server-side validator in the same file: ```typescript filename="lib/env.ts" lineNumbers runtimeEnv: { // Existing variables... QSTASH_TOKEN: process.env.QSTASH_TOKEN, QSTASH_CURRENT_SIGNING_KEY: process.env.QSTASH_CURRENT_SIGNING_KEY, QSTASH_NEXT_SIGNING_KEY: process.env.QSTASH_NEXT_SIGNING_KEY, QSTASH_URL: process.env.QSTASH_URL } ``` Do not prefix these values with `NEXT_PUBLIC_`. Set a separate credential set in every deployed environment. ## Create the publishing client `QSTASH_URL` is optional for the managed service and useful when connecting to a local QStash server: ```typescript filename="lib/qstash.ts" lineNumbers import 'server-only'; import { Client } from '@upstash/qstash'; import { env } from '@/lib/env'; export const qstash = new Client({ token: env.QSTASH_TOKEN, ...(env.QSTASH_URL ? { baseUrl: env.QSTASH_URL } : {}) }); ``` ## Add a signed worker route QStash calls a public HTTP endpoint. Wrap the handler with the official App Router verifier so requests without a valid QStash signature are rejected: ```typescript filename="app/api/tasks/process/route.ts" lineNumbers import { verifySignatureAppRouter } from '@upstash/qstash/nextjs'; import * as z from 'zod'; import { processStoredJob } from '@/lib/tasks/process-stored-job'; const payloadSchema = z.object({ jobId: z.string().uuid() }); async function handler(request: Request): Promise { const { jobId } = payloadSchema.parse(await request.json()); await processStoredJob(jobId); return Response.json({ success: true }); } export const POST = verifySignatureAppRouter(handler); ``` Create `processStoredJob` for your product. Load the job and its organization from the database instead of trusting tenant data carried in the message. Make the state transition idempotent so delivering the same `jobId` twice does not repeat billing, credits, email or another external side effect. Return a non-success status or throw for transient failures that QStash should retry. Record permanent failures so they can be investigated without retrying forever. ## Publish after authorization Authorize the current user and persist a pending job before publishing its ID. Use the kit's `getBaseUrl()` rather than introducing another site URL variable: ```typescript filename="lib/tasks/publish-job.ts" lineNumbers import 'server-only'; import { qstash } from '@/lib/qstash'; import { getBaseUrl } from '@/lib/utils'; export async function publishJob(jobId: string): Promise { const result = await qstash.publishJSON({ url: `${getBaseUrl()}/api/tasks/process`, body: { jobId }, retries: 3 }); return result.messageId; } ``` Do not expose this function directly to the browser. Call it from an authenticated server action, route or tRPC procedure after checking access to the organization that owns the stored job. ## Test locally The managed QStash service cannot deliver to an inaccessible localhost URL. Use the official local server or a public development tunnel. Start local QStash in one terminal: ```sh filename="Terminal" lineNumbers npx @upstash/qstash-cli dev ``` Copy the printed local URL, token and signing keys into the root `.env`, then restart `npm run dev`. Publish a test job and verify all four outcomes: 1. The producer returns a QStash message ID. 2. The signed worker changes the stored job from pending to successful. 3. A failing attempt is retried and remains observable. 4. Publishing the same `jobId` again does not repeat its side effect. ## Production checklist - Set managed QStash credentials in the production environment and remove any local `QSTASH_URL` override. - Confirm the destination uses the final HTTPS production origin. - Keep both signing keys configured so key rotation can complete safely. - Monitor QStash delivery logs and the application's stored job state. - Add alerts for exhausted retries and jobs that remain pending too long. - Avoid logging message bodies when they contain customer or organization data. For delays, schedules, queues and callbacks, use the [QStash TypeScript SDK documentation](https://upstash.com/docs/qstash/sdks/ts/gettingstarted). --- ## trigger.dev **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/background-tasks/trigger **Description**: Integrate trigger.dev with your application for reliable background task processing. [trigger.dev](https://trigger.dev) is an open-source background jobs framework that lets you write reliable workflows in plain async code. The starter kits do not include Trigger.dev packages, configuration, task definitions or credentials. Add this recipe only after choosing Trigger.dev as your background-task provider. trigger.dev provides automatic retries, real-time monitoring, and seamless scaling - all while letting you write background tasks in familiar JavaScript/TypeScript code directly in your project. ## Setup Visit [trigger.dev](https://trigger.dev) and create a free account. Create a new project and note down your API key. Add your trigger.dev API key to your environment variables: ```env filename=".env" lineNumbers TRIGGER_SECRET_KEY=your_secret_key_here ``` For production, make sure to add the production API key to your deployment environment. ## Install dependencies Install the SDK, build package and CLI. Keep all three on the same version: ```bash filename="Terminal" lineNumbers npm install @trigger.dev/sdk@latest npm install -D @trigger.dev/build@latest trigger.dev@latest ``` Commit the resolved versions from `package.json` and `package-lock.json`. Trigger.dev warns when the CLI, SDK and build package versions drift. ## Configure trigger.dev Create a `trigger.config.ts` file in the root of your project: ```typescript filename="trigger.config.ts" lineNumbers import { defineConfig } from "@trigger.dev/sdk"; export default defineConfig({ project: "your_project_id", // Replace with your actual project ID runtime: "node", logLevel: "log", maxDuration: 300, dirs: ["./src/trigger"], }); ``` Update your `package.json` to include trigger.dev scripts: ```json filename="package.json" lineNumbers { "scripts": { "trigger:dev": "trigger dev", "trigger:deploy": "trigger deploy" } } ``` ## Create your first task Create a `src/trigger` directory and add your first task: ```typescript filename="src/trigger/process-user-data.ts" lineNumbers import { task, logger, wait } from "@trigger.dev/sdk"; import * as z from "zod"; const ProcessUserDataSchema = z.object({ userId: z.string(), operation: z.enum(["export", "analyze", "cleanup"]), }); export const processUserDataTask = task({ id: "process-user-data", run: async (payload: z.infer) => { const { userId, operation } = payload; logger.info("Starting user data processing", { userId, operation }); switch (operation) { case "export": await wait.for({ seconds: 2 }); logger.info("User data exported successfully"); return { success: true, result: "Data exported to CSV" }; case "analyze": await wait.for({ seconds: 5 }); logger.info("User data analysis completed"); return { success: true, result: { totalActions: 156, avgSessionTime: "4m 32s" }, }; case "cleanup": await wait.for({ seconds: 3 }); logger.info("User data cleanup completed"); return { success: true, result: "Removed 23 obsolete records" }; default: throw new Error(`Unknown operation: ${operation}`); } }, }); ``` Create a scheduled task: ```typescript filename="src/trigger/daily-cleanup.ts" lineNumbers import { schedules, logger, wait } from "@trigger.dev/sdk"; export const dailyCleanupTask = schedules.task({ id: "daily-cleanup", cron: "0 2 * * *", run: async () => { logger.info("Starting daily cleanup"); // Cleanup old logs await wait.for({ seconds: 5 }); logger.info("Logs cleaned up"); // Cleanup temporary files await wait.for({ seconds: 3 }); logger.info("Temp files cleaned up"); // Generate daily reports await wait.for({ seconds: 8 }); logger.info("Reports generated"); return { success: true, cleanupTime: new Date().toISOString(), itemsProcessed: 1247, }; }, }); ``` A string cron is evaluated in UTC. Use the object form documented by Trigger.dev when the job must run in a named timezone. Reserve `schedules.create()` for dynamic schedules created by an authenticated server flow, not a fixed call executed when this module loads. ## Test your task You can test your tasks locally by running: ```bash filename="Terminal" lineNumbers npm run trigger:dev ``` This will deploy your tasks to trigger.dev in the development environment, allowing you to trigger them from the dashboard or programmatically. ## Deploy your tasks To deploy your tasks to production on trigger.dev, run: ```bash filename="Terminal" lineNumbers npm run trigger:deploy ``` You can also add this command as an automated deployment step in your CI/CD pipeline. Add the `TRIGGER_ACCESS_TOKEN` secret to your repository secrets, which you can create in the trigger.dev dashboard. ```yaml filename=".github/workflows/deploy-tasks.yml" lineNumbers name: Deploy to trigger.dev (prod) on: push: branches: - main jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: lts/* - name: Install dependencies run: npm install - name: Deploy trigger tasks env: TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }} run: | npm run trigger:deploy ``` ## Triggering tasks You can trigger tasks from your application using the trigger.dev SDK. ### From an API route Create an API route to handle task triggering: ```typescript filename="app/api/tasks/process-user-data/route.ts" lineNumbers import { tasks } from "@trigger.dev/sdk"; import { NextResponse } from "next/server"; import * as z from "zod"; import { getSession } from "@/lib/auth/server"; import { processUserDataTask } from "@/src/trigger/process-user-data"; const processUserDataSchema = z.object({ operation: z.enum(["export", "analyze", "cleanup"]), }); export async function POST(request: Request) { const session = await getSession(); if (!session) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const body = await request.json(); const { operation } = processUserDataSchema.parse(body); const handle = await tasks.trigger( "process-user-data", { userId: session.user.id, operation }, ); return NextResponse.json({ success: true, taskId: handle.id, message: "Background task started successfully", }); } ``` ### From a server action ```typescript filename="app/actions/user-actions.ts" lineNumbers "use server"; import { tasks } from "@trigger.dev/sdk"; import { getSession } from "@/lib/auth/server"; import { processUserDataTask } from "@/src/trigger/process-user-data"; export async function processUserData( operation: "export" | "analyze" | "cleanup", ) { const session = await getSession(); if (!session) { throw new Error("Unauthorized"); } try { const handle = await tasks.trigger( "process-user-data", { userId: session.user.id, operation }, ); return { success: true, taskId: handle.id, }; } catch (error) { console.error("Failed to trigger background task:", error); throw new Error("Failed to start background task"); } } ``` ### From the client You can call the task endpoint from your React components: ```tsx filename="components/process-data-button.tsx" lineNumbers "use client"; import { useMutation } from "@tanstack/react-query"; export function ProcessDataButton() { const { mutate: processData, isPending } = useMutation({ mutationFn: async (operation: "export" | "analyze" | "cleanup") => { const response = await fetch("/api/tasks/process-user-data", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ operation }), }); if (!response.ok) { throw new Error("Failed to start task"); } return response.json(); }, onSuccess: (data) => { console.log("Task started:", data.taskId); }, }); return ( ); } ``` The authenticated server derives `userId`; the browser never chooses the task owner. For organization-scoped work, accept an organization ID only as a lookup key, verify membership with the shipped authorization helpers and persist the authorized tenant scope before triggering the task. ## Monitoring and debugging ### Dashboard access Visit the [trigger.dev dashboard](https://trigger.dev) to monitor your tasks: - View task execution logs and performance metrics - Track success and failure rates across all your tasks - Monitor task duration and resource usage - Replay failed tasks with a single click - Set up alerts for task failures or performance issues ### Local development During development, run your tasks locally while connected to trigger.dev: ```bash filename="Terminal" lineNumbers npm run trigger:dev ``` This allows you to: - Test tasks locally with real data - Debug with breakpoints and console logs - See immediate feedback as you develop ## Best practices ### Use descriptive task IDs ```typescript // ✅ Good - Clear and descriptive id: 'user-data-export-csv'; id: 'weekly-newsletter-campaign'; id: 'cleanup-temp-files'; // ❌ Not so good - Generic and unclear id: 'task1'; id: 'job'; id: 'process'; ``` ### Include proper error handling ```typescript run: async (payload) => { try { const result = await processData(payload); logger.info("Task completed successfully", { result }); return result; } catch (error) { logger.error("Task failed:", error.message); throw error; // Re-throw to trigger retry logic } }, ``` ### Use structured logging ```typescript logger.info('Processing started', { userId: payload.userId, operation: payload.operation, timestamp: new Date().toISOString() }); ``` ### Keep tasks focused Instead of one massive task, create focused, single-purpose tasks that can be composed together for complex workflows. ### Configure appropriate retries Set retry policies based on your task's requirements: ```typescript // For critical operations retry: { maxAttempts: 5, minTimeoutInMs: 2000, maxTimeoutInMs: 30000, factor: 2, } // For less critical operations retry: { maxAttempts: 2, minTimeoutInMs: 1000, maxTimeoutInMs: 5000, factor: 1.5, } ``` ## Next steps With trigger.dev integrated into your application, you can now: - **Handle long-running operations** that would timeout in serverless functions - **Schedule recurring tasks** like reports, cleanups, and maintenance - **Process background jobs** reliably with automatic retries - **Scale your application** without worrying about task execution infrastructure Ready to explore more advanced features? Check out the official documentation for additional capabilities like webhooks, batching, and custom integrations. --- ## Vercel Workflows **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/background-tasks/vercel-workflows **Description**: Integrate Vercel Workflows with your application for serverless background tasks. [Vercel Workflows](https://vercel.com/docs/workflows) runs durable TypeScript functions that can pause, retry and resume after a deployment or process failure. It is built on the open-source Workflow SDK. The starter kit does not install or configure Workflow. Check the current pricing, limits and release status before making it part of a critical product path. Use it when a process genuinely needs durable steps, not for an ordinary short route handler. ## Install and configure Workflow Run the current setup command from the starter kit root: ```sh filename="Terminal" lineNumbers npx workflow@latest ``` The setup installs the `workflow` package and adds the required Next.js plugin. Review the resulting `next.config.ts` carefully because the starter kit already composes Content Collections, Fumadocs, Sentry and the bundle analyzer there. Preserve those wrappers when adding `withWorkflow` from `workflow/next`, then wrap the existing final configuration once. Running the setup a second time should update the integration, not add another wrapper. Use a current `workflow` release. Older beta releases contained a webhook token vulnerability. Run `npm audit` after installation and follow the Workflow SDK security guidance before exposing hooks or webhooks. ## Create a durable workflow Keep orchestration in the workflow function and database or external-service I/O inside step functions. Pass a stored job ID rather than a browser-supplied user or organization object: ```typescript filename="workflows/process-stored-job.ts" lineNumbers import { processStoredJob } from '@/lib/tasks/process-stored-job'; export async function processStoredJobWorkflow(jobId: string): Promise { 'use workflow'; await processJobStep(jobId); } async function processJobStep(jobId: string): Promise { 'use step'; await processStoredJob(jobId); } ``` `processStoredJob` is product code you create. It should load the pending job and its organization from the database, claim it atomically and record success or failure. Make the operation idempotent because a step may be retried. Workflow functions are deterministic orchestration code. Put database queries, Node.js APIs, ordinary `fetch` calls and third-party SDK calls in a `use step` function. Use Workflow's own durable primitives when the orchestration needs to sleep or wait for an external event. ## Start the workflow after authorization Start workflows through `workflow/api`. Do not call the workflow function directly: ```typescript filename="app/api/tasks/process/route.ts" lineNumbers import { NextResponse } from 'next/server'; import { processStoredJobWorkflow } from '@/workflows/process-stored-job'; import { start } from 'workflow/api'; import { z } from 'zod/v4'; import { getSession } from '@/lib/auth/server'; const requestSchema = z.object({ jobId: z.string().uuid() }); export async function POST(request: Request): Promise { const session = await getSession(); if (!session) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const { jobId } = requestSchema.parse(await request.json()); // Verify that session.user can start this stored job before continuing. const run = await start(processStoredJobWorkflow, [jobId]); return NextResponse.json({ runId: run.runId }, { status: 202 }); } ``` The authorization comment is a required product-specific step, not optional sample cleanup. Verify ownership or organization membership before starting the workflow. Re-check access in delayed steps when permissions may have changed. ## Test the complete lifecycle The Workflow CLI can verify the generated endpoints and inspect runs: ```sh filename="Terminal" lineNumbers npx workflow health npx workflow web npx workflow inspect runs ``` Test more than the successful path: 1. An authenticated member can start a job they are allowed to operate. 2. Another user cannot start the same organization's job. 3. A transient step failure retries without repeating an external side effect. 4. A permanent failure becomes visible and does not remain pending forever. 5. Redeploying while a workflow is paused does not lose the run. 6. Starting the same stored job twice does not process it twice. On Vercel, inspect runs under **Observability → Workflows**. Keep application job state in the database as well so customers and support can see a stable product status without depending on provider-specific run details. ## Production checklist - Pin a reviewed Workflow SDK version and update it deliberately. - Keep step inputs small and avoid secrets or complete customer records. - Log stable job and organization identifiers, not sensitive payloads. - Define which errors retry and which failures are permanent. - Add alerts for failed runs and jobs that remain pending too long. - Document how support can safely replay or cancel a job. - Review [Workflow pricing and limits](https://vercel.com/docs/workflows/pricing-and-limits) before launch. Continue with the [Workflow SDK documentation](https://useworkflow.dev/docs/getting-started) for sleep, hooks, streaming and other durable primitives. --- ## Billing **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/billing **Description**: Manage subscriptions, one-time payments and credit-based billing for AI features. The Pro Next.js Prisma starter kit provides a comprehensive billing system integrated with **Stripe**. It supports traditional subscriptions and a flexible credit system for AI-driven features. ## Configuration Billing plans are configured in `config/billing.config.ts`. Credit packages are configured separately in the same file. ```typescript filename="config/billing.config.ts" lineNumbers export const billingConfig = { enabled: true, defaultCurrency: 'usd', plans: { free: { id: 'free', name: 'Free', isFree: true, features: ['Basic features'] }, pro: { id: 'pro', name: 'Pro', description: 'For professional developers', prices: [ { id: 'pro_monthly', stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY ?? '', type: 'recurring', interval: 'month', amount: 2900, // $29.00 in cents currency: 'usd' } ], features: ['Advanced AI', 'Priority Support'] } } } satisfies BillingConfig; // Credit packages are exported separately export const creditPackages = [ { id: 'basic', name: 'Basic Credits', credits: 1000, priceAmount: 1000, // $10.00 in cents stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC ?? '' } ] as const; ``` ## Subscriptions Subscriptions are managed per organization. The kit handles checkout sessions, customer portals and webhooks automatically. ### Checking Subscription Status You can check if an organization has an active subscription using tRPC queries: ```typescript filename="trpc/routers/organization/organization-router.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; import { getActiveSubscriptionByOrganizationId } from '@/lib/billing'; export const organizationRouter = createTRPCRouter({ getSettings: protectedOrganizationProcedure.query(async ({ ctx }) => { const subscription = await getActiveSubscriptionByOrganizationId( ctx.organization.id ); const isSubscribed = !!subscription && (subscription.status === 'active' || subscription.status === 'trialing'); return { isSubscribed }; }) }); ``` ## Credit System The Pro kit includes a robust credit system for usage-based billing, typically used for AI features. ### Consuming Credits Use the `consumeCredits` helper to deduct credits from an organization's balance. ```typescript filename="lib/actions/ai.ts" lineNumbers import { consumeCredits } from '@/lib/billing/credits'; await consumeCredits({ organizationId: ctx.organization.id, amount: 50, description: 'AI Image Generation', referenceType: 'ai_image', referenceId: imageId }); ``` ### Checking Balance ```typescript filename="trpc/routers/organization/organization-credit-router.ts" lineNumbers import { getCreditBalance } from '@/lib/billing/credits'; // In a procedure: const { balance } = await getCreditBalance(organizationId); ``` ## Webhooks Stripe webhooks are handled in `app/api/webhooks/stripe/route.ts`. They keep the local database in sync with Stripe events (e.g., successful payments, subscription cancellations). ### Local Webhook Testing To test webhooks locally, use the Stripe CLI: ```bash filename="Terminal" lineNumbers npm run stripe:listen ``` ## UI Components The kit includes pre-built UI components for: - **Pricing Tables**: Display plans and packages. - **Billing Settings**: Manage subscriptions and view payment history. - **Credit Dashboard**: View current balance and recent transactions. --- ## Check Purchases & Subscriptions **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/billing/check-purchases **Description**: Learn how to check for purchases and subscriptions to provide access to premium features. One of the most common use cases for billing is to provide access to premium features based on a user's or organization's subscription status. ## Plan IDs Plan IDs are defined by the keys in your `billing.config.ts` file. For example, if you have: ```typescript filename="config/billing.config.ts" lineNumbers export const billingConfig = { plans: { free: { isFree: true }, pro: { /* ... */ }, lifetime: { /* ... */ } } }; ``` The plan IDs would be `"free"`, `"pro"` and `"lifetime"`. ## Client-Side Checks Use tRPC queries to check for purchases and subscriptions on the client: ```tsx filename="components/premium-feature.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function PremiumFeature() { const { data: billingStatus } = trpc.organization.subscription.getStatus.useQuery(); if (!billingStatus?.activePlan) { return
Please subscribe to access this feature
; } // Check if user has a specific plan const hasProPlan = billingStatus.activePlan.planId === 'pro'; const hasLifetimeAccess = billingStatus.activePlan.planId === 'lifetime'; const hasActiveSubscription = billingStatus.subscription?.status === 'active' || billingStatus.subscription?.status === 'trialing'; if (!hasActiveSubscription && !hasLifetimeAccess) { return
Please subscribe to access this feature
; } return
Premium feature content
; } ``` ### Billing Status Properties The `getStatus` query returns: - **`activePlan`**: The currently active plan (if any), containing `planId`, `planName`, `status`, etc. - **`subscription`**: The active subscription object (if any), containing `status`, `currentPeriodEnd`, etc. - **`enabled`**: Whether billing is enabled ## Server-Side Checks ### In tRPC Procedures ```typescript filename="trpc/routers/premium-feature.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; import { getActivePlanForOrganization, hasActivePaidPlan, hasSpecificPlan } from '@/lib/billing'; export const premiumFeatureRouter = createTRPCRouter({ access: protectedOrganizationProcedure.query(async ({ ctx }) => { const activePlan = await getActivePlanForOrganization(ctx.organization.id); if ( !activePlan || (activePlan.planId === 'free' && !activePlan.isLifetime) ) { throw new TRPCError({ code: 'FORBIDDEN', message: 'This feature requires an active subscription' }); } return { data: 'premium content' }; }) }); ``` ### In Server Components ```typescript filename="app/(saas)/dashboard/premium/page.tsx" lineNumbers import { getSession } from "@/lib/auth"; import { getActivePlanForOrganization } from "@/lib/billing"; import { redirect } from "next/navigation"; export default async function PremiumPage() { const session = await getSession(); if (!session?.session.activeOrganizationId) { redirect("/auth/sign-in"); } const activePlan = await getActivePlanForOrganization(session.session.activeOrganizationId); if (!activePlan || (activePlan.planId === 'free' && !activePlan.isLifetime)) { redirect("/dashboard/choose-plan"); } return
Premium page content
; } ``` ## Organization-Based Checks Billing is organization-based by default. All checks use the organization ID from the context: ### Client-Side ```tsx filename="components/organization-feature.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function OrganizationFeature() { const { data: billingStatus } = trpc.organization.subscription.getStatus.useQuery(); if ( !billingStatus?.subscription || (billingStatus.subscription.status !== 'active' && billingStatus.subscription.status !== 'trialing') ) { return
This organization needs an active subscription
; } return
Organization premium feature
; } ``` ### Server-Side ```typescript filename="trpc/routers/organization-feature.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; import { hasActivePaidPlan } from '@/lib/billing'; export const organizationFeatureRouter = createTRPCRouter({ access: protectedOrganizationProcedure.query(async ({ ctx }) => { const hasActivePlan = await hasActivePaidPlan(ctx.organization.id); if (!hasActivePlan) { throw new TRPCError({ code: 'FORBIDDEN', message: 'This organization requires an active subscription' }); } return { data: 'organization premium content' }; }) }); ``` ## Plan Limits You can also check plan limits: ```typescript filename="lib/billing/check-limits.ts" lineNumbers import { getOrganizationPlanLimits } from '@/lib/billing/guards'; export async function checkMemberLimit(organizationId: string) { const limits = await getOrganizationPlanLimits(organizationId); // -1 means unlimited if (limits.maxMembers === -1) { return true; // Unlimited members } // Check current member count against limit const currentMembers = await getMemberCount(organizationId); return currentMembers < limits.maxMembers; } ``` ## Helper Functions Use the built-in guard functions for common checks: ```typescript filename="lib/billing/feature-guards.ts" lineNumbers import { TRPCError } from '@trpc/server'; import { hasActivePaidPlan, hasSpecificPlan, requirePaidPlan, requireSpecificPlan } from '@/lib/billing'; // Require any paid plan export async function requireActivePlan(organizationId: string) { await requirePaidPlan(organizationId); } // Require a specific plan export async function requirePlan(organizationId: string, planIds: string[]) { await requireSpecificPlan(organizationId, planIds); } // Check if has active plan (doesn't throw) export async function checkHasActivePlan( organizationId: string ): Promise { return hasActivePaidPlan(organizationId); } // Check if has specific plan (doesn't throw) export async function checkHasPlan( organizationId: string, planId: string ): Promise { return hasSpecificPlan(organizationId, planId); } ``` --- ## Configuration **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/billing/configuration **Description**: Learn about the billing configuration. The billing configuration ensures consistent behavior across the application and any billing provider. ## Basic Setup Configure your Stripe keys in `.env`: ```ini filename=".env" lineNumbers STRIPE_SECRET_KEY=sk_test_... NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... STRIPE_WEBHOOK_SECRET=whsec_... NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY=price_... NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=price_... NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PRO=price_... ``` ## Products and Plans Define your products and plans in your billing configuration file. A simple monthly pro plan would look like this: ```typescript filename="config/billing.config.ts" lineNumbers import { env } from '@/lib/env'; export const billingConfig = { enabled: true, defaultCurrency: 'usd', plans: { free: { id: 'free', name: 'Free', description: 'Get started with basic features', isFree: true, features: ['Basic analytics'], limits: { maxMembers: 3, maxStorage: 1 } }, pro: { id: 'pro', name: 'Pro', description: 'Best for most teams.', features: ['Feature 1', 'Feature 2'], limits: { maxMembers: 10, maxStorage: 100 }, prices: [ { id: 'pro_monthly', stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY ?? '', type: 'recurring', interval: 'month', intervalCount: 1, amount: 2900, // $29.00 in cents currency: 'usd' } ] } } } satisfies BillingConfig; ``` ## Environment Variables Make sure to set the following environment variables: - `STRIPE_SECRET_KEY` - Your Stripe secret key - `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` - Your Stripe publishable key - `STRIPE_WEBHOOK_SECRET` - Your Stripe webhook secret (for production) - `NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY` - Monthly Pro price ID - `NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY` - Yearly Pro price ID - `NEXT_PUBLIC_STRIPE_PRICE_LIFETIME` - One-time lifetime price ID - `NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER` - Starter credit package price ID - `NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC` - Basic credit package price ID - `NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PRO` - Pro credit package price ID ## Email Configuration (Resend) The starter kit uses [Resend](https://resend.com/) for sending emails. Configure Resend in your `.env`: ```ini filename=".env" lineNumbers RESEND_API_KEY=re_... EMAIL_FROM=noreply@yourdomain.com ``` ### Setting up Resend 1. Create an account at [Resend](https://resend.com/) 2. Get your API key from the dashboard 3. Add your domain and verify it with DNS records 4. Set `EMAIL_FROM` to use your verified domain For more details, see the [Email documentation](/docs/starter-kits/pro-nextjs-prisma/email/overview). --- ## Credits **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/billing/credits **Description**: Learn how to implement and manage a credit-based billing system. The Pro Next.js Prisma starter kit includes a robust credit system for usage-based billing, typically used for AI features and pay-as-you-go services. ## Overview Credits allow you to charge users based on their actual usage rather than fixed subscription tiers. This is ideal for: - AI-powered features (image generation, text analysis, etc.) - API calls and compute resources - Pay-as-you-go services ## Configuration Credit packages are configured separately in `config/billing.config.ts`: ```typescript filename="config/billing.config.ts" lineNumbers import { env } from '@/lib/env'; // Credit packages are exported separately from billingConfig export const creditPackages = [ { id: 'credits_starter', name: 'Starter', credits: 10_000, priceAmount: 999, // $9.99 in cents stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER ?? '' }, { id: 'credits_basic', name: 'Basic', credits: 50_000, bonusCredits: 5_000, // 10% bonus priceAmount: 3999, // $39.99 in cents stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC ?? '' } ] as const; ``` ## Consuming Credits Use the `consumeCredits` helper to deduct credits from an organization's balance: ```typescript filename="lib/billing/credits.ts" lineNumbers import { consumeCredits } from '@/lib/billing/credits'; await consumeCredits({ organizationId: ctx.organization.id, amount: 50, description: 'AI Image Generation', referenceType: 'ai_image', referenceId: imageId }); ``` The `consumeCredits` function will: - Check if the organization has sufficient credits - Deduct the specified amount - Create a transaction record - Throw an error if insufficient credits ## Checking Credit Balance Get the current credit balance for an organization: ```typescript filename="trpc/routers/organization/organization-credit-router.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; import { getCreditBalance } from '@/lib/billing/credits'; export const organizationCreditRouter = createTRPCRouter({ getBalance: protectedOrganizationProcedure.query(async ({ ctx }) => { const { balance } = await getCreditBalance(ctx.organization.id); return { balance }; }) }); ``` ## Adding Credits Credits are automatically added when a user purchases a credit package. This is handled automatically via Stripe webhooks in `lib/billing/sync.ts`. The webhook handler processes `checkout.session.completed` events and adds credits based on the purchased package. ## Credit Transactions All credit operations are tracked in the database, allowing you to: - View transaction history - Audit credit usage - Generate reports ```typescript filename="trpc/routers/organization/organization-credit-router.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; import { listCreditTransactions } from '@/lib/billing/credits'; export const organizationCreditRouter = createTRPCRouter({ getTransactions: protectedOrganizationProcedure.query(async ({ ctx }) => { return await listCreditTransactions(ctx.organization.id, { limit: 50, offset: 0 }); }) }); ``` ## UI Components The kit includes pre-built UI components for: - **Credit Balance Display**: Show current balance - **Credit Purchase**: Purchase credit packages - **Transaction History**: View credit transactions - **Low Balance Warnings**: Alert users when credits are running low --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/billing/overview **Description**: Learn about the billing package. The starter kit comes with a billing package that enables you to charge your users, display relevant information and let them manage their plan or billing info. ## Monetization The billing package supports multiple monetization options: - **Subscriptions:** Automatically generates recurring invoices at fixed intervals, typically monthly or annually. Ideal for products that offer ongoing access. - **One-time Payments:** Charges the customer a single upfront payment for perpetual access. Useful for lifetime deals, downloadable products or purchasable addons. - **Credits:** A flexible credit system for usage-based billing, typically used for AI features and pay-as-you-go services. ## Configuration The billing configuration ensures consistent behavior across the application and any billing provider. --- ## Paywall **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/billing/paywall **Description**: Learn how to set up a paywall to restrict access to paid plans only. By default, the starter kit includes a free plan, allowing users to access your application after signing up without payment. If you want to require a paid plan or trial, you can set up a paywall. ## Setting Up a Paywall To enable a paywall, remove or disable the free plan in your billing configuration: ```typescript filename="config/billing.config.ts" lineNumbers export const billingConfig = { plans: { // Remove the free plan // free: { // isFree: true, // }, pro: { // ... pro plan configuration } } }; ``` When the free plan is removed, users will be redirected to the plan selection page (`/dashboard/choose-plan`) after signup and onboarding. ## Checking for Active Plans To restrict access to features based on plan status, check if the user has an active plan: ### Server-Side (tRPC) ```typescript filename="trpc/routers/premium-feature.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; import { requirePaidPlan } from '@/lib/billing'; export const premiumFeatureRouter = createTRPCRouter({ access: protectedOrganizationProcedure.query(async ({ ctx }) => { // This will throw if organization doesn't have a paid plan await requirePaidPlan(ctx.organization.id); // Allow access to premium feature return { data: 'premium content' }; }) }); ``` ### Client-Side ```tsx filename="components/premium-feature.tsx" lineNumbers 'use client'; import Link from 'next/link'; import { trpc } from '@/trpc/client'; export function PremiumFeature() { const { data: billingStatus } = trpc.organization.subscription.getStatus.useQuery(); if ( !billingStatus?.activePlan || billingStatus.activePlan.planId === 'free' || (billingStatus.subscription?.status !== 'active' && billingStatus.subscription?.status !== 'trialing') ) { return (

This feature requires an active subscription.

Upgrade now
); } return
Premium feature content
; } ``` ## Protecting Routes You can protect entire routes based on plan status: ```typescript filename="app/(saas)/dashboard/premium/page.tsx" lineNumbers import { getSession } from "@/lib/auth"; import { getActivePlanForOrganization } from "@/lib/billing"; import { redirect } from "next/navigation"; export default async function PremiumPage() { const session = await getSession(); if (!session?.session.activeOrganizationId) { redirect("/auth/sign-in"); } const activePlan = await getActivePlanForOrganization(session.session.activeOrganizationId); if (!activePlan || activePlan.planId === 'free') { redirect("/dashboard/choose-plan"); } return
Premium page content
; } ``` ## Plan-Specific Access You can also check for specific plans: ```typescript filename="lib/billing/check-access.ts" lineNumbers import { hasSpecificPlan } from '@/lib/billing'; export async function checkPlanAccess( organizationId: string, requiredPlanId: string ): Promise { return hasSpecificPlan(organizationId, requiredPlanId); } ``` ```tsx filename="components/pro-feature.tsx" lineNumbers 'use client'; import Link from 'next/link'; import { trpc } from '@/trpc/client'; export function ProFeature() { const { data: billingStatus } = trpc.organization.subscription.getStatus.useQuery(); if (billingStatus?.activePlan?.planId !== 'pro') { return (

This feature is only available on the Pro plan.

Upgrade to Pro
); } return
Pro feature content
; } ``` ## Organization-Based Paywalls Billing is organization-based by default. Use the organization context: ```typescript filename="trpc/routers/organization-feature.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; import { requirePaidPlan } from '@/lib/billing'; export const organizationFeatureRouter = createTRPCRouter({ access: protectedOrganizationProcedure.query(async ({ ctx }) => { // This will throw if organization doesn't have a paid plan await requirePaidPlan(ctx.organization.id); return { data: 'organization premium content' }; }) }); ``` --- ## Plans & Products **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/billing/plans **Description**: Learn how to manage plans and products in your application. Plans and products are defined in `config/billing.config.ts`. This configuration determines what plans are available, their pricing, and how they appear in your pricing table. Storage limits are metadata The shipped kit does not meter stored objects or enforce{' '} maxStorage. Storage amounts in feature lists and plan limits are display and configuration metadata until you add usage tracking and server-side guards. ## Plan Types The starter kit supports several plan types: ### Free Plan The free plan is the default plan for users who haven't purchased any paid plans. It provides limited access to your product. ```typescript filename="config/billing.config.ts" lineNumbers export const billingConfig = { plans: { free: { id: 'free', name: 'Free', description: 'Get started with basic features', isFree: true, features: [ 'Up to 3 team members', 'Basic analytics', 'Community support' ], limits: { maxMembers: 3, maxStorage: 1 // GB } // No prices needed for free plans } } }; ``` ### Subscription Plans Subscription plans charge users on a recurring basis (monthly, yearly, etc.): ```typescript filename="config/billing.config.ts" lineNumbers import { env } from '@/lib/env'; export const billingConfig = { plans: { pro: { id: 'pro', name: 'Pro', description: 'For growing teams', recommended: true, // Highlights this plan in the pricing table features: [ 'Unlimited team members', 'Advanced analytics', 'Priority support', '100 GB storage' ], limits: { maxMembers: -1, // unlimited maxStorage: 100 // GB }, prices: [ { id: 'pro_monthly', stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY ?? '', type: 'recurring', interval: 'month', intervalCount: 1, amount: 2900, // $29.00 in cents currency: 'usd', seatBased: true, // Per-seat pricing trialDays: 14 // Optional: 14-day free trial }, { id: 'pro_yearly', stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY ?? '', type: 'recurring', interval: 'year', intervalCount: 1, amount: 27800, // $278.00 in cents currency: 'usd', seatBased: true, trialDays: 14 } ] } } }; ``` ### One-Time Purchase Plans One-time purchase plans charge users a single upfront payment: ```typescript filename="config/billing.config.ts" lineNumbers import { env } from '@/lib/env'; export const billingConfig = { plans: { lifetime: { id: 'lifetime', name: 'Lifetime', description: 'Pay once, use forever', features: [ 'All Pro features', 'Lifetime updates', 'Priority support for 1 year' ], limits: { maxMembers: -1, maxStorage: 100 }, prices: [ { id: 'lifetime_once', stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_LIFETIME ?? '', type: 'one_time', amount: 49900, // $499.00 in cents currency: 'usd' } ] } } }; ``` ### Enterprise Plan Enterprise plans show up in the pricing table with a link to a contact form: ```typescript filename="config/billing.config.ts" lineNumbers export const billingConfig = { plans: { enterprise: { id: 'enterprise', name: 'Enterprise', description: 'For large organizations with custom needs', isEnterprise: true, features: [ 'Everything in Pro', 'Dedicated account manager', 'Custom SLA', 'Unlimited storage', 'SSO / SAML' ], limits: { maxMembers: -1, maxStorage: -1 } // No prices needed - users contact you directly } } }; ``` ## Price Properties Each price object supports the following properties: - **`id`**: Unique identifier for the price (e.g., `"pro_monthly"`) - **`type`**: `"recurring"` or `"one_time"` - **`stripePriceId`**: The Stripe Price ID (from environment variables, starts with `price_`) - **`interval`**: For recurring plans: `"month"`, `"year"`, `"week"` or `"day"` - **`intervalCount`**: Number of intervals to bill (defaults to 1) - **`amount`**: The price amount (in cents for Stripe) - **`currency`**: Currency code (e.g., `"usd"`, `"eur"`) - **`trialDays`**: Optional trial period in days - **`seatBased`**: If `true`, price is per seat (multiplies by number of organization members) ## Plan Configuration Options - **`recommended`**: Highlights the plan in the pricing table - **`hidden`**: Hides the plan from the pricing table (useful for grandfathering old plans) ## Creating Plans in Stripe 1. Go to your [Stripe Dashboard](https://dashboard.stripe.com/login) 2. Navigate to **Products** > **Add product** 3. Create your product with pricing 4. Copy the **Price ID** (starts with `price_`) 5. Add it to your environment variables: ```env filename=".env" lineNumbers NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY=price_xxxxx NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=price_xxxxx NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=price_xxxxx ``` ## Plan Limits You can define plan limits in `config/billing.config.ts`: ```typescript filename="config/billing.config.ts" lineNumbers export const billingConfig = { plans: { pro: { // ... prices limits: { maxMembers: 10, // Maximum organization members maxStorage: 50 // GB of plan metadata } } } }; ``` The shipped invitation guards enforce `maxMembers`. `maxStorage` is plan metadata until you add stored-byte usage tracking and a server-side guard. To add another limit such as `maxProjects`, extend the billing schema and enforce it on every relevant server mutation. --- ## Subscriptions **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/billing/subscriptions **Description**: Learn how to set up and manage subscriptions. ## Creating a Subscription To create a subscription, use the Stripe API: ```typescript filename="create-subscription.ts" lineNumbers import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); const subscription = await stripe.subscriptions.create({ customer: customerId, items: [{ price: priceId }] }); ``` ## Checking Subscription Status Check if an organization has an active subscription: ```typescript filename="check-subscription.ts" lineNumbers import { getActivePlanForOrganization, getActiveSubscriptionByOrganizationId } from '@/lib/billing'; // Get the active subscription const subscription = await getActiveSubscriptionByOrganizationId(organizationId); if (subscription?.status === 'active' || subscription?.status === 'trialing') { // Organization has active subscription } // Or get the active plan (includes subscription and lifetime orders) const activePlan = await getActivePlanForOrganization(organizationId); if (activePlan && activePlan.planId !== 'free') { // Organization has an active paid plan } ``` ## Canceling a Subscription Allow users to cancel their subscriptions: ```typescript filename="cancel-subscription.ts" lineNumbers import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); await stripe.subscriptions.cancel(subscriptionId); ``` --- ## Webhooks **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/billing/webhooks **Description**: Learn how to handle Stripe webhooks. Webhooks are used to receive events from Stripe. They are important to get the latest data so your application is in sync with Stripe. ## Setting up Webhooks 1. Go to your [Stripe Dashboard](https://dashboard.stripe.com/login?redirect=%2Fwebhooks) 2. Click "Add endpoint" 3. Enter your webhook URL: `https://yourdomain.com/api/webhooks/stripe` 4. Select the required events (see list below) 5. Copy the webhook signing secret ## Required Webhook Events The following Stripe webhook events are handled by the webhook handler: ### Subscription Events - `customer.subscription.created` - When a new subscription is created - `customer.subscription.updated` - When a subscription is modified (plan changes, status updates) - `customer.subscription.deleted` - When a subscription is canceled or expires - `customer.subscription.trial_will_end` - When a trial is ending soon (3 days before) - `customer.subscription.paused` - When a subscription is paused - `customer.subscription.resumed` - When a paused subscription is resumed ### Checkout Events - `checkout.session.completed` - When a checkout session completes (subscriptions, one-time payments, credit purchases) ### Invoice Events - `invoice.paid` - When an invoice payment succeeds - `invoice.payment_failed` - When an invoice payment fails ### Charge Events - `charge.refunded` - When a charge is refunded (handles both full and partial refunds) ### Refund Events - `refund.created` - When a refund is initiated - `refund.updated` - When a refund's status updates - `refund.failed` - When a refund fails ### Dispute Events - `charge.dispute.created` - When a customer disputes a charge - `charge.dispute.updated` - When a dispute status updates - `charge.dispute.closed` - When a dispute is resolved - `charge.dispute.funds_withdrawn` - Funds withdrawn from balance - `charge.dispute.funds_reinstated` - Funds reinstated to balance ### Customer Events - `customer.deleted` - When a customer is deleted from Stripe ### Payment Intent Events - `payment_intent.succeeded` - When a payment intent succeeds (for audit logging) ## Webhook Handler The starter kit includes a comprehensive webhook handler at `app/api/webhooks/stripe/route.ts` that handles all billing events. The handler includes: - **Signature verification** - Validates webhook authenticity using Stripe's signature - **Idempotency** - Prevents duplicate processing of the same event - **Error handling** - Distinguishes between transient and permanent errors - **Event logging** - Records all events in the database for audit trails ### Supported Events The handler processes the following events: - `checkout.session.completed` - Handles subscriptions, one-time payments and credit purchases - `customer.subscription.created` - Creates subscription records - `customer.subscription.updated` - Updates subscription status and plan changes - `customer.subscription.deleted` - Marks subscriptions as canceled - `customer.subscription.trial_will_end` - Sends trial ending notifications - `customer.subscription.paused` - Handles subscription pauses - `customer.subscription.resumed` - Handles subscription resumption - `invoice.paid` - Logs successful invoice payments - `invoice.payment_failed` - Sends payment failure notifications - `charge.refunded` - Handles refunds (full and partial) - `refund.created` - Tracks refund lifecycle - `refund.updated` - Updates refund status - `refund.failed` - Logs refund failure - `charge.dispute.created` - Alerts admins of new chargebacks - `charge.dispute.updated` - Updates dispute status - `charge.dispute.closed` - Logs dispute resolution - `customer.deleted` - Clears Stripe customer ID from organizations - `payment_intent.succeeded` - Logs payment intents for audit ### Extending the Handler To add custom logic for a specific event, you can modify the handler functions in `app/api/webhooks/stripe/route.ts`. For example, to add custom logic when a subscription is created: ```typescript filename="app/api/webhooks/stripe/route.ts" lineNumbers async function handleSubscriptionCreated( eventId: string, subscription: Stripe.Subscription ): Promise { // ... existing code ... // Add your custom logic here await sendWelcomeEmail(organizationId); await createInitialResources(organizationId); } ``` ## Testing Webhooks Install and authenticate the Stripe CLI, start the application and run the included listener from a second terminal: ```sh filename="Terminal" lineNumbers npm run stripe:listen ``` The CLI prints a temporary `whsec_...` signing secret. Put that value in the local `.env` as `STRIPE_WEBHOOK_SECRET`, then restart the development server so the handler reads it. The Stripe CLI listener secret and the production endpoint secret are different. Use the value printed by `stripe listen` locally. Store the endpoint's Dashboard secret in the hosting provider for production. You can ask the Stripe CLI to send a fixture event through the listener: ```sh filename="Terminal" lineNumbers npm run stripe:trigger -- payment_intent.succeeded ``` A generated fixture proves that forwarding and signature verification work. It may not contain the organization, price and checkout metadata created by the application. Test state synchronization by completing a checkout through the local UI with Stripe test-mode credentials, then confirm the related order, subscription or credits in the application. ## Verify Production Delivery After deploying: 1. Confirm the endpoint URL is the final HTTPS origin plus `/api/webhooks/stripe`. 2. Confirm the endpoint is subscribed to every event used by your enabled billing modes. 3. Complete a test-mode checkout and inspect its delivery in Stripe's webhook event log. 4. Check that the application recorded the event and updated the intended organization. 5. Resend the same event from Stripe and confirm it is treated as already processed rather than applying credits or access twice. The handler returns a failure status for transient processing errors so Stripe can retry. Permanent data errors are recorded and acknowledged to avoid an endless retry loop. Monitor failed billing-event records and Stripe delivery attempts together when diagnosing synchronization problems. --- ## Blog **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/cms/blog **Description**: Learn how to write blog posts using Content Collections. The starter kit uses [Content Collections](https://www.content-collections.dev/) for managing blog content. All blog posts are written using `.mdx` files, which combine markdown with React components. Why choose Content Collections? Content Collections is a great alternative to headless CMS platforms such as Contentful or Prismic. It's powered by MDX, free, open-source, and saves content directly in your repository. The blog is configured in `content-collections.ts` and uses [Fumadocs](https://www.fumadocs.dev/) for rendering documentation-style content. ## Add a new blog post To create a new blog post, follow these steps: 1. **Create a new file** Navigate to the `content/blog` directory and create a new `.mdx` file. The file name will act as the URL slug for the post. For example: - File name: `hello-world.mdx` - URL: `https://your-app.com/blog/hello-world` 2. **Add metadata** At the top of the `.mdx` file, include a frontmatter block. This block contains key metadata about your post, written in a YAML-like format enclosed by three dashes (`---`). Here's an example: ```mdx filename="content/posts/hello-world.mdx" lineNumbers --- title: How to create a blog post date: 2025-01-20T12:00:00.000Z authorName: John Doe authorImage: /authors/john.jpg authorLink: https://example.com excerpt: A short description of your blog post. tags: [Innovation, Tutorial] published: true content: | Your blog post content goes here... --- ``` ### Frontmatter Fields The blog post schema supports the following fields: - `title` (required) - The title of the blog post - `date` (required) - ISO 8601 date string for publication date - `authorName` (required) - Name of the author - `authorImage` (optional) - URL to author's image - `authorLink` (optional) - Link to author's profile - `excerpt` (optional) - A short description/excerpt of the post - `tags` (required) - Array of tag strings - `published` (required) - Boolean to control visibility - `image` (optional) - Featured image URL - `content` (required) - The full content of the post ## Using MDX Components You can use React components directly in your MDX files. The starter kit provides several custom components: ```mdx filename="content/blog/example.mdx" lineNumbers --- title: Example Post description: An example blog post --- import { Callout } from '@/components/mdx-components'; # My Blog Post This is a callout component! Regular markdown content here. ``` ## Code Blocks Code blocks are automatically highlighted and support line numbers: ```typescript filename="example.ts" lineNumbers export function example() { return 'Hello, World!'; } ``` ## Images You can include images in your blog posts: ```mdx filename="content/blog/example.mdx" lineNumbers ![Alt text](/path/to/image.png) ``` Or use the Image component for more control: ```mdx filename="content/blog/example.mdx" lineNumbers import { Image } from '@/components/mdx-components'; Alt text ``` ## Building Content Content Collections are automatically built during the Next.js build process. During development, they are rebuilt when files change. ## Querying Blog Posts You can query blog posts in your application: ```typescript filename="app/blog/page.tsx" lineNumbers import { allDocuments } from 'content-collections/generated'; export default function BlogPage() { const posts = allDocuments('posts') .filter((post) => post.published) .sort((a, b) => { const dateA = new Date(a.date); const dateB = new Date(b.date); return dateB.getTime() - dateA.getTime(); }); return (
{posts.map((post) => (

{post.title}

{post.excerpt}

))}
); } ``` ## Configuration The blog collection is configured in `content-collections.ts`: ```typescript filename="content-collections.ts" lineNumbers const posts = defineCollection({ name: 'posts', directory: 'content/posts', include: '**/*.{mdx,md}', schema: z.object({ title: z.string(), date: z.string(), authorName: z.string(), excerpt: z.string().optional(), tags: z.array(z.string()), published: z.boolean(), content: z.string() }) }); export default defineConfig({ collections: [posts] }); ``` ## Best Practices 1. **Use descriptive filenames** - The filename becomes the URL slug 2. **Add descriptions** - Help with SEO and preview cards 3. **Use categories** - Organize related posts 4. **Set publication dates** - Control when posts appear 5. **Test locally** - Always preview posts before publishing --- ## Documentation **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/cms/documentation **Description**: Write and organize product documentation with Fumadocs MDX. The starter kit includes a Fumadocs documentation site at `/docs`. Its pages are local MDX files in `content/docs`, so the documentation stays versioned with the application code. Both starter kit repositories use the same documentation structure. Do not create an ORM-specific folder inside `content/docs`. ## How documentation is connected | File | Responsibility | | ------------------------------- | ------------------------------------------------------- | | `content/docs/*.mdx` | Documentation content and page metadata | | `content/docs/meta.json` | Sidebar groups, labels and page order | | `source.config.ts` | Declares `content/docs` as the Fumadocs MDX source | | `lib/marketing/docs/source.ts` | Loads the content and assigns the `/docs` base URL | | `app/docs/layout.tsx` | Configures the documentation layout and navigation tree | | `app/docs/[[...slug]]/page.tsx` | Renders each page and generates its metadata | The kit already connects these files. You normally only need to edit `content/docs` when writing product documentation. ## Add a page ### Create the MDX file Add a file directly under `content/docs`. Its path becomes the URL after `/docs`. ```mdx filename="content/docs/getting-started.mdx" --- title: Getting started description: Configure the application for local development. icon: Rocket --- ## Prerequisites Add your guide here. ``` This example is available at `/docs/getting-started`. The optional `icon` value must match an icon exported by Lucide React. ### Add the page to the sidebar Add the filename without `.mdx` to the `pages` array in `content/docs/meta.json`: ```json filename="content/docs/meta.json" { "title": "Documentation", "root": true, "pages": ["index", "getting-started"] } ``` Keep this array in the order you want readers to follow. Fumadocs also supports separators and external links in this file. ### Preview the page Start the application and open the new route: ```bash npm run dev ``` Visit `http://localhost:3000/docs/getting-started` and check the page on both desktop and mobile. ## Organize a section For a larger topic, put its pages in a folder and add a `meta.json` inside that folder. The folder name becomes the URL segment. ```text content/docs/ ├── meta.json └── billing/ ├── meta.json ├── overview.mdx └── webhooks.mdx ``` ```json filename="content/docs/billing/meta.json" { "title": "Billing", "pages": ["overview", "webhooks"] } ``` The pages are then available at `/docs/billing/overview` and `/docs/billing/webhooks`. Add `billing` to the root `content/docs/meta.json` where that section should appear. ## Use the included MDX components The page renderer registers Fumadocs components including `Callout`, `Cards`, `Tabs`, `Steps`, `Files` and `ImageZoom`. You can use them directly in an MDX page without importing them. ```mdx filename="content/docs/getting-started.mdx" Copy `.env.example` to `.env` and provide the required values. Use your local service credentials. Use credentials from the production project. ``` Standard fenced code blocks support syntax highlighting. Add a `filename` attribute when the file location helps the reader. ## Change the documentation UI - Edit `app/docs/layout.tsx` to change the documentation shell or sidebar behavior. - Edit `lib/marketing/docs/layout.config.tsx` to change shared layout options such as navigation links. - Edit `app/docs/[[...slug]]/page.tsx` to register another MDX component or change page rendering. - Edit `lib/marketing/docs/source.ts` only when changing how the content source is loaded. Keep content changes in `content/docs` and layout changes in the application files above. This separation makes upgrades easier and keeps navigation generated from the same source as the pages. ## Validate before publishing Run the same checks used for application changes: ```bash npm run typecheck npm run lint npm run build ``` Also open every new documentation route locally. A successful build confirms that Fumadocs can compile the MDX, while the browser check catches navigation, layout and readability problems. For advanced navigation and MDX options, see the [Fumadocs documentation](https://fumadocs.dev/docs/mdx). --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/cms/overview **Description**: Learn how to manage content using Content Collections and Fumadocs. The starter kit uses [Content Collections](https://www.content-collections.dev/) for managing content and [Fumadocs](https://www.fumadocs.dev/) for rendering documentation. This provides a powerful, type-safe content management system that's easy to use and maintain. Why Content Collections? Content Collections is a great alternative to headless CMS platforms such as Contentful or Prismic. It's powered by MDX, free, open-source, and saves content directly in your repository. This means your content is version-controlled and easy to manage. ## Features - **Type-safe content** - Full TypeScript support with automatic type generation - **MDX support** - Write content using Markdown with React components - **Version control** - Content is stored in your repository, making it easy to track changes - **Fast builds** - Content is compiled at build time for optimal performance - **Developer-friendly** - Edit content using your favorite code editor - **No database required** - Content is stored as files, not in a database ## Content Collections Content Collections provides: - **Schema validation** - Define schemas for your content using Zod - **Automatic type generation** - TypeScript types are generated from your schemas - **Query API** - Easy-to-use API for querying content - **Transform functions** - Process and transform content during build ## Fumadocs Fumadocs provides: - **Beautiful UI** - Pre-built documentation UI components - **Search** - Full-text search across your documentation - **Dark mode** - Automatic theme switching - **Responsive design** - Mobile-friendly layouts - **Table of contents** - Automatically generated from headings ## Configuration Content Collections is configured in `content-collections.ts`. The starter kit includes multiple collections: ```typescript filename="content-collections.ts" lineNumbers import { defineCollection, defineConfig } from '@content-collections/core'; import { compileMDX } from '@content-collections/mdx'; import { z } from 'zod'; // Blog posts collection const posts = defineCollection({ name: 'posts', directory: 'content/posts', include: '**/*.{mdx,md}', schema: z.object({ title: z.string(), date: z.string(), authorName: z.string(), excerpt: z.string().optional(), tags: z.array(z.string()), published: z.boolean(), content: z.string() }), transform: async (document, context) => { const body = await compileMDX(context, document); return { ...document, body, path: document._meta.path.replace(/\.mdx?$/, '') }; } }); export default defineConfig({ collections: [posts] }); ``` ## Building Content Content Collections are automatically built during the Next.js build process. During development, they are rebuilt when files change. ## Querying Content You can query content in your application: ```typescript filename="app/blog/page.tsx" lineNumbers import { allDocuments } from 'content-collections/generated'; export default function BlogPage() { const posts = allDocuments('posts') .filter((post) => post.published) .sort((a, b) => { const dateA = new Date(a.date); const dateB = new Date(b.date); return dateB.getTime() - dateA.getTime(); }); return (
{posts.map((post) => (

{post.title}

{post.excerpt}

))}
); } ``` ## Best Practices 1. **Organize content** - Use clear directory structures 2. **Use schemas** - Define schemas for type safety 3. **Version control** - Commit content changes to git 4. **Test locally** - Always preview content before publishing 5. **Use MDX components** - Leverage React components in your content --- ## Common Commands **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/codebase/commands **Description**: A practical reference for daily development, tests and local services. Use these commands from the repository root. They are defined in `package.json`, so prefer them over running the underlying tools directly. ## Everyday Development | Command | Purpose | | ------------------- | ------------------------------------------------- | | `npm install` | Install project dependencies | | `npm run dev` | Start the Next.js development server on port 3000 | | `npm run build` | Create a production build | | `npm run start` | Serve an existing production build | | `npm run typecheck` | Check TypeScript without emitting files | ## Environment-aware commands Next.js loads the root `.env` file for `dev`, `build` and `start`. The test scripts use the repository's `with-dev-env` wrapper because Vitest and Playwright also need those values outside the Next.js process: ```sh filename="Terminal" lineNumbers npm run with-dev-env -- ``` You normally do not need to call this wrapper yourself. Use it when running a one-off tool that imports application modules depending on `lib/env.ts`. ## Code Quality | Command | Purpose | | ---------------------- | ------------------------------------ | | `npm run format` | Check formatting with Oxfmt | | `npm run format:write` | Apply safe formatting changes | | `npm run lint` | Check lint rules with Oxlint | | `npm run lint:write` | Apply safe lint fixes | | `npm run check` | Run Oxlint and Oxfmt checks together | Run the non-writing commands in CI. Review the diff after any command ending in `:write` before committing its changes. ## Tests | Command | Purpose | | ------------------------- | ------------------------------------------------------- | | `npm run test -- --run` | Run the unit test suite once and exit | | `npm run test:watch` | Run unit tests in watch mode | | `npm run test:coverage` | Run unit tests and collect coverage | | `npm run test:db` | Include tests that require the configured test database | | `npm run test:e2e:setup` | Install the Playwright browser | | `npm run test:e2e` | Run Playwright end-to-end tests headlessly | | `npm run test:e2e:headed` | Run Playwright while displaying the browser | | `npm run test:e2e:ui` | Open Playwright's interactive test runner | | `npm run test:e2e:debug` | Run Playwright with its inspector | | `npm run e2e:ci` | Install Playwright browsers and run E2E tests for CI | `test:db` and the Playwright commands load variables from the root `.env` file. Use isolated test credentials and never point them at production services. Running `npm run test` without `-- --run` can enter Vitest's interactive watch workflow in a local terminal. Use the explicit one-shot command in scripts and before commits so the process exits with a reliable status. ## Local Services | Command | Purpose | | ----------------------------------- | ---------------------------------------------- | | `npm run docker:up` | Start the included PostgreSQL container | | `npm run docker:down` | Stop the included containers | | `npm run docker:logs` | Follow container logs | | `npm run db:studio` | Open the Prisma database browser | | `npm run stripe:listen` | Forward Stripe CLI events to the local webhook | | `npm run stripe:trigger -- ` | Ask Stripe CLI to emit a test event | | `npm run email:dev` | Preview React Email templates on port 3001 | Database migration commands differ between the starter kit variants. Follow the [Prisma database guide](/docs/starter-kits/pro-nextjs-prisma/database) before changing or applying a schema. ## Repository MCP server | Command | Purpose | | ------------------- | -------------------------------------------------- | | `npm run mcp:build` | Compile the local read-only MCP server | | `npm run mcp:start` | Compile and start the MCP server over standard I/O | An MCP client normally launches `mcp:start` for you. The process waits for MCP messages over standard input/output; it does not open a browser page or HTTP port. See the [MCP server guide](/docs/starter-kits/pro-nextjs-prisma/codebase/mcp-server) for the checked-in configuration and client-specific setup. ## Dependency Maintenance | Command | Purpose | | --------------------- | ---------------------------------------------- | | `npm run deps:check` | List dependency updates without changing files | | `npm run deps:update` | Update version ranges in `package.json` | After updating dependencies, run `npm install`, review the lockfile and complete the typecheck, lint, test and production build checks before committing. Be careful with the clean command npm run clean removes generated output, dependencies and ignored files from the listed build directories. Commit or back up any ignored work you intend to keep before running it. --- ## Dependencies **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/codebase/dependencies **Description**: Learn how to manage dependencies in the starter kit. We use [npm](https://www.npmjs.com/) as our package manager, which is the standard for Next.js projects. About npm npm is the default package manager for Node.js and comes pre-installed with Node.js. It's widely supported and works seamlessly with Next.js and the broader JavaScript ecosystem. ## Install all packages To install all packages, run: ```sh filename="Terminal" lineNumbers npm install ``` This is likely your first command when you download the starter kit. ## Add a package To install a package as a dependency: ```sh filename="Terminal" lineNumbers npm install ``` To install a package as a dev dependency: ```sh filename="Terminal" lineNumbers npm install -D ``` ## Remove a package To remove a package: ```sh filename="Terminal" lineNumbers npm uninstall ``` ## Update packages To update all packages to their latest versions: ```sh filename="Terminal" lineNumbers npm run deps:update ``` ## Check for outdated packages To see which packages have updates available: ```sh filename="Terminal" lineNumbers npm run deps:check ``` --- ## Environment Variables **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/codebase/environment-variables **Description**: Learn how environment variables are managed in the project. The starter kit uses [`@t3-oss/env-nextjs`](https://env.t3.gg/) to manage environment variables with type safety and validation. All environment variables are defined in `lib/env.ts` with Zod schemas. ## Environment Variable Files Create a `.env` file in the root directory (you can copy from `.env.example`): ```sh filename="Terminal" lineNumbers cp .env.example .env ``` Security Note Never commit `.env` to version control. It's already included in `.gitignore`. Use the same variable names in every environment, but store the values in the place that owns that environment: | Environment | Where to set values | What to commit | | ---------------------- | ------------------------------------------------ | ------------------------------------------ | | Local development | Root `.env` file | Only `.env.example` with safe placeholders | | Vercel or another host | The project's environment variable settings | Nothing containing production values | | CI | The CI provider's encrypted secrets or variables | Workflow references to the variable names | After changing a local value, restart the development server. After changing a hosted value, redeploy the affected environment so Next.js can include any build-time values in the new deployment. ## Server and Browser Variables The `server` and `client` schemas in `lib/env.ts` are a security boundary: - Server variables such as `DATABASE_URL`, `BETTER_AUTH_SECRET`, `STRIPE_SECRET_KEY` and `RESEND_API_KEY` must never use the `NEXT_PUBLIC_` prefix. - Client variables must start with `NEXT_PUBLIC_`. Their values are included in browser-accessible JavaScript and must not contain credentials or secrets. - Adding a variable to `.env` does not add it to the validated application configuration. Declare it in the matching schema and in `runtimeEnv` as shown below. Assume every public value is visible Publishable Stripe keys, Price IDs, site URLs and Turnstile site keys can be public. Stripe secret keys, webhook secrets, database credentials, Better Auth secrets, Resend keys and Turnstile secret keys must remain server-only. ## Required Variables The following environment variables are required for the application to run: ### Database ```env filename=".env" DATABASE_URL=postgresql://user:password@localhost:5432/dbname ``` Note The POSTGRES_* variables (POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, POSTGRES_HOST, POSTGRES_PORT) are optional and have defaults. Only DATABASE_URL is required. ### Authentication ```env filename=".env" BETTER_AUTH_SECRET=paste-a-new-random-secret-here ``` Generate your own value The authentication secret is named BETTER_AUTH_SECRET, not{' '} AUTH_SECRET. Replace the development value copied from{' '} .env.example before sharing or deploying the application. Every environment should use its own secret. ## Optional Variables Optional means the application can start without the integration. Once you enable a feature, configure its complete variable set rather than adding one key at a time. | Feature | Configure together | If omitted | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | | AI chat | `OPENAI_API_KEY` | AI requests cannot reach OpenAI | | Google sign-in | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | Google is not offered as a sign-in method | | Email delivery | `EMAIL_FROM`, `RESEND_API_KEY` | Email-sending flows fail when invoked | | Stripe billing | `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` and the Price IDs used by your configured plans or credits | Billing actions are unavailable | | S3 storage | `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_ENDPOINT`, `NEXT_PUBLIC_IMAGES_BUCKET_NAME` | Uploads are unavailable | | Turnstile | `TURNSTILE_SECRET_KEY`, `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | Captcha protection is disabled | | Sentry source maps | `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN` | Builds do not upload source maps | Keep paired values in sync Configure both the server and browser value for Stripe and Turnstile. A browser-only key can render an integration that the server cannot verify, while a server-only key leaves the corresponding client flow unavailable. ### AI (OpenAI) ```env filename=".env" OPENAI_API_KEY=sk-... ``` The shipped chat route uses the direct OpenAI provider. Its SDK reads `OPENAI_API_KEY` from the server environment, so this variable is intentionally not prefixed with `NEXT_PUBLIC_`. Remove the key if you disable the AI feature. ### Authentication (OAuth) ```env filename=".env" GOOGLE_CLIENT_ID=your-google-client-id GOOGLE_CLIENT_SECRET=your-google-client-secret ``` ### Billing (Stripe) ```env filename=".env" STRIPE_SECRET_KEY=sk_test_... STRIPE_WEBHOOK_SECRET=whsec_... NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY=price_... NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=price_... NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PRO=price_... ``` ### Email (Resend) ```env filename=".env" EMAIL_FROM=noreply@example.com RESEND_API_KEY=re_... ``` ### Storage (S3) ```env filename=".env" S3_ACCESS_KEY_ID=your-access-key S3_SECRET_ACCESS_KEY=your-secret-key S3_ENDPOINT=https://your-s3-compatible-endpoint.example S3_REGION=your-provider-region NEXT_PUBLIC_IMAGES_BUCKET_NAME=your-bucket-name ``` Use the endpoint and signing region supplied by your storage provider. The storage client falls back to `auto` only when `S3_REGION` is omitted. ### Monitoring (Sentry) ```env filename=".env" SENTRY_ORG=your-org SENTRY_PROJECT=your-project SENTRY_AUTH_TOKEN=your-auth-token NEXT_PUBLIC_SENTRY_DSN=https://...@sentry.io/... ``` ### Captcha (Cloudflare Turnstile) ```env filename=".env" TURNSTILE_SECRET_KEY=your-secret-key NEXT_PUBLIC_TURNSTILE_SITE_KEY=your-site-key ``` ### Site Configuration ```env filename=".env" NEXT_PUBLIC_SITE_URL=https://your-domain.com NEXT_PUBLIC_LOG_LEVEL=info ``` ## Type Safety The project uses TypeScript and Zod to ensure type safety for environment variables. All variables are defined in `lib/env.ts` with validation schemas. Type Safety Environment variables are validated at build time and runtime. If a required variable is missing or has an invalid type, the application will fail to start with a clear error message. ## Adding New Variables 1. Add the variable to `lib/env.ts` in the appropriate schema (server or client) 2. Add the variable to `.env.example` (without sensitive values) 3. Add the variable to your `.env` file with the actual value 4. Add the variable to `runtimeEnv` in `lib/env.ts` 5. Restart your development server ### Example: Adding a Server Variable ```typescript filename="lib/env.ts" lineNumbers server: { // ... existing variables MY_NEW_VAR: z.string().min(1), }, ``` ```typescript filename="lib/env.ts" lineNumbers runtimeEnv: { // ... existing variables MY_NEW_VAR: process.env.MY_NEW_VAR, }, ``` ### Example: Adding a Client Variable Client variables must be prefixed with `NEXT_PUBLIC_`: ```typescript filename="lib/env.ts" lineNumbers client: { // ... existing variables NEXT_PUBLIC_MY_VAR: z.string().optional(), }, ``` ```typescript filename="lib/env.ts" lineNumbers runtimeEnv: { // ... existing variables NEXT_PUBLIC_MY_VAR: process.env.NEXT_PUBLIC_MY_VAR, }, ``` ## Production For production deployments, set environment variables in your hosting platform's dashboard (Vercel, Railway, etc.). Never commit production secrets to your repository. ### Skipping Validation For Docker builds or CI/CD pipelines, you can skip environment variable validation: ```sh filename="Terminal" lineNumbers SKIP_ENV_VALIDATION=true bun run build ``` This is useful when environment variables are provided at runtime rather than build time. It only skips schema validation. It does not supply missing values, so the related feature can still fail when used. --- ## Formatting & Linting **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/codebase/formatting-linting **Description**: Learn how to format with Oxfmt and lint with Oxlint. The starter kit uses [Oxfmt](https://oxc.rs/docs/guide/usage/formatter.html) for formatting and [Oxlint](https://oxc.rs/docs/guide/usage/linter.html) for linting. The tools have separate configuration and commands so a formatting change never hides a lint failure. Type-aware linting is enabled Oxlint loads its TypeScript, React, import, accessibility and Next.js plugins with type-aware analysis. Run the repository scripts from the project root so the linter can resolve the local TypeScript configuration. ## Format and fix on save The recommended VS Code workspace settings use the Oxc extension for both formatting and safe lint fixes: ```json filename=".vscode/settings.json" lineNumbers { "editor.formatOnSave": true, "editor.defaultFormatter": "oxc.oxc-vscode", "editor.codeActionsOnSave": { "source.fixAll.oxc": "always" }, "oxc.fmt.configPath": ".oxfmtrc.json", "oxc.fixKind": "safe_fix_or_suggestion", "oxc.typeAware": true } ``` Change `editor.formatOnSave` to `false` and remove the `source.fixAll.oxc` action if you prefer to run the commands manually. ## Manual commands Use non-writing commands in CI and before reviewing a change: | Command | Purpose | | ---------------- | ----------------------------------------- | | `npm run format` | Check formatting with Oxfmt | | `npm run lint` | Check lint rules with Oxlint | | `npm run check` | Run linting and then the formatting check | Use the writing variants when you intentionally want to change files: | Command | Purpose | | ---------------------- | -------------------------------------------- | | `npm run format:write` | Format supported files with Oxfmt | | `npm run lint:write` | Apply safe Oxlint fixes | | `npm run check:write` | Apply lint fixes and then format the project | Always review the resulting diff. Automated fixes can be valid while still changing code in a way you did not intend. ## Configuration Oxlint reads `.oxlintrc.json`. The shipped configuration enables TypeScript, React, import, JSX accessibility, Next.js and Oxc rules. Generated output, coverage, migration files and test reports are ignored. Oxfmt reads `.oxfmtrc.json`. It defines the print width, quote and semicolon style, import sorting and Tailwind CSS class sorting. Migration files and generated or vendored output are excluded from formatting. Keep tool exclusions in these configuration files rather than adding ad hoc flags to package scripts. That keeps editor, local and CI behavior aligned. ## Editor integration Install the [Oxc VS Code extension](https://marketplace.visualstudio.com/items?itemName=oxc.oxc-vscode). The repository already recommends it through `.vscode/extensions.json` and sets it as the default formatter in `.vscode/settings.json`. If VS Code still uses an older formatter: 1. Disable the older workspace formatter extension for this repository. 2. Run **Format Document With...** and choose **Oxc**. 3. Select **Configure Default Formatter** and choose **Oxc**. 4. Reload the editor after installing or upgrading the extension. ## Pre-commit behavior The Husky pre-commit hook runs `lint-staged`. JavaScript and TypeScript files receive safe Oxlint fixes followed by Oxfmt. JSON, CSS, Markdown and MDX files are formatted with Oxfmt. Database migration files are deliberately excluded. The pre-push hook runs `npm run typecheck`. These hooks are a fast guardrail, not a replacement for the complete test and build checks in CI. --- ## Local Development **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/codebase/local-development **Description**: Learn how to set up your local development environment. This guide will help you set up your local development environment for the Pro Next.js Prisma starter kit, including the necessary services like PostgreSQL. ## Prerequisites To run the application locally, you need to have the following: - [Node.js 22.21.1](https://nodejs.org/en), matching the version in `package.json` - [npm](https://www.npmjs.com/) (comes with Node.js) - [PostgreSQL](https://www.postgresql.org/download/) (v14 or later) ## Recommended Startup Order Start only the services needed for the flow you are testing: | Order | Service | Command | Local address | Required | | ----- | ------------------------- | ----------------------- | ---------------------------------- | --------------------------------- | | 1 | PostgreSQL 17 | `npm run docker:up` | `localhost:5432` | Yes | | 2 | Next.js | `npm run dev` | `http://localhost:3000` | Yes | | 3 | React Email preview | `npm run email:dev` | `http://localhost:3001` | Only when editing email templates | | 4 | Stripe webhook forwarding | `npm run stripe:listen` | Forwards to `/api/webhooks/stripe` | Only when testing billing events | The Stripe command requires the [Stripe CLI](https://docs.stripe.com/stripe-cli) and an authenticated Stripe account. Copy the temporary `whsec_...` value it prints into `STRIPE_WEBHOOK_SECRET`, then restart Next.js. ## Setting Up Local Services ### Option 1: Local PostgreSQL Installation Install PostgreSQL on your machine and create a database: ```sh filename="Terminal" lineNumbers createdb your_database_name ``` ### Option 2: Docker Compose (Recommended) The repository includes a `docker-compose.yml` file with PostgreSQL 17. It creates a database named `database` with the password `password`. ## Starting the Services 1. Start the services using Docker Compose: ```sh filename="Terminal" lineNumbers npm run docker:up ``` 2. Verify that the services are running: ```sh filename="Terminal" lineNumbers docker compose ps ``` ## Environment Configuration Start from the environment template shipped with the repository: ```sh filename="Terminal" lineNumbers cp .env.example .env ``` The template already contains the local Docker database URL and `NEXT_PUBLIC_SITE_URL`. Replace its example `BETTER_AUTH_SECRET` with a unique value before starting the application: Optional integrations may remain empty until you test them. Password signup does require `RESEND_API_KEY` and `EMAIL_FROM` because new accounts must verify their email. Use the [environment variables guide](/docs/starter-kits/pro-nextjs-prisma/codebase/environment-variables) to select complete variable groups for email, Stripe, Google sign-in, AI, storage, Turnstile and Sentry. ## Accessing the Services - **PostgreSQL**: - Host: localhost - Port: 5432 - Username: postgres - Password: password - Database: database ## Running Database Migrations After setting up your database, run the migrations: ```sh filename="Terminal" lineNumbers npm run db:migrate:dev ``` This command will: - Create and apply migrations - Automatically regenerate Prisma Client ## Start Development Server Start the development server: ```sh filename="Terminal" lineNumbers npm run dev ``` Your application should now be running at `http://localhost:3000` with the local PostgreSQL database. ## Troubleshooting ### Database Connection Issues If you're having trouble connecting to PostgreSQL: 1. Verify the database is running: ```sh filename="Terminal" lineNumbers docker compose ps postgres ``` 2. Check the logs: ```sh filename="Terminal" lineNumbers docker compose logs postgres ``` 3. Verify your `DATABASE_URL` in `.env` matches your database configuration ### Port Already in Use If port 3000 is already in use, you can change it by setting the `PORT` environment variable: ```sh filename="Terminal" lineNumbers PORT=3002 npm run dev ``` Port `3001` is reserved by the included React Email preview command. If you change the application port, also update `NEXT_PUBLIC_SITE_URL`, OAuth callback URLs and the target used by Stripe webhook forwarding. ## Stopping the Services To stop all services: ```sh filename="Terminal" lineNumbers npm run docker:down ``` To stop and remove all data (including volumes): ```sh filename="Terminal" lineNumbers docker compose down -v ``` ## Additional Resources - [Docker Compose Documentation](https://docs.docker.com/compose/) - [PostgreSQL Documentation](https://www.postgresql.org/docs/) - [Next.js Documentation](https://nextjs.org/docs) - [Prisma Documentation](https://www.prisma.io/docs) --- ## MCP Server **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/codebase/mcp-server **Description**: Give coding assistants safe, repository-aware context with the local read-only MCP server. The starter kit includes a local [Model Context Protocol (MCP)](https://modelcontextprotocol.io/docs/getting-started/intro) server for compatible coding assistants. It gives an assistant structured, read-only context about the repository instead of relying on guessed paths, commands or database conventions. The server runs on your computer over standard input/output. Its MCP tools do not connect to PostgreSQL, execute package scripts, change source files, read environment files or make network requests. The startup command only compiles the server into the ignored `dist/` directory before connecting. ## Set up the server Install the project dependencies from the repository root: ```sh filename="Terminal" lineNumbers npm install ``` The checked-in `.mcp.json` is the project configuration used by Claude Code. It compiles the server before each start so a client cannot run stale generated output: ```json filename=".mcp.json" lineNumbers { "mcpServers": { "achromatic": { "type": "stdio", "command": "npm", "args": ["run", "--silent", "mcp:start"] } } } ``` You can compile the server without starting it as a separate validation step: ```sh filename="Terminal" lineNumbers npm run mcp:build ``` `npm run mcp:start` performs this compilation automatically and then starts the server. Because it uses stdio as its protocol transport, run it through an MCP client rather than expecting a browser page or HTTP port. ## Connect your coding assistant MCP clients use different project configuration filenames. Open the starter kit as the client workspace, install dependencies and use the matching setup below. ### Claude Code Claude Code discovers the checked-in `.mcp.json`. Review and approve the project server when prompted, then start a new session if it is not listed immediately. ### Cursor Create `.cursor/mcp.json`: ```json filename=".cursor/mcp.json" lineNumbers { "mcpServers": { "achromatic": { "command": "npm", "args": ["run", "--silent", "mcp:start"] } } } ``` ### Visual Studio Code Run **MCP: Add Server** from the command palette and save the stdio server to the workspace, or create `.vscode/mcp.json`: ```json filename=".vscode/mcp.json" lineNumbers { "servers": { "achromatic": { "type": "stdio", "command": "npm", "args": ["run", "--silent", "mcp:start"] } } } ``` ### Codex Create `.codex/config.toml`, trust the project and start Codex from the repository root: ```toml filename=".codex/config.toml" lineNumbers [mcp_servers.achromatic] command = "npm" args = ["run", "--silent", "mcp:start"] ``` For another client, add a local stdio server with command `npm`, arguments `run`, `--silent`, `mcp:start` and the starter kit repository root as its working directory. Restart the client or begin a new session after changing its configuration. Project-scoped MCP configuration can launch local commands. Review changes to `.mcp.json` and any client-specific MCP file before approving the server after a pull or branch switch, just as you would review changes to package scripts. ## What the server exposes The server provides 19 read-only tools grouped around common development tasks. Each tool returns both a text representation for broad client compatibility and a machine-readable structured result for clients that consume MCP structured content. A published output schema defines and validates the shared `structuredContent.result` envelope. Component and implementation lists return at most 250 entries, while searches return at most 50 matches. Use the available area and query filters to narrow a broad result before reading individual files. Limited tools expose `structuredContent.resultLimit`; when `reached` is `true`, narrow the request before assuming the result is complete. | Area | Available context | | -------------- | -------------------------------------------------------------------------------------------------------------- | | Project | Architecture, key package versions, security guardrails, package scripts and the supported validation sequence | | Components | Searchable UI and feature component paths, exported names and source | | Implementation | Searchable routes, configuration, hooks, core libraries, Zod schemas, tRPC source and shared types | | Documentation | A documentation index, full document reads and line-level search results | | Database | The current Prisma schema, field metadata and constraints, checked-in migrations and task-specific workflows | The complete tool contract is: - Project: `get_project_overview`, `list_project_scripts`, `get_healthcheck` - Components: `list_components`, `search_components`, `read_component` - Implementation: `list_implementation_files`, `search_implementation`, `read_implementation_file` - Documentation: `list_documentation`, `search_documentation`, `read_documentation` - Database: `get_database_overview`, `read_database_schema`, `list_migrations`, `read_migration`, `list_migration_metadata`, `read_migration_metadata`, `get_database_workflow` It also publishes: - a project overview resource - the current database schema resource - a documentation index resource - a feature-planning prompt - a change-review prompt During the MCP handshake, the server also tells compatible clients to begin with project discovery, search before reading individual files, inspect existing components before creating UI and request the ORM-specific workflow before suggesting database commands. The prompts do not grant extra access. They guide the assistant to use the same read-only tools and to inspect implementation sources, tenant isolation, authorization, migrations, existing components, tests and documentation. ## Recommended workflow Use discovery tools before asking an assistant to implement a change: 1. Call `get_project_overview` to load the application boundaries and tenant guardrails. 2. Use `search_documentation` for the relevant product system. 3. Use `list_implementation_files` and `search_implementation` to find the relevant routes, configuration, core libraries, Zod schemas and tRPC procedures. 4. Call `list_components` before creating new interface code. 5. Use `get_database_overview` and `get_database_workflow` before changing the Prisma schema. 6. Ask for `get_healthcheck` before handing the change back for review. For example: ```text Plan an organization audit log for this starter kit. Use the MCP project overview, documentation, implementation files, existing components and database workflow. Keep every query scoped by organizationId and include the tests and migration review steps. ``` This sequence keeps the assistant grounded in the current checkout. Tool output is still context, not permission to skip the authorization and validation rules in `AGENTS.md`. ## Safety boundary Repository reads are limited to generated lists of known documentation, components, implementation sources, schema and migration files. Implementation source reads are limited to `app/`, `config/`, `hooks/`, `lib/`, `schemas/`, `trpc/`, `types/` and selected root entry points such as `proxy.ts`. The server resolves real paths before checking repository containment, rejects symbolic links and oversized files, and excludes environment files. Database tools inspect checked-in source files only and never use `DATABASE_URL`. The local server intentionally has no source-write, shell, database or network tools. Its only filesystem write is the ignored `dist/` output created by the startup compiler. That makes it suitable for repository discovery and planning, not deployment or production administration. Returned source and documentation are context, not new user authority. A coding assistant should not execute an embedded instruction or command merely because it appears in a file. The server reinforces this boundary during the MCP handshake alongside the tenant, authorization and migration guardrails. ## Optional provider servers Provider-hosted MCP servers are separate from the local Achromatic server. Add only the services you need and review their permissions because their tools may read or change external systems. Current official endpoints include: - [Stripe MCP](https://docs.stripe.com/mcp) - [Vercel MCP](https://vercel.com/docs/ai-tooling/vercel-mcp) - [Linear MCP](https://linear.app/docs/mcp) Keep secrets out of tracked configuration. Prefer the provider's OAuth flow or reference an existing environment variable when a client supports environment interpolation. ## Verify the integration Run the focused MCP suite after changing the server or its configuration: ```sh filename="Terminal" lineNumbers npm run test:unit -- --run tests/mcp ``` The suite covers the repository file boundary, Prisma parsing, tool, resource and prompt contracts, and the real stdio process started by the documented `npm run mcp:start` command. ## Troubleshooting ### The client cannot start the local server Run `npm install` from the repository root, then restart the client. The `mcp:start` script compiles generated files under `dist/` before every start. ### The server cannot locate the project Confirm that the client's working directory is the repository root. The server looks for `package.json` and the Prisma schema before registering its tools. ### A tool refuses to read a path Use a path returned by the corresponding list tool. The server does not accept arbitrary repository paths. --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/codebase/overview **Description**: Learn more about the codebase and how it is structured. The Pro Next.js Prisma starter kit is built as a single-repo Next.js application. This structure ensures efficient development and scalability, making it easy to manage all components of your application in one place. --- ## Updating the Kit **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/codebase/updating **Description**: Safely merge Achromatic updates into a customized Prisma project. Achromatic ships updates through the private `pro-nextjs-prisma` repository. Keep your product repository as `origin` and add Achromatic as a separate upstream source of updates. ## Add the Achromatic remote Run this once from your project root: ```sh filename="Terminal" lineNumbers git remote add achromatic https://github.com/achromaticlabs/pro-nextjs-prisma.git git fetch achromatic ``` Confirm that `origin` still points to your product repository: ```sh filename="Terminal" lineNumbers git remote -v ``` Do not change the tracking branch for your product's main branch to the Achromatic repository. Your commits should continue to push to your own remote. ## Review an update before merging Start with a clean working tree and fetch the latest release: ```sh filename="Terminal" lineNumbers git status --short git fetch achromatic git log --oneline --decorate HEAD..achromatic/main git diff --stat HEAD...achromatic/main ``` Read the [Achromatic changelog](/changelog) and inspect changes that touch authentication, database migrations, billing, environment variables or deployment before merging them. ## Merge on a dedicated branch Create a branch from your current product state, then merge the upstream code: ```sh filename="Terminal" lineNumbers git switch -c update/achromatic git merge achromatic/main ``` Resolve conflicts in favor of your product requirements while preserving security fixes and new committed migrations. Do not regenerate or delete migration history just to make a merge clean. After resolving conflicts: ```sh filename="Terminal" lineNumbers npm install npm run typecheck npm run lint npm run test npm run build ``` Run the authenticated E2E suite against a disposable database when the update changes authentication, organizations, billing, credits, settings or the admin area: ```sh filename="Terminal" lineNumbers npm run test:e2e:setup npm run test:e2e ``` ## Apply database changes safely Review new migration files before applying them. Back up any database containing data you need, then run the kit's deployment migration command in each target environment: ```sh filename="Terminal" lineNumbers npm run db:migrate ``` The Prisma kit applies the committed migrations supplied with each update. Generate a new migration only for schema changes made by your product, not for migration files already supplied by Achromatic. ## Finish the update Test the update branch in a preview environment. When it is ready, merge it into your product's main branch using your normal review workflow. If an update is too large to merge at once, review the upstream commits and cherry-pick a focused security or dependency fix. Record skipped commits so the same conflict is not investigated again during the next update. --- ## VS Code Setup **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/codebase/vscode **Description**: Configure VS Code for the repository's formatter, linter, Tailwind CSS and TypeScript. You can use any editor. The repository includes VS Code recommendations and workspace settings so contributors use the same formatter, lint fixes and local TypeScript version. When you first open the repository, accept the recommended extensions from `.vscode/extensions.json`. You can install them manually at any time. ## Oxc The [Oxc extension](https://marketplace.visualstudio.com/items?itemName=oxc.oxc-vscode) integrates Oxlint and Oxfmt. The workspace sets Oxc as the default formatter, formats on save and applies safe lint fixes on save. ## Tailwind CSS IntelliSense [Tailwind CSS IntelliSense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) adds completion, validation and hover previews for Tailwind classes. The workspace points it to `app/globals.css`, which is the Tailwind CSS entry point. ## Prisma The [Prisma extension](https://marketplace.visualstudio.com/items?itemName=Prisma.prisma) adds schema highlighting, formatting, completion and diagnostics. It is useful when editing `prisma/schema.prisma`, but it is optional and is not installed by the workspace recommendation automatically. ## TypeScript VS Code includes TypeScript language support. The workspace uses `node_modules/typescript/lib` so editor diagnostics match the TypeScript version installed by the repository. If VS Code prompts you to choose a TypeScript version, select **Use Workspace Version**. ## Included workspace behavior The settings in `.vscode/settings.json`: - format supported files with Oxfmt on save and paste - apply safe Oxlint fixes on save - enable type-aware Oxlint analysis - use the local TypeScript SDK - point Tailwind CSS IntelliSense at `app/globals.css` - exclude generated Next.js output from search - avoid auto-imports from unsupported Next.js entry points and `radix-ui` Treat the workspace files as shared project configuration. Discuss changes before committing personal editor preferences that affect every contributor. --- ## Configuration **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/configuration **Description**: Learn how to configure your application using the configuration files. The Pro Next.js Prisma starter kit uses a modular configuration system that allows you to customize your application to your needs. Configuration is split into separate files in the `config/` directory, making it easy to manage different aspects of your application. ## Configuration Structure Configuration files are located in the `config/` directory: ```text filename="Project Structure" lineNumbers config/ ├── app.config.ts # App-wide settings (name, themes, site sections) ├── auth.config.ts # Authentication settings (redirects, CORS, signup) ├── billing.config.ts # Billing and plans configuration └── storage.config.ts # Storage bucket configuration ``` ## Using Configuration Configuration objects are exported from each file and can be imported where needed: ```typescript filename="lib/utils.ts" lineNumbers import { appConfig } from '@/config/app.config'; export function getAppName() { return appConfig.appName; } ``` ## Configuration Principles ### Type Safety All configuration objects use TypeScript's `satisfies` keyword to ensure type safety while preserving literal types. This gives you autocomplete and type checking. ### Environment Variables Configuration files can read from environment variables using the `env` object from `@/lib/env`. This keeps sensitive values out of your code. ### Modular Design Each configuration file focuses on a specific domain (app, auth, billing, storage), making it easy to find and modify settings. ### Default Values Configuration files provide sensible defaults, but you can override them to match your needs. ## Common Use Cases ### Disable Marketing Site If you want to deploy only the SaaS application without the marketing site: ```typescript filename="config/app.config.ts" lineNumbers export const appConfig = { // ... other config site: { marketing: { enabled: false // Disables marketing routes }, saas: { enabled: true } } }; ``` ### Disable SaaS Application If you want to deploy only the marketing site: ```typescript filename="config/app.config.ts" lineNumbers export const appConfig = { // ... other config site: { marketing: { enabled: true }, saas: { enabled: false // Disables SaaS routes } } }; ``` ### Gate the Starter Signup Page Set `enableSignup` to `false` to hide signup links in the starter auth cards and require a valid, pending invitation when someone opens the starter signup page: ```typescript filename="config/auth.config.ts" lineNumbers export const authConfig = { // ... other config enableSignup: false // Gate the starter signup page by invitation }; ``` This setting is a UI and page-route gate. It does not block direct requests to Better Auth's signup endpoint. Add server-side invitation validation in your auth layer before describing the application as strictly invitation-only. ### Gate the Starter Organization Creation Path To block non-admin organization creation through the starter's `trpc.organization.create` procedure: ```typescript filename="config/app.config.ts" lineNumbers export const appConfig = { // ... other config organizations: { allowUserCreation: false // Guard the starter tRPC creation procedure } }; ``` This setting does not configure Better Auth's organization endpoint. Passing `allowUserToCreateOrganization: false` to the Better Auth `organization` plugin disables creation through that endpoint for everyone. Use a function that returns `true` for allowed users if you want to preserve an admin exception. ## Next Steps Explore the individual configuration files to learn more about each area: - [App Configuration](/docs/starter-kits/pro-nextjs-prisma/configuration/app) - App-wide settings - [Authentication Configuration](/docs/starter-kits/pro-nextjs-prisma/configuration/auth) - Auth settings - [Billing Configuration](/docs/starter-kits/pro-nextjs-prisma/configuration/billing) - Plans and pricing - [Storage Configuration](/docs/starter-kits/pro-nextjs-prisma/configuration/storage) - Storage buckets --- ## App Configuration **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/configuration/app **Description**: Configure app name, site sections, themes, and organization settings. The app configuration file (`config/app.config.ts`) contains application-wide settings including the app name, site sections, theme configuration, and organization settings. ## Configuration File ```typescript filename="config/app.config.ts" lineNumbers import { getBaseUrl } from '@/lib/utils'; export const appConfig = { appName: 'Acme', description: `Acme's description`, baseUrl: getBaseUrl(), // Contact information (displayed on contact page) contact: { enabled: true, email: 'hello@yourdomain.com', phone: '(123) 456-7890', address: '123 Main St, San Francisco, CA' }, // Site sections - enable/disable major parts of the site site: { // Marketing website (landing page, blog, docs, etc.) // When disabled, all marketing routes redirect to /dashboard marketing: { enabled: true }, // SaaS application (dashboard, auth, etc.) // When disabled, all /dashboard and /auth routes redirect to marketing homepage saas: { enabled: true } }, // Theme configuration theme: { // Default theme for new users: "light", "dark", or "system" default: 'system' as const, // Available themes users can choose from available: ['light', 'dark'] as const }, // Organization settings organizations: { // Guard non-admin access to the starter tRPC creation procedure // This does not configure Better Auth's organization endpoint allowUserCreation: true }, // Pagination defaults pagination: { // Default page size for lists defaultLimit: 20, // Maximum allowed page size maxLimit: 100 } } satisfies AppConfig; ``` ## Configuration Options ### App Information - **`appName`**: The name of your application, displayed throughout the UI - **`description`**: A brief description of your application - **`baseUrl`**: The base URL of your application (automatically detected) ### Contact Information The `contact` object configures contact information displayed on the contact page: - **`enabled`**: Whether the contact form is enabled - **`email`**: Contact email address - **`phone`**: Contact phone number - **`address`**: Physical address ### Site Sections The `site` object controls which parts of your application are enabled: - **`marketing.enabled`**: Enable/disable the marketing website (landing page, blog, docs) - When disabled, all marketing routes redirect to `/dashboard` - **`saas.enabled`**: Enable/disable the SaaS application (dashboard, auth) - When disabled, all `/dashboard` and `/auth` routes redirect to marketing homepage ### Theme Configuration The `theme` object controls theme settings: - **`default`**: Default theme for new users (`"light"`, `"dark"`, or `"system"`) - **`available`**: Array of themes users can choose from ### Organization Settings The `organizations` object controls organization-related features: - **`allowUserCreation`**: Whether the starter's `trpc.organization.create` procedure accepts non-admin users - When `false`, that procedure still accepts platform admins - This does not configure Better Auth's organization endpoint ### Pagination The `pagination` object sets default pagination values: - **`defaultLimit`**: Default number of items per page - **`maxLimit`**: Maximum allowed items per page ## Use Cases ### Deploy Marketing Site Only To deploy only the marketing site without the SaaS application: ```typescript filename="config/app.config.ts" lineNumbers export const appConfig = { // ... other config site: { marketing: { enabled: true }, saas: { enabled: false // Disables SaaS routes } } }; ``` ### Deploy SaaS Application Only To deploy only the SaaS application without the marketing site: ```typescript filename="config/app.config.ts" lineNumbers export const appConfig = { // ... other config site: { marketing: { enabled: false // Disables marketing routes }, saas: { enabled: true } } }; ``` ### Gate the Starter Organization Creation Path To block non-admin creation through `trpc.organization.create`: ```typescript filename="config/app.config.ts" lineNumbers export const appConfig = { // ... other config organizations: { allowUserCreation: false // Guard the starter tRPC creation procedure } }; ``` Passing `allowUserToCreateOrganization: false` to the Better Auth `organization` plugin disables creation through that endpoint for everyone. Use a function that returns `true` for allowed users if you want to preserve the starter tRPC procedure's admin exception. ### Customize Theme Options To customize available themes: ```typescript filename="config/app.config.ts" lineNumbers export const appConfig = { // ... other config theme: { default: 'dark' as const, available: ['light', 'dark', 'system'] as const } }; ``` ## Type Definitions The configuration uses TypeScript types for type safety: ```typescript filename="config/app.config.ts" lineNumbers export type ContactConfig = { enabled: boolean; email: string; phone: string; address: string; }; export type SiteConfig = { marketing: { enabled: boolean; }; saas: { enabled: boolean; }; }; export type ThemeConfig = { default: 'light' | 'dark' | 'system'; available: readonly ('light' | 'dark')[]; }; export type OrganizationsConfig = { allowUserCreation: boolean; }; export type PaginationConfig = { defaultLimit: number; maxLimit: number; }; export type AppConfig = { appName: string; description: string; baseUrl: string; contact: ContactConfig; site: SiteConfig; theme: ThemeConfig; organizations: OrganizationsConfig; pagination: PaginationConfig; }; ``` ## Using the Configuration Import and use the configuration in your code: ```typescript filename="components/app-header.tsx" lineNumbers import { appConfig } from "@/config/app.config"; export function AppHeader() { return

{appConfig.appName}

; } ``` ```typescript filename="lib/pagination.ts" lineNumbers import { appConfig } from '@/config/app.config'; export function getDefaultLimit() { return appConfig.pagination.defaultLimit; } ``` --- ## Authentication Configuration **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/configuration/auth **Description**: Configure authentication settings, redirects, and CORS. The authentication configuration file (`config/auth.config.ts`) contains settings for authentication, session management, redirects, and CORS. ## Configuration File ```typescript filename="config/auth.config.ts" lineNumbers import { env } from '@/lib/env'; import { getBaseUrl } from '@/lib/utils'; const origins = Array.from( new Set( [ getBaseUrl(), env.NEXT_PUBLIC_SITE_URL, env.NEXT_PUBLIC_VERCEL_URL ? `https://${env.NEXT_PUBLIC_VERCEL_URL}` : undefined, env.NEXT_PUBLIC_VERCEL_BRANCH_URL ? `https://${env.NEXT_PUBLIC_VERCEL_BRANCH_URL}` : undefined, env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL ? `https://${env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL}` : undefined, env.NEXT_PUBLIC_NODE_ENV === 'development' ? 'http://localhost:3000' : undefined ].filter(Boolean) as string[] ) ); export const authConfig = { redirectAfterSignIn: '/dashboard', redirectAfterLogout: '/', sessionCookieMaxAge: 60 * 60 * 24 * 30, verificationExpiresIn: 60 * 60 * 24 * 14, minimumPasswordLength: 8, trustedOrigins: origins, // Controls signup links and the starter signup page // This does not block Better Auth's signup endpoint enableSignup: true, enableSocialLogin: true, enablePasskeys: true, cors: { allowedOrigins: [...origins, /^https:\/\/.*\.vercel\.app$/], allowedMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: [ 'Authorization', 'Content-Type', 'Accept', 'Origin', 'X-Requested-With', 'Access-Control-Request-Method', 'Access-Control-Request-Headers', 'X-CSRF-Token', 'Accept-Version', 'Content-Length', 'Content-MD5', 'Date', 'X-Api-Version', 'cf-connecting-ip', 'cf-ipcountry', 'cf-ray', 'cf-visitor', 'x-vercel-id', 'x-vercel-deployment-url', 'x-vercel-proxied-for', 'X-Forwarded-For', 'X-Forwarded-Host', 'X-Forwarded-Proto', 'X-Real-IP', 'Connection', 'Host', 'User-Agent', 'Referer' ], maxAge: 86_400 } } satisfies AuthConfig; ``` ## Configuration Options ### Redirects - **`redirectAfterSignIn`**: Where users are redirected after successful sign in (default: `"/dashboard"`) - **`redirectAfterLogout`**: Where users are redirected after logout (default: `"/"`) ### Session Management - **`sessionCookieMaxAge`**: Maximum age of the session cookie in seconds (default: 30 days) - **`verificationExpiresIn`**: How long email verification links are valid in seconds (default: 14 days) ### Password Requirements - **`minimumPasswordLength`**: Minimum password length required (default: `8`) ### Trusted Origins - **`trustedOrigins`**: Array of trusted origins for authentication requests - Automatically includes base URL, Vercel URLs, and localhost in development - Used for CSRF protection and secure authentication ### Signup and Login - **`enableSignup`**: Whether the starter auth cards show signup links and the signup page opens without a valid, pending invitation (default: `true`) - **`enableSocialLogin`**: Whether the starter UI shows social login buttons (default: `true`) - **`enablePasskeys`**: Whether the passkey plugin endpoints, sign-in button and account-management card are available (default: `true`) ### CORS Configuration The `cors` object configures Cross-Origin Resource Sharing: - **`allowedOrigins`**: Array of allowed origins (includes trusted origins and Vercel preview URLs) - **`allowedMethods`**: HTTP methods allowed in CORS requests - **`allowedHeaders`**: HTTP headers allowed in CORS requests - **`maxAge`**: Maximum age for preflight requests in seconds (default: 24 hours) ## Use Cases ### Gate the Starter Signup Page To hide signup links in the starter auth cards and gate the starter signup page by invitation: ```typescript filename="config/auth.config.ts" lineNumbers export const authConfig = { // ... other config enableSignup: false }; ``` The signup page validates that the supplied invitation exists, is pending and has not expired. `enableSignup` is not a server-side policy for Better Auth's signup endpoint. A production invitation-only product must add server-side invitation validation and review every enabled signup path, including OAuth. ### Hide Social Login Buttons To hide the starter's OAuth buttons and connected accounts card: ```typescript filename="config/auth.config.ts" lineNumbers export const authConfig = { // ... other config enableSocialLogin: false }; ``` This flag does not unregister the configured OAuth provider or disable Better Auth's OAuth routes. Remove the provider from `lib/auth/index.ts` and its credentials from the environment if you want to disable the provider itself. ### Disable Passkeys To remove both passkey UI and server endpoints: ```typescript filename="config/auth.config.ts" lineNumbers export const authConfig = { // ... other config enablePasskeys: false }; ``` Unlike the social-login display flag, `enablePasskeys` conditionally registers the Better Auth passkey plugin. Existing passkey rows can remain in the database if you temporarily disable the feature. ### Custom Redirects To customize redirect paths: ```typescript filename="config/auth.config.ts" lineNumbers export const authConfig = { // ... other config redirectAfterSignIn: '/dashboard', redirectAfterLogout: '/auth/sign-in' }; ``` ### Adjust Session Duration To change session cookie duration: ```typescript filename="config/auth.config.ts" lineNumbers export const authConfig = { // ... other config sessionCookieMaxAge: 60 * 60 * 24 * 7 // 7 days instead of 30 }; ``` ### Stricter Password Requirements To require longer passwords: ```typescript filename="config/auth.config.ts" lineNumbers export const authConfig = { // ... other config minimumPasswordLength: 12 // Require 12 characters minimum }; ``` ## Type Definitions The configuration uses TypeScript types for type safety: ```typescript filename="config/auth.config.ts" lineNumbers export type CorsConfig = { allowedOrigins: (string | RegExp)[]; allowedMethods: string[]; allowedHeaders: string[]; maxAge: number; }; export type AuthConfig = { redirectAfterSignIn: string; redirectAfterLogout: string; sessionCookieMaxAge: number; verificationExpiresIn: number; minimumPasswordLength: number; trustedOrigins: string[]; enableSignup: boolean; enableSocialLogin: boolean; cors: CorsConfig; }; ``` ## Using the Configuration Import and use the configuration in your code: ```typescript filename="lib/auth/redirects.ts" lineNumbers import { authConfig } from '@/config/auth.config'; export function getSignInRedirect() { return authConfig.redirectAfterSignIn; } ``` ```typescript filename="lib/auth/validation.ts" lineNumbers import { authConfig } from '@/config/auth.config'; export function validatePassword(password: string) { if (password.length < authConfig.minimumPasswordLength) { throw new Error( `Password must be at least ${authConfig.minimumPasswordLength} characters` ); } } ``` --- ## Billing Configuration **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/configuration/billing **Description**: Configure plans, pricing, and credit packages. The billing configuration file (`config/billing.config.ts`) contains settings for billing features, subscription plans, pricing, and credit packages for AI features. ## Configuration File The billing configuration is extensive and includes: - **Billing settings**: Enable/disable billing, default currency - **Plans**: Subscription plans with features, limits, and pricing - **Credit packages**: One-time credit purchases for AI features - **Credit costs**: Per-model pricing for AI usage ```typescript filename="config/billing.config.ts" lineNumbers import { env } from '@/lib/env'; export const billingConfig = { // Enable/disable billing feature enabled: true, // Default currency defaultCurrency: 'usd', // Plans configuration plans: { // Free tier - no Stripe price needed free: { id: 'free', name: 'Free', description: 'Get started with basic features', isFree: true, features: [ 'Up to 3 team members', 'Basic analytics', 'Community support', '1 GB storage' ], limits: { maxMembers: 3, maxStorage: 1 // GB } }, // Pro plan - main paid tier pro: { id: 'pro', name: 'Pro', description: 'For growing teams', recommended: true, features: [ 'Unlimited team members', 'Advanced analytics', 'Priority support', '100 GB storage', 'Custom integrations', 'API access' ], limits: { maxMembers: -1, // unlimited maxStorage: 100 // GB }, prices: [ { id: 'pro_monthly', stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY ?? '', type: 'recurring', interval: 'month', intervalCount: 1, amount: 2900, // $29.00 in cents currency: 'usd', seatBased: true, // Per-seat pricing trialDays: 14 }, { id: 'pro_yearly', stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY ?? '', type: 'recurring', interval: 'year', intervalCount: 1, amount: 27800, // $278.00 in cents currency: 'usd', seatBased: true, trialDays: 14 } ] }, // Lifetime deal - one-time order lifetime: { id: 'lifetime', name: 'Lifetime', description: 'Pay once, use forever', features: [ 'All Pro features', 'Lifetime updates', 'Priority support for 1 year', '100 GB storage' ], limits: { maxMembers: -1, maxStorage: 100 }, prices: [ { id: 'lifetime_once', stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_LIFETIME ?? '', type: 'one_time', amount: 49900, // $499.00 in cents currency: 'usd' } ] } } } satisfies BillingConfig; ``` Storage limits are not enforced The current storage code does not read maxStorage or meter stored bytes. Treat storage amounts as plan metadata until you add usage tracking and server-side enforcement. ## Configuration Options ### Billing Settings - **`enabled`**: Enable/disable billing feature (default: `true`) - **`defaultCurrency`**: Default currency for pricing (default: `"usd"`) ### Plans Each plan in the `plans` object has: - **`id`**: Unique identifier for the plan - **`name`**: Display name - **`description`**: Plan description - **`features`**: Array of feature strings - **`limits`**: Plan limits (members, storage) - **`prices`**: Array of price configurations #### Plan Types - **Free plans**: Set `isFree: true`, no prices needed - **Paid plans**: Include `prices` array with Stripe price IDs - **Enterprise plans**: Set `isEnterprise: true`, typically no prices (contact sales) #### Price Configuration Each price has: - **`id`**: Unique price identifier - **`stripePriceId`**: Stripe Price ID from your Stripe dashboard - **`type`**: `"recurring"` or `"one_time"` - **`amount`**: Price in cents - **`currency`**: Currency code - **`interval`**: For recurring: `"month"`, `"year"`, `"week"`, or `"day"` - **`intervalCount`**: Number of intervals - **`seatBased`**: Whether pricing is per-seat (optional) - **`trialDays`**: Trial period in days (optional) ### Credit Packages Credit packages are configured separately for one-time purchases: ```typescript filename="config/billing.config.ts" lineNumbers export const creditPackages = [ { id: 'credits_starter', name: 'Starter', description: 'Great for trying out AI features', credits: 10_000, bonusCredits: 0, priceAmount: 999, // $9.99 in cents currency: 'usd', stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER ?? '', popular: false }, { id: 'credits_basic', name: 'Basic', description: 'For regular AI usage', credits: 50_000, bonusCredits: 5_000, // 10% bonus priceAmount: 3999, // $39.99 currency: 'usd', stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC ?? '', popular: true } ]; ``` ### Credit Costs Credit costs define pricing per AI model: ```typescript filename="config/billing.config.ts" lineNumbers export const creditCosts = { 'gpt-4o-mini': { input: 1, // credits per 1K input tokens output: 6 // credits per 1K output tokens }, 'gpt-4o': { input: 25, output: 100 } // ... more models } as const; ``` ## Use Cases ### Add a New Plan To add a new subscription plan: ```typescript filename="config/billing.config.ts" lineNumbers export const billingConfig = { // ... other config plans: { // ... existing plans business: { id: 'business', name: 'Business', description: 'For larger teams', features: [ 'Everything in Pro', 'Advanced security', 'Dedicated support', '500 GB storage' ], limits: { maxMembers: -1, maxStorage: 500 }, prices: [ { id: 'business_monthly', stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_BUSINESS_MONTHLY ?? '', type: 'recurring', interval: 'month', intervalCount: 1, amount: 9900, // $99.00 currency: 'usd', seatBased: true } ] } } }; ``` `NEXT_PUBLIC_STRIPE_PRICE_BUSINESS_MONTHLY` is a customization placeholder. It is not defined by the shipped kit. Before using it, add the variable to the client schema and `runtimeEnv` mapping in `lib/env.ts`, then add it to `.env.example` and your deployment environment. ### Disable Billing To disable billing entirely: ```typescript filename="config/billing.config.ts" lineNumbers export const billingConfig = { enabled: false // ... other config }; ``` ### Add Enterprise Plan To add an enterprise plan (contact sales): ```typescript filename="config/billing.config.ts" lineNumbers export const billingConfig = { // ... other config plans: { // ... existing plans enterprise: { id: 'enterprise', name: 'Enterprise', description: 'For large organizations', isEnterprise: true, features: [ 'Everything in Pro', 'Dedicated account manager', 'Custom SLA', 'Unlimited storage', 'SSO / SAML' ], limits: { maxMembers: -1, maxStorage: -1 } } } }; ``` ## Type Definitions The configuration uses TypeScript types for type safety: ```typescript filename="config/billing.config.ts" lineNumbers export type PriceConfig = { id: string; stripePriceId: string; amount: number; currency: string; } & ( | { type: 'recurring'; interval: 'month' | 'year' | 'week' | 'day'; intervalCount: number; seatBased?: boolean; trialDays?: number; } | { type: 'one_time'; } ); export type PlanLimits = { maxMembers: number; // -1 for unlimited maxStorage: number; // in GB, -1 for unlimited }; export type Plan = FreePlan | PaidPlan | EnterprisePlan; export type BillingConfig = { enabled: boolean; defaultCurrency: string; plans: Record; }; ``` ## Using the Configuration Import and use the configuration in your code: ```typescript filename="lib/billing/plans.ts" lineNumbers import { billingConfig } from '@/config/billing.config'; export function getPlanById(planId: string) { return billingConfig.plans[planId]; } export function getAllPlans() { return Object.values(billingConfig.plans); } ``` ```typescript filename="lib/billing/credits.ts" lineNumbers import { creditCosts, creditPackages } from '@/config/billing.config'; export function getCreditPackageById(id: string) { return creditPackages.find((pkg) => pkg.id === id); } export function calculateCreditsForModel( modelId: string, inputTokens: number, outputTokens: number ) { const costs = creditCosts[modelId as keyof typeof creditCosts]; const inputCost = Math.ceil((inputTokens / 1000) * costs.input); const outputCost = Math.ceil((outputTokens / 1000) * costs.output); return inputCost + outputCost; } ``` ## Next Steps For more information on billing, see: - [Billing Overview](/docs/starter-kits/pro-nextjs-prisma/billing/overview) - [Plans](/docs/starter-kits/pro-nextjs-prisma/billing/plans) - [Subscriptions](/docs/starter-kits/pro-nextjs-prisma/billing/subscriptions) - [Credits](/docs/starter-kits/pro-nextjs-prisma/billing/credits) --- ## Storage Configuration **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/configuration/storage **Description**: Configure the image bucket used by the shipped storage integration. The shipped configuration contains one bucket name for user avatars and organization logos. ## Current configuration ```typescript filename="config/storage.config.ts" lineNumbers import { env } from '@/lib/env'; export const storageConfig = { bucketNames: { images: env.NEXT_PUBLIC_IMAGES_BUCKET_NAME ?? '' } } satisfies StorageConfig; export type StorageConfig = { bucketNames: { images: string; }; }; ``` Set the value with: ```env filename=".env" lineNumbers NEXT_PUBLIC_IMAGES_BUCKET_NAME="my-images-bucket" ``` The name is included in client-generated `/storage/{bucket}/{key}` URLs, so it is intentionally public configuration. Storage credentials must remain in the server-only `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY` variables. ## Adding another bucket Additional document, video or tenant-specific buckets are a customization. Adding a property to `storageConfig` alone is not enough. You must also: 1. Add and map the environment variable in `lib/env.ts`. 2. Extend the `StorageConfig` type. 3. Decide which authenticated procedures may sign uploads for the bucket. 4. Add server-side object key and ownership rules. 5. Add an authorized download route for private data. 6. Configure provider credentials, CORS and lifecycle policies. Do not add a private bucket to the shipped public `/storage/[...path]` handler unless its objects are intended to be accessible to anyone who knows their key. ## Related guides - [Storage Overview](/docs/starter-kits/pro-nextjs-prisma/storage/overview) - [Setup](/docs/starter-kits/pro-nextjs-prisma/storage/setup) - [Upload Files](/docs/starter-kits/pro-nextjs-prisma/storage/upload) - [Access Files](/docs/starter-kits/pro-nextjs-prisma/storage/access) --- ## Favicons & Icons **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/customization/favicons **Description**: Learn how to generate and integrate favicons and app icons for your project. ## Generating a favicon 1. Visit [Favicon Generator](https://www.favicon-generator.org/). 2. Upload an image (recommended size: **at least 512×512px** for optimal resizing). 3. Click on **Create Favicon** ## Downloading Click **Download** to save the generated files. ## Copying and overwriting 1. Select all downloaded files, **excluding** `browserconfig.xml` and `manifest.json`. 2. Copy and paste the files into the `public` directory. ## Updating metadata Update the favicon references in `app/layout.tsx`: ```tsx filename="app/layout.tsx" lineNumbers export const metadata = { icons: { icon: '/favicon.ico', apple: '/apple-touch-icon.png' } // ... }; ``` Note that sometimes it takes time for the browser to reflect favicon changes. Try clearing your browser cache or doing a hard refresh. --- ## Fonts **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/customization/fonts **Description**: Learn how to change fonts using Google Fonts or custom fonts. You can change the font in `app/layout.tsx`. The starter kit currently uses: ```tsx filename="app/layout.tsx" lineNumbers import { Literata } from 'next/font/google'; import { GeistSans } from 'geist/font/sans'; const literata = Literata({ subsets: ['latin'], variable: '--font-literata' }); export default function RootLayout({ children }) { return ( {/* ... */} ); } ``` To change to a different Google Font, for example `Inter`: ```tsx filename="app/layout.tsx" lineNumbers import { Inter } from 'next/font/google'; import { GeistSans } from 'geist/font/sans'; const inter = Inter({ subsets: ['latin'], variable: '--font-inter' }); export default function RootLayout({ children }) { return ( {/* ... */} ); } ``` Or use a custom font: ```tsx filename="app/layout.tsx" lineNumbers import localFont from 'next/font/local'; const customFont = localFont({ src: './fonts/custom-font.woff2', display: 'swap' }); ``` ## Font Variables Font variables are automatically available via the `variable` prop. You can use them in your CSS: ```css filename="app/globals.css" lineNumbers :root { --font-sans: var(--font-geist-sans); --font-literata: var(--font-literata); } ``` Then use them in your components: ```tsx filename="components/example.tsx" lineNumbers
This uses Geist Sans
This uses Literata
``` --- ## Naming & Branding **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/customization/naming **Description**: Learn how to change the app name, description, and metadata throughout your application. ## App Name and Description The app name and description are used throughout the application. Update them in the configuration file: ```typescript filename="config/app.config.ts" lineNumbers export const appConfig = { appName: 'Your App Name', description: 'A fantastic SaaS to make your life easier.', baseUrl: 'https://yourdomain.com' // ... }; ``` This configuration is used in: - Navigation and headers - Email templates - SEO metadata - Social sharing ## Metadata The metadata in `app/layout.tsx` automatically uses values from `appConfig`: ```typescript filename="app/layout.tsx" lineNumbers import { appConfig } from '@/config/app.config'; export const metadata: Metadata = { metadataBase: new URL(appConfig.baseUrl), title: { absolute: appConfig.appName, default: appConfig.appName, template: `%s | ${appConfig.appName}` }, description: appConfig.description, openGraph: { type: 'website', locale: 'en_US', siteName: appConfig.appName, title: appConfig.appName, description: appConfig.description }, twitter: { card: 'summary_large_image', title: appConfig.appName, description: appConfig.description } }; ``` ## Package.json Update the name and description in `package.json`: ```json filename="package.json" lineNumbers { "name": "your-app-name", "version": "1.0.0", "description": "Your app description", "author": "Your Name", "license": "MIT" // ... } ``` ## Environment Variables The `baseUrl` in `appConfig` uses `getBaseUrl()` which reads from `NEXT_PUBLIC_SITE_URL` if set, otherwise falls back to the request URL. You can set it in your `.env`: ```env filename=".env" lineNumbers NEXT_PUBLIC_SITE_URL=https://yourdomain.com ``` ## Email Branding Update email templates to reflect your branding. Email templates are located in `lib/email/templates/`: ```typescript filename="lib/email/templates/welcome-email.tsx" lineNumbers export function WelcomeEmail({ name }: { name: string }) { return ( Welcome to Your App Name! Welcome to Your App Name! Hi {name}, Welcome to Your App Name! We're excited to have you. ); } ``` --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/customization/overview **Description**: Learn how to customize your application to match your brand and requirements. Customization is essential for making the starter kit your own. This section covers everything you need to personalize your application, from branding and theming to fonts and icons. --- ## Theming & Styling **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/customization/theming **Description**: Learn how to customize colors, themes, and styling with Tailwind CSS and shadcn/ui. The starter kit uses [Tailwind CSS](https://tailwindcss.com) for styling and [shadcn/ui](https://ui.shadcn.com) components backed by [Base UI](https://base-ui.com/). This gives you complete control over the visual appearance of your application while keeping accessible interaction behavior in unstyled primitives. ## Color System The color system is based on CSS variables defined in `app/globals.css`: ```css filename="app/globals.css" lineNumbers :root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; --secondary: 210 40% 96.1%; --secondary-foreground: 222.2 47.4% 11.2%; --muted: 210 40% 96.1%; --muted-foreground: 215.4 16.3% 46.9%; --accent: 210 40% 96.1%; --accent-foreground: 222.2 47.4% 11.2%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 222.2 84% 4.9%; --radius: 0.5rem; } .dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; /* ... dark mode colors */ } ``` ## Tailwind Configuration The starter kit uses Tailwind CSS v4, which uses CSS-based configuration instead of a config file. All configuration is done in `app/globals.css`: ```css filename="app/globals.css" lineNumbers @import 'tailwindcss'; @import 'tw-animate-css'; /* Specify content paths */ @source "./**/*.{ts,tsx}"; @source "../components/**/*.{ts,tsx}"; @source "../lib/**/*.{ts,tsx}"; @source "../hooks/**/*.{ts,tsx}"; /* Custom dark mode variant */ @custom-variant dark (&:is(.dark *)); /* Define theme values */ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); } ``` To add new content paths, add more `@source` directives. To customize theme values, add them to the `@theme inline` block. ## shadcn/ui Components The starter kit uses the Base UI variant of [shadcn/ui](https://ui.shadcn.com). The source lives in `components/ui/`, so you can change the styles and composition directly. Base UI supplies the accessible behavior for dialogs, menus, selects, tooltips and other interactive primitives. The repository's `components.json` sets `base-nova` as its shadcn style. The CLI therefore installs compatible Base UI components instead of Radix variants. ### Installing Components You can install additional components using the shadcn CLI: ```bash filename="Terminal" lineNumbers npx shadcn@latest add button npx shadcn@latest add card npx shadcn@latest add dialog ``` ### Customizing Components Components are located in `components/ui/` and can be customized directly: ```tsx filename="components/ui/button.tsx" lineNumbers import type * as React from 'react'; import { Button as ButtonPrimitive } from '@base-ui/react/button'; export type ButtonProps = ButtonPrimitive.Props & { loading?: boolean; }; function Button({ children, loading = false, ...props }: ButtonProps) { return ( {loading ? 'Loading…' : children} ); } export { Button }; ``` Base UI uses the `render` prop for composition. When an installed component documents `render`, prefer it over Radix's former `asChild` pattern: ```tsx filename="components/example-dialog.tsx" lineNumbers }>Open dialog ``` ## Dark Mode Dark mode is automatically handled by the theme system. Users can toggle between light and dark themes: ```tsx filename="components/theme-toggle.tsx" lineNumbers 'use client'; import { useTheme } from 'next-themes'; import { Button } from '@/components/ui/button'; export function ThemeToggle() { const { theme, setTheme } = useTheme(); return ( ); } ``` ## Global Styles Customize global styles in `app/globals.css`: ```css filename="app/globals.css" lineNumbers @import 'tailwindcss'; @layer base { * { @apply border-border; } body { @apply bg-background text-foreground; } } ``` ## Custom Themes You can create custom themes by modifying the CSS variables: ```css filename="app/globals.css" lineNumbers [data-theme='custom'] { --primary: 142 76% 36%; --primary-foreground: 355 100% 97%; /* ... other custom colors */ } ``` Then apply the theme: ```tsx filename="app/layout.tsx" lineNumbers {/* ... */} ``` --- ## Database **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/database **Description**: Learn how to manage your database, schema, and migrations with Prisma ORM. The Pro Next.js Prisma starter kit uses **Prisma ORM** with **PostgreSQL**. Prisma provides a powerful and intuitive way to manage your database schema and interact with your data. ## Client Setup The Prisma client is initialized in `lib/db/prisma.ts` and exported from `lib/db/index.ts`. It uses the `pg` driver for optimized connections. ```typescript filename="lib/db/prisma.ts" lineNumbers import 'server-only'; import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from '@prisma/client'; import { Pool } from 'pg'; function createPrismaClient() { const connectionString = process.env.DATABASE_URL; if (!connectionString) { throw new Error('DATABASE_URL is required to initialize PrismaClient'); } const pool = new Pool({ connectionString }); return new PrismaClient({ adapter: new PrismaPg(pool) }); } export const prisma = createPrismaClient(); ``` The client is then exported from `lib/db/index.ts`: ```typescript filename="lib/db/index.ts" lineNumbers export * from './prisma'; ``` ## Schema Definition Your database schema is defined in `prisma/schema.prisma`. This file contains your models, enums, and relations. ### Example Model Definition ```prisma filename="prisma/schema.prisma" lineNumbers model Lead { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid organizationId String @map("organization_id") @db.Uuid firstName String @map("first_name") @db.Text lastName String @map("last_name") @db.Text email String @unique @db.Text createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) @@index([organizationId], map: "lead_organization_id_idx") @@map("lead") } ``` ## Migrations ### Commands | Command | Description | | --------------------- | --------------------------------------------- | | `npm run db:generate` | Generate Prisma Client from schema | | `npm run db:migrate` | Apply pending migrations | | `npm run db:studio` | Open Prisma Studio GUI | | `npm run db:push` | Push schema directly (dev only, no migration) | ### Migration Workflow 1. **Edit schema** in `prisma/schema.prisma`. 2. **Create migration**: `npm run db:migrate:dev -- --name your_migration_name`. 3. **Review migration** in `prisma/migrations/`. 4. **Deploy migration**: `npm run db:migrate` (production) or `npm run db:migrate:dev` (development). ## Multi-Tenancy **Critical**: Always filter by `organizationId` for tenant data to ensure data isolation. ```typescript filename="trpc/routers/lead-router.ts" lineNumbers const leads = await prisma.lead.findMany({ where: { organizationId: ctx.organization.id } }); ``` ## Transactions Use `$transaction` for related operations that must be atomic. ```typescript filename="lib/actions/widget.ts" lineNumbers const result = await prisma.$transaction(async (tx) => { await tx.subscriptionItem.deleteMany({ where: { subscriptionId: subId } }); await tx.subscriptionItem.createMany({ data: newItems }); return tx.subscriptionItem.findMany({ where: { subscriptionId: subId } }); }); ``` --- ## Client **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/database/client **Description**: Learn how to use basic database operations with the Prisma database client. The database client is an export of the Prisma Client from the `@prisma/client` package. Prisma generates this client automatically based on your defined schema. This guide outlines core operations with the database client, such as querying, creating, updating, and deleting records. For in-depth details and advanced usage, visit the [Prisma Client documentation](https://www.prisma.io/docs/orm/prisma-client/queries/crud). ## Query records To retrieve records from the database, you can use the `findMany` method provided by the Prisma client: ```typescript filename="query-records.ts" lineNumbers import { prisma } from '@/lib/db'; const users = await prisma.user.findMany({ orderBy: { createdAt: 'desc' } }); ``` You can also filter records using the `where` clause: ```typescript filename="query-filtered-records.ts" lineNumbers import { prisma } from '@/lib/db'; const user = await prisma.user.findUnique({ where: { email: 'user@example.com' } }); ``` To limit the number of results, use the `take` option: ```typescript filename="query-limited-records.ts" lineNumbers import { prisma } from '@/lib/db'; const recentUsers = await prisma.user.findMany({ orderBy: { createdAt: 'desc' }, take: 10 }); ``` ## Create record To insert a new record into the database, you can use the `create` method: ```typescript filename="create-record.ts" lineNumbers import { prisma } from '@/lib/db'; const user = await prisma.user.create({ data: { name: 'John Doe', email: 'john.doe@gmail.com' } }); ``` By default Prisma returns the full row. You can use `select` to make the `create` method more efficient. ## Update record To update an existing record, use the `update` method: ```typescript filename="update-record.ts" lineNumbers import { prisma } from '@/lib/db'; const updatedUser = await prisma.user.update({ where: { id: 'some-uuid' }, data: { name: 'John Doe Updated', email: 'john.doe.updated@gmail.com' } }); ``` ## Delete record To delete a record from the database, use the `delete` method: ```typescript filename="delete-record.ts" lineNumbers import { prisma } from '@/lib/db'; const deletedUser = await prisma.user.delete({ where: { id: 'some-uuid' } }); ``` This will remove the record from the database permanently. --- ## Migrations **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/database/migrations **Description**: Learn how to manage database migrations with Prisma. Migrations are a way to version control your database schema changes. They allow you to track, apply, and rollback database changes in a controlled and reproducible manner. ## Migration Workflow The typical migration workflow consists of three steps: ### 1. Create Migration After updating your `schema.prisma` file, create a migration: ```sh filename="Terminal" lineNumbers npx prisma migrate dev --name add_phone_field ``` This command: - Analyzes your `schema.prisma` file - Compares it with the current database state - Generates SQL migration files in `prisma/migrations/` - Applies the migration to your database - Regenerates Prisma Client automatically Migration Files Migration files are stored in prisma/migrations/ and should be committed to version control. Each migration has a unique name and contains the SQL statements needed to apply the changes. ### 2. Review Migration Before committing, review the generated migration file: ```sql filename="prisma/migrations/xxxx_add_phone_field/migration.sql" lineNumbers -- AlterTable ALTER TABLE "User" ADD COLUMN "phone" TEXT; ``` You can edit the migration file if needed, but be careful - only modify the SQL if you understand the implications. ### 3. Production Deployment For production deployments, use: ```sh filename="Terminal" lineNumbers npx prisma migrate deploy ``` This command: - Applies pending migrations to the production database - Does not generate new migrations - Does not regenerate Prisma Client (run `prisma generate` separately if needed) ## Migration Commands ### Create Migration (Development) Create a new migration and apply it immediately: ```sh filename="Terminal" lineNumbers npx prisma migrate dev --name migration_name ``` This is the recommended command for development. It: - Creates the migration - Applies it to your database - Regenerates Prisma Client ### Create Migration Without Applying Create a migration file without applying it: ```sh filename="Terminal" lineNumbers npx prisma migrate dev --create-only --name migration_name ``` This is useful when you want to review or modify the migration SQL before applying it. ### Apply Migrations (Production) Apply pending migrations to the database: ```sh filename="Terminal" lineNumbers npx prisma migrate deploy ``` This command: - Applies all pending migrations in order - Does not generate new migrations - Safe to run in production ### Push Changes (Development Only) For rapid prototyping, push schema changes directly without creating a migration: ```sh filename="Terminal" lineNumbers npx prisma db push ``` Warning db push is useful for development but should not be used in production. Always use migrations (migrate dev and{' '} migrate deploy) for production deployments. ### Reset Database Reset your database and apply all migrations from scratch: ```sh filename="Terminal" lineNumbers npx prisma migrate reset ``` Warning This will delete all data in your database. Only use this in development. ## Production Migrations For production deployments, follow these steps: 1. **Create migrations locally** - Run `npx prisma migrate dev --name migration_name` after schema changes 2. **Review migrations** - Check the generated SQL files in `prisma/migrations/` 3. **Test on staging** - Apply migrations to a staging database first using `npx prisma migrate deploy` 4. **Commit migrations** - Commit migration files to version control 5. **Deploy** - Run `npx prisma migrate deploy` as part of your deployment process Best Practice Always test migrations on a staging database that mirrors production before deploying to production. ## Migration Best Practices 1. **Always use migrations** for production deployments 2. **Review migration files** before committing them 3. **Test migrations** on a staging database first 4. **Commit migration files** to version control 5. **Never edit existing migrations** - create new ones instead 6. **Use descriptive migration names** - The migration name should describe what it does (e.g., `add_phone_to_user`) 7. **Keep migrations small** - Break large changes into multiple migrations 8. **Don't delete migrations** - Even if you rollback, keep the migration files ## Migration History Prisma tracks migration history in a special `_prisma_migrations` table. You can view which migrations have been applied by checking this table in your database. The migration history table stores: - Migration name - Applied timestamp - Migration checksum - Logs ## Troubleshooting ### Migration Fails If a migration fails: 1. **Check the error message** - It usually indicates what went wrong 2. **Review the migration SQL** - Ensure the SQL is correct 3. **Check database state** - Verify the current database schema 4. **Fix the migration** - Edit the migration file if needed 5. **Mark as applied** - If the migration was partially applied, you may need to mark it as applied manually 6. **Re-run** - Try applying the migration again ### Migration Already Applied If you see an error that a migration is already applied: 1. **Check migration history** - Query the `_prisma_migrations` table 2. **Resolve conflict** - Use `npx prisma migrate resolve --applied migration_name` if needed 3. **Continue** - Prisma will skip already applied migrations ### Schema Out of Sync If your `schema.prisma` doesn't match your database: 1. **Review schema file** - Ensure it's up to date 2. **Check migration history** - See which migrations have been applied 3. **Create new migration** - Run `npx prisma migrate dev --name sync_schema` to create a migration that brings the database in sync 4. **Apply migration** - The migration will be applied automatically ### Resolve Failed Migrations If a migration failed and you need to mark it as resolved: ```sh filename="Terminal" lineNumbers npx prisma migrate resolve --applied migration_name ``` Or mark it as rolled back: ```sh filename="Terminal" lineNumbers npx prisma migrate resolve --rolled-back migration_name ``` ## Advanced Topics ### Custom Migration SQL You can write custom SQL in migration files for complex changes: ```sql filename="prisma/migrations/xxxx_custom_migration/migration.sql" lineNumbers -- Custom migration SQL ALTER TABLE "User" ADD COLUMN "full_name" TEXT; UPDATE "User" SET "full_name" = "name" || ' ' || "last_name"; ``` ### Data Migrations Migrations can also include data transformations: ```sql filename="prisma/migrations/xxxx_data_migration/migration.sql" lineNumbers -- Data migration example UPDATE "User" SET "status" = 'active' WHERE "status" IS NULL; ``` ### Rollback Migrations While Prisma doesn't have built-in rollback support, you can: 1. **Create a new migration** - Write a migration that reverses the changes 2. **Manual rollback** - Manually revert the database changes 3. **Reset database** - Use `npx prisma migrate reset` (development only) 4. **Restore from backup** - Restore the database to a previous state ### Baseline Migrations If you have an existing database and want to start using Prisma migrations: 1. **Create initial migration** - Run `npx prisma migrate dev --name init` 2. **Mark as applied** - Use `npx prisma migrate resolve --applied init` to mark it as already applied 3. **Continue** - Future migrations will work normally ## Migration Files Structure Migration files are organized as follows: ```text filename="prisma/migrations/" lineNumbers prisma/migrations/ ├── 20240101000000_initial/ │ └── migration.sql ├── 20240102000000_add_users/ │ └── migration.sql └── 20240103000000_add_phone_field/ └── migration.sql ``` Each migration directory contains: - `migration.sql` - The SQL statements to apply - Optional migration metadata ## Generate Prisma Client After schema changes, regenerate the Prisma Client to update TypeScript types: ```sh filename="Terminal" lineNumbers npx prisma generate ``` Automatic Generation prisma migrate dev automatically runs{' '} prisma generate after applying migrations. You only need to run it manually when using db push or after pulling schema changes. --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/database/overview **Description**: Learn how to interact with the database in the starter kit. The database serves as the backbone for storing data, handling queries and making sure users get what they need fast. ## Prisma The starter kit uses Prisma as its data access solution. Why choose Prisma? Prisma is the most popular TypeScript ORM, providing excellent developer experience, type safety, and a powerful query API. It's battle-tested and widely adopted in the industry. ## Database driver The project uses PostgreSQL as the default database provider, ensuring seamless integration. Prisma also supports MySQL, SQLite, SQL Server, and MongoDB. For a comprehensive list of supported database drivers, visit [Prisma's Documentation](https://www.prisma.io/docs/orm/core-concepts/supported-databases). ## Prisma Studio Prisma's visual database editor allows you to view and edit your database records. You can open it with: ```sh filename="Terminal" lineNumbers npx prisma studio ``` Make sure `.env` has a correct `DATABASE_URL` defined. --- ## Schema **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/database/schema **Description**: Learn how to update your database schema and migrate changes with Prisma. The database schema is defined in `prisma/schema.prisma`. This schema file uses Prisma's schema definition language (PSL) to describe your database tables, relationships, and types. ## Schema Structure The `schema.prisma` file contains: - **Data source**: Database connection configuration - **Generator**: Prisma Client configuration - **Models**: Database table definitions with relationships ```prisma filename="prisma/schema.prisma" lineNumbers datasource db { provider = "postgresql" url = env("DATABASE_URL") } generator client { provider = "prisma-client-js" } model User { id String @id @default(uuid()) email String @unique name String? // ... other fields } ``` ## Updating the Schema To update your database schema, edit the `schema.prisma` file. More information about the Prisma schema can be found in the [Prisma documentation](https://www.prisma.io/docs/orm/prisma-schema/overview). ### Example: Adding a Field For example, to add a new `phone` field to the `User` model: ```prisma filename="prisma/schema.prisma" lineNumbers model User { id String @id @default(uuid()) email String @unique name String? phone String? // New field // ... other fields } ``` The field is defined as an optional string. Now you need to create a migration to apply this change to the database. ## Migration Workflow Prisma uses migrations to track and apply database schema changes. The migration workflow consists of three steps: ### 1. Create Migration Create a new migration by running: ```sh filename="Terminal" lineNumbers npx prisma migrate dev --name add_phone_field ``` This command: - Analyzes your `schema.prisma` file - Compares it with the current database state - Generates SQL migration files in `prisma/migrations/` - Applies the migration to your database - Regenerates Prisma Client automatically Migration Files Migration files are stored in prisma/migrations/ and should be committed to version control. Each migration has a unique name and contains the SQL statements needed to apply the changes. ### 2. Review Migration Before committing, review the generated migration file: ```sql filename="prisma/migrations/xxxx_add_phone_field/migration.sql" lineNumbers -- AlterTable ALTER TABLE "User" ADD COLUMN "phone" TEXT; ``` ### 3. Production Deployment For production deployments, use: ```sh filename="Terminal" lineNumbers npx prisma migrate deploy ``` This command: - Applies pending migrations to the production database - Does not generate new migrations - Does not regenerate Prisma Client (run `prisma generate` separately if needed) ## Alternative: Push Changes (Development Only) For rapid prototyping during development, you can push schema changes directly without creating a migration: ```sh filename="Terminal" lineNumbers npx prisma db push ``` Warning db push is useful for development but should not be used in production. Always use migrations (migrate dev and{' '} migrate deploy) for production deployments. ## Generate Prisma Client After schema changes, regenerate the Prisma Client to update TypeScript types: ```sh filename="Terminal" lineNumbers npx prisma generate ``` Automatic Generation prisma migrate dev automatically runs{' '} prisma generate after applying migrations. You only need to run it manually when using db push or after pulling schema changes. ## Migration Best Practices 1. **Always use migrations** for production deployments 2. **Review migration files** before committing them 3. **Test migrations** on a staging database first 4. **Commit migration files** to version control 5. **Never edit existing migrations** - create new ones instead 6. **Use descriptive migration names** (e.g., `add_phone_to_user`) ## Schema Relationships Prisma supports defining relationships between models. For example: ```prisma filename="prisma/schema.prisma" lineNumbers model User { id String @id @default(uuid()) email String @unique organizations Organization[] } model Organization { id String @id @default(uuid()) name String ownerId String owner User @relation(fields: [ownerId], references: [id]) } ``` For more information on relationships, see the [Prisma relations documentation](https://www.prisma.io/docs/orm/prisma-schema/data-model/relations). ## Creating Migrations Without Applying To create a migration file without applying it to the database: ```sh filename="Terminal" lineNumbers npx prisma migrate dev --create-only --name migration_name ``` This is useful when you want to review or modify the migration SQL before applying it. --- ## Studio **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/database/studio **Description**: Learn how to use Prisma Studio to view and interact with your database. Prisma Studio is a visual database editor that allows you to view and edit your database records directly in your browser. ## Start Prisma Studio To start Prisma Studio, run the following command from the root of your project: ```sh filename="Terminal" lineNumbers npx prisma studio ``` Prisma Studio will open at http://localhost:5555 Make sure your `DATABASE_URL` is correctly set in your `.env` file before starting Prisma Studio. ## Using Prisma Studio Prisma Studio allows you to: - **View all your database tables and data** - Browse through all models and see their records - **Edit records directly in the browser** - Update, create, or delete records without writing SQL - **Run queries and see results** - Execute queries and view results - **Inspect your Prisma schema** - See the structure of your models, fields, and relationships ## Features ### Browse Models Navigate through all your Prisma models using the sidebar. Click on any model to view its data. ### Edit Records - **Add new records** - Click the "Add record" button to create new records - **Edit existing records** - Click on any field to edit its value - **Delete records** - Select records and delete them using the delete button ### View Relationships Prisma Studio shows relationships between models: - **One-to-many** - See related records in nested views - **Many-to-many** - Manage junction table records - **One-to-one** - View linked records ### Filter and Search Use the search and filter features to: - Find specific records - Filter by field values - Sort records by any field ## Alternative Database Tools While Prisma Studio is convenient, you can also use other database GUI tools: - [TablePlus](https://tableplus.com/) - Modern database management tool - [DBeaver](https://dbeaver.io/) - Universal database tool - [pgAdmin](https://www.pgadmin.org/) - PostgreSQL administration tool - [Postico](https://eggerapps.at/postico2/) - PostgreSQL client for macOS ## Troubleshooting ### Studio won't start If Prisma Studio won't start, check: 1. **Database connection** - Ensure `DATABASE_URL` is set correctly in `.env` 2. **Port availability** - Make sure port `5555` is not already in use 3. **Database running** - Verify your database server is running 4. **Prisma Client** - Run `npx prisma generate` if you get client errors ### Can't see models If you can't see your models in Prisma Studio: 1. **Check schema** - Ensure your `schema.prisma` file is correct 2. **Run migrations** - Make sure all migrations have been applied 3. **Generate client** - Run `npx prisma generate` to regenerate the client 4. **Refresh** - Try refreshing the browser --- ## Deployment **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/deployment **Description**: Learn how to deploy your Pro Next.js Prisma application to production. We recommend deploying your application to **Vercel** for the most direct Next.js workflow, but you can use any provider that supports Node.js and PostgreSQL. ## Prerequisites Before deploying, ensure you have: - A GitHub repository with your project code. - A PostgreSQL database (Neon, Supabase, Railway, etc.). - The provider accounts required by the features you keep, such as Stripe, Resend or an S3-compatible storage service. - A unique production authentication secret. ## Deploying to Vercel 1. Push your code to a GitHub repository. 2. Import the project into [Vercel](https://vercel.com). 3. Add the required environment variables for Production. Add them to Preview only when preview deployments should connect to separate preview services. 4. Apply the committed database migrations once from CI or a one-off release task. 5. Deploy the same commit that you validated locally. ### Environment Variables Start with the complete [environment variable guide](/docs/starter-kits/pro-nextjs-prisma/codebase/environment-variables). The minimum application and database values are: ```bash filename="Vercel Settings" lineNumbers BETTER_AUTH_SECRET="generate-a-unique-production-secret" DATABASE_URL="postgresql://user:pass@host:5432/db?sslmode=require" NEXT_PUBLIC_SITE_URL="https://your-app.com" ``` Add provider variables only for the integrations you enable. Never copy live secrets into variables prefixed with `NEXT_PUBLIC_`. ## Database Migrations Both kits expose the same production migration command: ```sh filename="Terminal" lineNumbers npm run db:migrate ``` Run it once against the production `DATABASE_URL` before the new application revision receives traffic. A CI release job or a one-off task on your hosting provider is suitable. Do not add migrations to `npm run build` and do not run them from every application replica at startup. Keep migration files in version control and review them with the code that depends on the schema change. Use development migration commands only while authoring a migration locally. ## SSL and Database Connections Most production database providers (like Neon or Supabase) require SSL. Ensure your `DATABASE_URL` includes `?sslmode=require`. ```ini filename=".env" lineNumbers DATABASE_URL="postgresql://user:pass@ep-xxx.region.aws.neon.tech/neondb?sslmode=require" ``` ## Post-Deployment Checklist Treat the first production deployment as a release, not only a successful build. Complete each applicable check before sending customers to the app. ### Application and database - [ ] Run `npm run typecheck`, `npm run lint`, `npm run test:unit -- --run` and `npm run build` against the release commit. The explicit `--run` keeps Vitest non-interactive on a developer machine and in CI. - [ ] Run production migrations once and confirm the expected schema exists. - [ ] Confirm the production database has backups and a tested restore procedure. - [ ] Verify `NEXT_PUBLIC_SITE_URL` exactly matches the canonical production origin. - [ ] Verify the custom domain, HTTPS certificate, redirects, `robots.txt` and sitemap. ### Authentication and email - [ ] Create a new account and complete email verification on the production domain. - [ ] Complete password reset and confirm its link returns to the production app. - [ ] Update Google OAuth origins and callback URLs for the production domain. - [ ] Confirm `EMAIL_FROM` uses a verified domain and replies go to a monitored address. - [ ] Test organization invitations with a second email address. ### Billing - [ ] Replace every Stripe test key and Price ID with its live-mode value. - [ ] Configure the production webhook endpoint at `https://your-domain.com/api/webhooks/stripe`. - [ ] Subscribe the endpoint only to events handled by the shipped webhook route. - [ ] Complete a real or controlled live-mode purchase, then verify the local order or subscription state. - [ ] Open the customer portal and verify cancellation or plan-change behavior for your product. ### Storage, monitoring and operations - [ ] Restrict storage credentials to the required bucket and object operations. - [ ] Upload and display an avatar or organization logo from the production domain. - [ ] Confirm Sentry receives a controlled test error without exposing secrets or personal data. - [ ] Confirm production logs use the intended level and do not contain credentials or tokens. - [ ] Add uptime monitoring for the application and any business-critical webhook path. - [ ] Document who receives billing, authentication and infrastructure alerts. Features you have disabled do not need their provider checks. Do not configure production credentials for integrations the application does not use. --- ## Docker **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/deployment/docker **Description**: Learn how to deploy your application as a Docker container. Deploying your application as a Docker container gives you control over the server environment, better privacy, potential cost savings, and flexibility to customize your setup. It can also improve performance compared to serverless platforms by removing cold starts. ## Setup Next.js for Docker Deployment Configure Next.js to build as a standalone app for containerization. Update your `next.config.ts`: ```typescript filename="next.config.ts" lineNumbers import type { NextConfig } from 'next'; const nextConfig: NextConfig = { // ... other config output: 'standalone' }; export default nextConfig; ``` ## Create Dockerfile Create a `Dockerfile` in the root of your project: ```dockerfile filename="Dockerfile" lineNumbers FROM node:22.21.1-alpine AS base # Install dependencies only when needed FROM base AS deps RUN apk add --no-cache libc6-compat WORKDIR /app # Copy package files COPY package.json package-lock.json* ./ RUN npm ci # Rebuild the source code only when needed FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . # Generate Prisma Client RUN npx prisma generate # Build the application RUN npm run build # Production image, copy all the files and run next FROM base AS runner WORKDIR /app ENV NODE_ENV production RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs # Copy the standalone build COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma USER nextjs EXPOSE 3000 ENV PORT 3000 ENV HOSTNAME "0.0.0.0" CMD ["node", "server.js"] ``` ## Create .dockerignore Create a `.dockerignore` file in the root: ```text filename=".dockerignore" lineNumbers Dockerfile .dockerignore node_modules npm-debug.log README.md .next .git .env*.local ``` ## Build and Run Locally Test your Docker image locally: ```sh filename="Terminal" lineNumbers docker build -t my-app . docker run -p 3000:3000 --env-file .env my-app ``` ## Deploy to Any Platform You can now deploy this Docker image to any platform that supports Docker: - **Fly.io** - See [Fly.io deployment guide](/docs/starter-kits/pro-nextjs-prisma/deployment/fly) - **Railway** - See [Railway deployment guide](/docs/starter-kits/pro-nextjs-prisma/deployment/railway) - **Render** - See [Render deployment guide](/docs/starter-kits/pro-nextjs-prisma/deployment/render) - **AWS ECS/Fargate** - Use AWS container services - **Google Cloud Run** - Serverless container platform - **DigitalOcean App Platform** - Managed container hosting - **Your own server** - Deploy to any VPS with Docker ## Environment Variables Make sure to set all required environment variables when running the container: ```sh filename="Terminal" lineNumbers docker run -p 3000:3000 \ -e DATABASE_URL="postgresql://..." \ -e BETTER_AUTH_SECRET="..." \ -e NEXT_PUBLIC_SITE_URL="https://your-app.com" \ my-app ``` Or use an environment file: ```sh filename="Terminal" lineNumbers docker run -p 3000:3000 --env-file .env.production my-app ``` ## Database Migrations Create and review migrations during development: ```sh filename="Terminal" lineNumbers npm run db:migrate:dev -- --name describe_the_change ``` Commit the generated files in `prisma/migrations/` with the schema change. In production, apply those committed migrations once as a release step: ```sh filename="Terminal" lineNumbers npm run db:migrate ``` The production command maps to `prisma migrate deploy`. Run it in CI before replacing the application containers or use a one-off migration task provided by your container platform. Wait for it to succeed before directing traffic to the new revision. Keep migrations out of the image buildA Docker build should not connect to or mutate a production database. Avoid running migrations in the Dockerfile. Also avoid starting the migration command independently in every application replica because several containers may start at the same time. ## Troubleshooting ### SSL Errors If you encounter SSL errors like `ERR_SSL_PACKET_LENGTH_TOO_LONG`, ensure your `DATABASE_URL` includes SSL parameters: ```ini filename=".env" lineNumbers DATABASE_URL="postgresql://user:pass@host:5432/db?sslmode=require" ``` ### Port Configuration Make sure the port in your Dockerfile matches your Next.js configuration and the port you expose when running the container. ### Build Failures If the build fails, check: - Node.js version is 22.21.1, matching `package.json` - All dependencies are properly installed - Prisma Client generation completes successfully - Every environment variable required by the application build is available - `output: 'standalone'` remains enabled in `next.config.ts` For the complete container workflow, including runtime secrets, health checks and reverse proxies, read [Self-Host a Next.js SaaS With Docker](/blog/self-host-nextjs-saas-with-docker). --- ## Fly.io **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/deployment/fly **Description**: Learn how to deploy your application to Fly.io. [Fly.io](https://fly.io) is a platform for running full-stack apps and databases close to your users. It is well suited to Docker-based deployments. Review [Fly.io pricing](https://fly.io/docs/about/pricing/) before provisioning resources. ## Why Fly.io? - **Global edge network** - Deploy close to your users - **Docker-based** - Full control over your container - **Usage-based pricing** - Choose resources for your application's requirements - **Simple scaling** - Scale up or down easily - **Database support** - Can provision PostgreSQL ## Prerequisites Before deploying to Fly.io, you need to: 1. **Set up Docker** - Follow the [Docker deployment guide](/docs/starter-kits/pro-nextjs-prisma/deployment/docker) to create a Dockerfile 2. **Install Fly CLI** - Install the [Fly CLI](https://fly.io/docs/flyctl/install/) ## Deploying to Fly.io ### 1. Create Fly.io Account Sign up for a free account at [fly.io](https://fly.io). ### 2. Login to Fly CLI ```sh filename="Terminal" lineNumbers fly auth login ``` ### 3. Launch Your App From your project root, run: ```sh filename="Terminal" lineNumbers fly launch ``` The CLI will: - Detect your Dockerfile - Ask for an app name - Ask if you want to set up a PostgreSQL database - Create a `fly.toml` configuration file ### 4. Configure fly.toml The generated `fly.toml` should look like this: ```toml filename="fly.toml" lineNumbers app = "your-app-name" primary_region = "iad" [build] [env] PORT = "3000" [http_service] internal_port = 3000 force_https = true auto_stop_machines = true auto_start_machines = true min_machines_running = 0 processes = ["app"] [[vm]] memory = "256mb" cpu_kind = "shared" cpus = 1 ``` ### 5. Set Environment Variables Set your environment variables: ```sh filename="Terminal" lineNumbers fly secrets set DATABASE_URL="postgresql://..." fly secrets set BETTER_AUTH_SECRET="..." fly secrets set NEXT_PUBLIC_SITE_URL="https://your-app.fly.dev" fly secrets set STRIPE_SECRET_KEY="..." fly secrets set RESEND_API_KEY="..." ``` Or set multiple at once: ```sh filename="Terminal" lineNumbers fly secrets set DATABASE_URL="..." BETTER_AUTH_SECRET="..." NEXT_PUBLIC_SITE_URL="..." ``` ### 6. Deploy Deploy your application: ```sh filename="Terminal" lineNumbers fly deploy ``` Your app will be available at `https://your-app.fly.dev`. ## Database Migrations Run migrations after deployment: ```sh filename="Terminal" lineNumbers fly ssh console -C "npm run db:migrate" ``` Or add to your Dockerfile's entrypoint script. ## Provision Database If you didn't provision a database during `fly launch`: ```sh filename="Terminal" lineNumbers fly postgres create --name your-app-db fly postgres attach your-app-db ``` This will automatically set the `DATABASE_URL` secret. ## Custom Domain To use a custom domain: 1. Add your domain: ```sh filename="Terminal" lineNumbers fly domains add your-domain.com ``` 2. Follow DNS configuration instructions 3. Update `NEXT_PUBLIC_SITE_URL` secret ## Scaling Scale your app: ```sh filename="Terminal" lineNumbers # Scale to 2 instances fly scale count 2 # Scale memory fly scale vm shared-cpu-1x --memory 512 ``` ## Monitoring View logs and metrics: ```sh filename="Terminal" lineNumbers # View logs fly logs # View metrics fly status ``` ## Troubleshooting ### SSL Errors If you encounter SSL errors, ensure your `DATABASE_URL` includes SSL parameters: ```ini filename=".env" lineNumbers DATABASE_URL="postgresql://user:pass@host:5432/db?sslmode=require" ``` ### Build Failures - Check build logs: `fly logs` - Verify Dockerfile is correct - Ensure all dependencies are installed - Make sure Prisma Client is generated ### Database Connection - Verify `DATABASE_URL` secret is set: `fly secrets list` - Check database is attached: `fly postgres list` - Ensure database is in the same region --- ## Netlify **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/deployment/netlify **Description**: Learn how to deploy your application to Netlify. [Netlify](https://www.netlify.com/) is a popular platform for deploying web applications. While it's optimized for static sites, you can deploy Next.js applications using Netlify's Next.js runtime. ## Why Netlify? - **Easy deployment** - Connect GitHub and deploy automatically - **Free tier available** - Great for getting started - **Automatic HTTPS** - SSL certificates included - **Edge functions** - Run serverless functions at the edge - **Preview deployments** - Automatic previews for PRs ## Deploying to Netlify ### 1. Create Netlify Account Sign up for a free account at [netlify.com](https://www.netlify.com/). ### 2. Create New Site 1. Click **"Add new site"** → **"Import an existing project"** 2. Connect your Git provider (GitHub, GitLab, or Bitbucket) 3. Select your repository ### 3. Configure Build Settings Netlify will auto-detect Next.js, but verify these settings: - **Build command**: `npm run build` - **Publish directory**: `.next` - **Framework preset**: Next.js For Next.js with Prisma, the build command already includes Prisma generation: - **Build command**: `npm run build` (includes `prisma generate`) ### 4. Add Environment Variables Add the deployment variables. `DATABASE_URL` and `BETTER_AUTH_SECRET` are required. Add the Stripe and Resend variables when those features are enabled: 1. Go to **Site settings** → **Environment variables** 2. Add variables from your `.env`: ```env filename="Netlify Environment Variables" lineNumbers DATABASE_URL=postgresql://... BETTER_AUTH_SECRET=... NEXT_PUBLIC_SITE_URL=https://your-app.netlify.app STRIPE_SECRET_KEY=... NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=... STRIPE_WEBHOOK_SECRET=... NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY=price_... NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=price_... NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PRO=price_... RESEND_API_KEY=... EMAIL_FROM=... ``` ### 5. Deploy Click **"Deploy site"** and Netlify will: - Install dependencies - Generate Prisma Client - Build your application - Deploy to their CDN Your app will be available at `https://your-app.netlify.app`. ## Netlify Configuration Create a `netlify.toml` in your project root: ```toml filename="netlify.toml" lineNumbers [build] command = "npm run build" publish = ".next" [build.environment] NODE_VERSION = "22.21.1" [[plugins]] package = "@netlify/plugin-nextjs" ``` ## Database Migrations Netlify doesn't support running migrations during build. You have a few options: 1. **Run migrations manually** before deploying 2. **Use a build plugin** to run migrations 3. **Run migrations via API route** (not recommended for production) ## Custom Domain To use a custom domain: 1. Go to **Domain settings** → **Add custom domain** 2. Follow DNS configuration instructions 3. Update `NEXT_PUBLIC_SITE_URL` environment variable ## Functions Region For better performance, select the region closest to your database: 1. Go to **Site configuration** → **Build & deploy** → **Functions** 2. Select the **Functions region** closest to your database 3. Redeploy your site ## Preview Deployments Netlify automatically creates preview deployments for: - Pull requests - Branch pushes - Merge commits Each preview gets its own URL for testing. ## Environment Variables by Context Netlify supports different environment variables for: - **Production** - Production deployments - **Deploy previews** - Preview deployments - **Branch deploys** - Branch-specific deployments ## Troubleshooting ### Build Failures - Check build logs in the Netlify dashboard - Verify Node.js version (set in `netlify.toml`) - Ensure all dependencies are in `package.json` - Make sure Prisma Client is generated: `npx prisma generate` ### Function Timeouts - Netlify Functions have a 10-second timeout on free tier - Upgrade to Pro for longer timeouts - Optimize your API routes ### Database Connection Issues - Verify `DATABASE_URL` is set correctly - Check if database requires SSL (add `?sslmode=require`) - Ensure database allows connections from Netlify's IPs ### Environment Variables - Verify variables are set in the correct context - Redeploy after adding new variables - Check for typos in variable names --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/deployment/overview **Description**: Learn how to deploy your applications. You can deploy the app to any hosting provider that supports Node.js. Since Next.js is developed by Vercel, deploying to Vercel offers the most seamless and optimized developer experience. ## Choose a hosting model The starter kit ships as one Next.js service. It does not include a separately deployed API server or persistent worker process. | Model | Good fit | You operate | | ----------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------ | | Vercel or another serverless Next.js host | Automatic previews, managed scaling and the smallest operations surface | Environment variables, database migrations and provider configuration | | A managed container platform | A portable image, longer-running requests and more runtime control | Image builds, health checks, scaling and release migrations | | A self-hosted container or VPS | Infrastructure control and predictable host resources | TLS, reverse proxy, patching, restarts, monitoring, backups and capacity | Choose based on the operations you are prepared to own, not only the initial deployment cost. If the product needs durable background work, use a managed background provider or operate a separate worker service. Do not depend on a Next.js web instance remaining alive after an HTTP response. Apply committed database migrations once from CI or a one-off release task. Starting multiple web replicas must not race to apply the same migration. ## Production checklist Complete these steps before sending production traffic to a new environment: 1. Create an empty PostgreSQL database and set its production `DATABASE_URL`. 2. Add the required server and browser variables from the [environment variable guide](/docs/starter-kits/pro-nextjs-prisma/codebase/environment-variables). 3. Replace the development `BETTER_AUTH_SECRET` with a unique production value. 4. Add `https://yourdomain.com/api/auth/callback/google` to the Google OAuth client if Google sign-in is enabled. 5. Configure the Stripe webhook endpoint at `https://yourdomain.com/api/webhooks/stripe` if billing is enabled. 6. Apply the committed database migrations once as a release step: ```sh filename="Terminal" lineNumbers npm run db:migrate ``` 7. Build the same revision that will be deployed: ```sh filename="Terminal" lineNumbers npm run build ``` 8. Verify sign-in, email delivery, organization access and one billing flow in the deployed environment before announcing the release. ## Public launch checklist A successful build only proves that the application compiled. Complete this second pass before directing customers to it: - Connect the final domain, set `NEXT_PUBLIC_SITE_URL` to its HTTPS URL and redeploy so generated links and authentication callbacks use that origin. - Replace the starter name, logo, contact details, legal text and sample marketing content with your own product information. - Verify the sender domain and review the authentication and invitation email templates using real inboxes outside your company domain. - Enable automated database backups and perform a restore rehearsal before the database contains customer data. - Configure provider budgets and alerts for every usage-based service, including OpenAI, email, storage and monitoring. - Test a failed payment, canceled subscription and Stripe webhook retry in test mode, not only a successful checkout. - Confirm that a non-admin user cannot open admin routes or another organization's resources. - Check the privacy policy and terms against the data and providers your deployed product actually uses. Give preview deployments their own database and provider credentials. Never point an untrusted branch or pull request at the production database, Stripe account or billable AI project. ## Control third-party spend Treat every server-side provider key as access to a billable account. Use a separate provider project or account for each environment so a development or demo incident cannot consume the production budget. For the included AI chat: 1. Set an OpenAI project budget and provider-side usage alerts before adding `OPENAI_API_KEY` to production. 2. Keep model selection restricted to the allowlist in `config/billing.config.ts`. 3. Configure application credits deliberately. Organization credits limit what the product permits, but they do not replace the OpenAI project budget. 4. Do not fund an unrestricted key for an anonymous public demo. Disable live generation or add a durable per-user and per-IP limiter first. 5. Monitor provider usage after launch and keep a documented way to revoke the key quickly. The starter kits check organization credit balance before AI generation and deduct actual usage afterward. They do not ship a generic distributed request-frequency limiter. Add one backed by shared durable storage before exposing a billable endpoint to untrusted traffic. Run the migration command once in CI or as a one-off release task. Do not run it independently from every application container when several replicas may start at the same time. To learn more about deployment, explore the following guide: --- ## Railway **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/deployment/railway **Description**: Learn how to deploy your application to Railway. [Railway](https://railway.com/) is a modern platform that makes it easy to deploy full-stack applications. It offers a generous free tier and seamless integration with GitHub. ## Why Railway? - **Simple deployment** - Connect your GitHub repo and deploy in minutes - **Free tier available** - Great for testing and MVPs - **Automatic HTTPS** - SSL certificates handled automatically - **Database included** - Can provision PostgreSQL directly - **Environment variables** - Easy management through the dashboard ## Deploying to Railway ### 1. Create Railway Account Sign up for a free account at [railway.com](https://railway.com/). ### 2. Create New Project 1. Click **"New Project"** in the Railway dashboard 2. Select **"Deploy from GitHub repo"** 3. Connect your GitHub account if prompted 4. Select your repository ### 3. Configure Build Settings Railway will auto-detect Next.js, but you can verify these settings: - **Build Command**: `npm run build` - **Start Command**: `npm start` - **Root Directory**: `/` (root of your project) ### 4. Add Environment Variables Add the deployment variables in the Railway dashboard. `DATABASE_URL` and `BETTER_AUTH_SECRET` are required. Add the Stripe and Resend variables when those features are enabled: 1. Go to your project → **Variables** tab 2. Add variables from your `.env`: ```env filename="Railway Environment Variables" lineNumbers DATABASE_URL=postgresql://... BETTER_AUTH_SECRET=... NEXT_PUBLIC_SITE_URL=https://your-app.railway.app STRIPE_SECRET_KEY=... NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=... STRIPE_WEBHOOK_SECRET=... NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY=price_... NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=price_... NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PRO=price_... RESEND_API_KEY=... EMAIL_FROM=... ``` ### 5. Provision Database (Optional) Railway can provision a PostgreSQL database for you: 1. Click **"New"** → **"Database"** → **"Add PostgreSQL"** 2. Railway will automatically set the `DATABASE_URL` environment variable 3. Configure the pre-deploy migration command before releasing the web service ### 6. Deploy Railway will automatically: - Install dependencies - Build your application - Deploy to their infrastructure Your app will be available at `https://your-app.railway.app`. ## Database Migrations Keep the build command as `npm run build`. In the web service settings, set the **Pre-Deploy Command** to: ```sh filename="Railway Pre-Deploy Command" lineNumbers npm run db:migrate ``` Railway runs this command in a separate container after the build and before the new deployment starts. The command receives the service environment variables, including `DATABASE_URL`. A non-zero exit stops the deployment. For a controlled one-off migration, you can run the same script from a trusted local checkout with Railway's production variables: ```sh filename="Terminal" lineNumbers railway run npm run db:migrate ``` Do not append migrations to `npm run build` and do not run them from every web replica at startup. ## Custom Domain To use a custom domain: 1. Go to **Settings** → **Domains** 2. Click **"Add Domain"** 3. Follow the DNS configuration instructions 4. Update `NEXT_PUBLIC_SITE_URL` to your custom domain ## Environment-Specific Variables Railway supports environment-specific variables: - **Production** - Used for production deployments - **Preview** - Used for preview deployments (from PRs) - **Development** - Used for local development with Railway CLI ## Monitoring Railway provides: - **Logs** - View real-time application logs - **Metrics** - CPU, memory, and network usage - **Deployments** - View deployment history ## Troubleshooting ### Build Failures - Check build logs in the Railway dashboard - Ensure all dependencies are in `package.json` - Verify Node.js 22.21.1 is active, matching the version in `package.json` - Make sure Prisma Client is generated: `npx prisma generate` ### Database Connection Issues - Verify `DATABASE_URL` is set correctly - Check if database requires SSL (add `?sslmode=require`) - Ensure database is accessible from Railway's IPs ### Environment Variables Not Loading - Verify variables are set in the correct environment - Check for typos in variable names - Redeploy after adding new variables --- ## Render **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/deployment/render **Description**: Learn how to deploy your application to Render. [Render](https://render.com) is a managed cloud platform for deploying web services and PostgreSQL databases from a Git repository. ## Why Render? - **Git-based deployment** - Build automatically from your production branch - **Managed HTTPS** - Connect a custom domain with managed TLS - **Database support** - Provision PostgreSQL or connect an external database - **Release controls** - Run migrations with a pre-deploy command on supported plans ## Deploying to Render ### 1. Create Render Account Sign up for a free account at [render.com](https://render.com). ### 2. Create New Web Service 1. Click **"New +"** in the Render dashboard 2. Select **"Web Service"** 3. Choose **"Build and deploy from a Git repository"** 4. Connect your GitHub account if prompted 5. Select your repository ### 3. Configure Service Set the following configuration: - **Name**: Your application name - **Region**: Choose closest to your users - **Branch**: `main` or your production branch - **Root Directory**: `/` (leave empty if root) - **Runtime**: `Node` - **Build Command**: `npm run build` - **Start Command**: `npm start` ### 4. Add Environment Variables Add the deployment variables. `DATABASE_URL` and `BETTER_AUTH_SECRET` are required. Add the Stripe and Resend variables when those features are enabled: 1. Scroll to **"Environment Variables"** section 2. Add variables from your `.env`: ```env filename="Render Environment Variables" lineNumbers DATABASE_URL=postgresql://... BETTER_AUTH_SECRET=... NEXT_PUBLIC_SITE_URL=https://your-app.onrender.com STRIPE_SECRET_KEY=... NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=... STRIPE_WEBHOOK_SECRET=... NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY=price_... NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=price_... NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PRO=price_... RESEND_API_KEY=... EMAIL_FROM=... ``` ### 5. Select Plan Choose a plan that supports the uptime, compute and deployment features your application needs. A testing plan can be suitable for evaluation, but use an always-on service for production and confirm that your selected plan supports the pre-deploy migration command described below. ### 6. Deploy Click **"Create Web Service"** and Render will: - Install dependencies - Generate Prisma Client - Build your application - Deploy to their infrastructure Your app will be available at `https://your-app.onrender.com`. ## Database Migrations Keep the build command as `npm run build`. On a paid Render service, set the **Pre-Deploy Command** to: ```sh filename="Render Pre-Deploy Command" lineNumbers npm run db:migrate ``` Render runs this after a successful build and before the new revision goes live. If your plan does not support pre-deploy commands, run the same command once from a trusted release environment against the production `DATABASE_URL` before deploying the application revision. Do not use `npm run db:push` in production and do not run migrations from every web-service replica at startup. ## Provision Database (Optional) Render can provision a PostgreSQL database: 1. Click **"New +"** → **"PostgreSQL"** 2. Configure database settings 3. Render will automatically set `DATABASE_URL` 4. Link the database to your web service ## Custom Domain To use a custom domain: 1. Go to **Settings** → **Custom Domains** 2. Add your domain 3. Follow DNS configuration instructions 4. Update `NEXT_PUBLIC_SITE_URL` to your custom domain ## Auto-Deploy Render automatically deploys when you push to your connected branch. You can: - Enable/disable auto-deploy in settings - Set up manual deploys - Configure deploy hooks ## Monitoring Render provides: - **Logs** - Real-time application logs - **Metrics** - CPU, memory usage - **Events** - Deployment history ## Troubleshooting ### Service Sleeping (Free Tier) The free tier service sleeps after 15 minutes of inactivity. To prevent this: - Upgrade to a paid plan - Use a service like [UptimeRobot](https://uptimerobot.com) to ping your app ### Build Failures - Check build logs in the Render dashboard - Verify Node.js 22.21.1 is selected, matching the version in `package.json` - Ensure all dependencies are in `package.json` - Make sure Prisma Client is generated: `npx prisma generate` ### Database Connection Issues - Verify `DATABASE_URL` is set correctly - Check if database requires SSL (add `?sslmode=require`) - Ensure database and web service are in the same region ### Environment Variables - Verify variables are set correctly - Redeploy after adding new variables - Check for typos in variable names --- ## Vercel **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/deployment/vercel **Description**: Learn how to deploy on Vercel. Deploy the Pro Next.js Prisma starter kit as a standard Next.js project on [Vercel](https://vercel.com). The repository is a single application, so it does not require monorepo root-directory or custom framework settings. ## Deploying to Vercel Vercel is the easiest way to deploy Next.js apps. It's the company behind Next.js and has first-class support for Next.js. ### Setup Vercel account To host your project on Vercel you first have to [create an account](https://vercel.com/signup). ### Connect your git repository After signing up you will be prompted to import a git repository. Select the git provider of your project and connect your git account with Vercel. Now you will see a list of all your projects. Select the project you want to deploy and click on the **Import** button. ### Configure project In the **Configure Project** view expand the **Environment Variables** section and add the following variables one by one (you can copy them from the `.env` file in your projects root too): ```env filename="Vercel Environment Variables" lineNumbers NEXT_PUBLIC_SITE_URL= DATABASE_URL= BETTER_AUTH_SECRET= STRIPE_SECRET_KEY= NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= STRIPE_WEBHOOK_SECRET= NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY= NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY= NEXT_PUBLIC_STRIPE_PRICE_LIFETIME= NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER= NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC= NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PRO= RESEND_API_KEY= EMAIL_FROM= ``` `DATABASE_URL` and `BETTER_AUTH_SECRET` are required. `NEXT_PUBLIC_SITE_URL` is optional and should contain your stable production URL when set. The Stripe and Resend variables are only required when you enable those features. Copy every price variable referenced by your deployed `billingConfig`; the names above match the shipped configuration. Then click the **Deploy** button and your project will be deployed. ## Environment Variables Make sure to add all required environment variables in the Vercel Dashboard. You can add them during the initial setup or later in the project settings under the **Environment Variables** tab. ## Apply the database migrations The application build does not apply database migrations. Before the new deployment receives production traffic, run the committed migrations once against the production database: ```sh filename="Terminal" lineNumbers npm run db:migrate ``` Run this from a controlled CI release job or a one-off local session with the production `DATABASE_URL`. Do not add it to the Vercel build command because concurrent builds or replicas must not race to change the schema. Use a pooled connection string intended for serverless workloads when your database provider offers one. Keep the application and database in nearby regions to reduce query latency. If the database firewall requires fixed source addresses, configure a supported secure connection method with the provider rather than assuming every Vercel function has a stable outbound IP. ## Build Settings Vercel will automatically detect Next.js and configure the build settings. The starter kit already includes Prisma generation in the build script: ```json filename="package.json" lineNumbers { "scripts": { "build": "prisma generate && next build", "postinstall": "fumadocs-mdx && prisma generate" } } ``` The `build` script automatically runs `prisma generate` before building, and `postinstall` ensures Prisma Client is generated after dependencies are installed. No additional configuration is needed. Leave Vercel's framework preset set to **Next.js** and use the repository root. A failed environment validation identifies a missing required variable; do not bypass it with `SKIP_ENV_VALIDATION` for an ordinary Vercel deployment. ## Webhooks If you're using Stripe webhooks, make sure to configure the webhook endpoint in your Stripe dashboard to point to your Vercel deployment URL: ```text filename="Webhook URL" lineNumbers https://your-app.vercel.app/api/webhooks/stripe ``` After adding the endpoint, subscribe only to the events handled by `app/api/webhooks/stripe/route.ts`. Use Stripe test mode to verify a checkout and webhook delivery before switching to live keys. ## Verify the deployment Before sending users to the application: 1. Open the production URL and confirm it uses HTTPS and the final domain. 2. Create an account, verify its email and sign in again. 3. Test an organization invitation with a second account. 4. Exercise each enabled integration, including one Stripe test checkout and one upload. 5. Confirm the Vercel function logs do not contain credentials or tokens. 6. Enable Web Analytics and Speed Insights separately in the Vercel dashboard if you want to use the components already included in the root layout. --- ## Configuration **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/email/configuration **Description**: Learn how to configure Resend and set up email sending. The starter kit uses [Resend](https://resend.com/) for sending emails. Resend is a modern email API designed for developers, offering excellent deliverability and a simple integration. ## Setup ### 1. Create a Resend Account 1. Go to [Resend](https://resend.com/) and create an account 2. Navigate to the **API Keys** section in your dashboard 3. Click **Create API Key** 4. Give it a name (e.g., "Production" or "Development") 5. Copy the API key (starts with `re_`) ### 2. Configure Environment Variables Add the following environment variables to your `.env` file: ```env filename=".env" lineNumbers RESEND_API_KEY=re_... EMAIL_FROM=noreply@yourdomain.com ``` Security Note Never commit your API keys to version control. Always use environment variables and ensure .env is in your .gitignore. ### 3. Domain Setup To send emails from your own domain: 1. Go to **Domains** in your Resend dashboard 2. Click **Add Domain** 3. Enter your domain (e.g., `yourdomain.com`) 4. Add the required DNS records to verify your domain: - **SPF Record** - Authorizes Resend to send emails - **DKIM Record** - Signs emails for authentication - **DMARC Record** (optional) - Email authentication policy 5. Wait for domain verification (usually a few minutes) 6. Update `EMAIL_FROM` to use your verified domain: ```env filename=".env" lineNumbers EMAIL_FROM=noreply@yourdomain.com ``` Using Resend's Domain For testing, you can use Resend's default domain:{' '} onboarding@resend.dev. However, for production, always use your own verified domain for better deliverability. ## Email Functions The email functions in `lib/email` automatically use your environment variables. ### Basic Usage ```typescript filename="lib/email/example.ts" lineNumbers import { sendEmail } from '@/lib/email'; await sendEmail({ recipient: 'user@example.com', subject: 'Welcome!', html: '

Welcome to our platform!

', text: 'Welcome to our platform!' }); ``` ### Using Pre-built Templates The email module exports functions for all pre-built templates: ```typescript filename="lib/actions/signup.ts" lineNumbers import { sendVerifyEmailAddressEmail } from '@/lib/email'; await sendVerifyEmailAddressEmail({ recipient: user.email, name: user.name, verificationLink: `${getBaseUrl()}/verify-email?token=${token}` }); ``` ## Retry Logic The email service includes automatic retry logic with exponential backoff: - **Max Attempts**: 3 total attempts (the initial attempt and up to 2 retries) - **Base Delay**: 1 second - **Max Delay**: 10 seconds - **Exponential Backoff**: Delay doubles with each retry Permanent errors (invalid email, auth failure) are not retried. ## Error Handling The email functions handle errors gracefully: ```typescript filename="lib/email/example.ts" lineNumbers import { sendEmail } from '@/lib/email'; try { await sendEmail({ recipient: 'user@example.com', subject: 'Test', html: '

Test

', text: 'Test' }); } catch (error) { // Error is logged automatically // Permanent errors are not retried // Transient errors are retried automatically } ``` ## Production Configuration For production deployments: 1. **Use a verified domain** - Always use your own domain, not Resend's default 2. **Set up SPF/DKIM** - Ensure DNS records are properly configured 3. **Monitor deliverability** - Check Resend dashboard for bounce rates 4. **Set up webhooks** (optional) - Track email events (delivered, bounced, etc.) ### Environment Variables in Production Add your environment variables in your hosting platform: - **Vercel**: Project Settings → Environment Variables - **Railway**: Variables tab - **Other platforms**: Follow their environment variable documentation ## Testing Test templates and delivery separately. A correct preview does not prove that Resend can authenticate your sender or deliver a message. ### Preview templates locally Run `npm run email:dev`, then open `http://localhost:3001`. This renders the components in `lib/email/templates/` with their `PreviewProps` without sending email or calling Resend. See [React Email Preview](/docs/starter-kits/pro-nextjs-prisma/email/react-email-preview). ### Verify provider delivery 1. Set `RESEND_API_KEY` to a development API key. 2. Set `EMAIL_FROM` to an address on a verified domain. For an initial Resend test, `onboarding@resend.dev` can only send to the email address associated with your Resend account. 3. Start the application and trigger a real product flow such as email verification, password reset or an organization invitation. 4. Confirm the request succeeds, the message appears in the Resend dashboard and the recipient receives it. 5. Open the generated link and confirm that it uses the correct application URL for the environment you are testing. Use a real product flow The repository does not include a standalone email test script. Triggering a shipped flow verifies the template, provider configuration and generated URL together. ### Before production - Use separate Resend API keys for development and production. - Verify the production sending domain and set `EMAIL_FROM` to that domain. - Add both email variables to the production deployment environment. - Exercise verification, password reset and invitation delivery after deploy. - Review failed requests, bounces and provider limits in the Resend dashboard. ## Best Practices 1. **Always use your own domain** - Better deliverability and branding 2. **Set up DNS records correctly** - SPF, DKIM and DMARC 3. **Monitor bounce rates** - Remove invalid email addresses 4. **Use templates** - Consistent branding and easier maintenance 5. **Handle errors gracefully** - Log errors and notify admins 6. **Test before production** - Preview templates, then exercise real product flows against the deployed environment --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/email/overview **Description**: Understand the shipped Resend and React Email integration. The starter kit uses [Resend](https://resend.com/) for delivery and [React Email](https://react.email/) for typed email templates. Both editions ship the same email API under `lib/email/`. ## How email flows through the application 1. A product flow calls an exported function from `lib/email/emails.ts`. 2. That function renders a React Email component to HTML and plain text. 3. `lib/email/resend.ts` sends both versions through Resend. 4. Transient failures are retried and final failures are logged. This separation keeps provider credentials out of templates and gives each message a typed input contract. ## Connected product emails The following messages are connected to shipped application flows: | Flow | Trigger | | ----------------------- | ------------------------------------------------------ | | Verify email address | Password signup through Better Auth | | Password reset | Better Auth password-reset request | | Confirm email change | Better Auth email-change request | | Organization invitation | Creating an invitation through the organization plugin | | Contact form | A successful contact-form submission | | Payment failed | A handled Stripe invoice failure webhook | | Subscription canceled | A handled Stripe subscription deletion webhook | | Trial ending | A handled Stripe trial-ending webhook | | Dispute received | A handled Stripe dispute webhook | `lib/email/templates/revoked-invitation-email.tsx` and its sending function are included for customization, but the shipped invitation-revocation action does not call it automatically. Wire it into that action if your product should notify the recipient. ## Send a custom email Use the generic transport for a one-off message: ```typescript filename="lib/actions/send-custom.ts" lineNumbers import { sendEmail } from '@/lib/email'; await sendEmail({ recipient: 'user@example.com', subject: 'Welcome!', html: '

Welcome to our platform!

', text: 'Welcome to our platform!' }); ``` For a reusable product email, create a typed React Email template and an exported rendering function in `lib/email/emails.ts`. This guarantees that HTML and plain-text versions are generated consistently. The React Email preview proves that a component renders. Exercise the real product flow with a development Resend key to verify sender authentication, generated links and delivery. --- ## React Email Preview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/email/react-email-preview **Description**: Learn how to preview email templates using React Email's preview server. ## Start the preview server To preview email templates, run: ```sh filename="Terminal" lineNumbers npm run email:dev ``` The preview server runs at http://localhost:3001 and automatically detects all email templates in lib/email/templates/ . ## How it works React Email's preview server automatically: - Scans the `lib/email/templates/` directory - Detects all email template files - Uses the `PreviewProps` exported from each template for preview data - Provides a web interface to preview all templates ## Email Development React Email allows you to develop email templates using React components, making it easy to create responsive and beautiful emails that work across email clients. --- ## Email Templates **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/email/templates **Description**: Learn how to create and use React Email templates. The starter kit uses [React Email](https://react.email/) to create email templates using `.tsx` files. React Email allows you to leverage Tailwind CSS and React components while ensuring consistent email styling across various email clients. ## Why React Email? Why choose React Email? React Email allows us to leverage Tailwind and React components, while ensuring consistent email styling across various email clients. It's really easy to write consistent email templates. ## Creating Email Templates Create email templates in the `lib/email/templates/` directory: ```tsx filename="lib/email/templates/welcome-email.tsx" lineNumbers import type * as React from 'react'; import { Body, Button, Container, Head, Heading, Html, Preview, Section, Text } from '@react-email/components'; import { Tailwind } from '@react-email/tailwind'; export type WelcomeEmailProps = { name: string; welcomeLink: string; }; function WelcomeEmail({ name, welcomeLink }: WelcomeEmailProps): React.JSX.Element { return ( Welcome to our platform! Welcome! Hello {name}, Thanks for joining us. We're excited to have you on board!
); } // Preview props for React Email preview WelcomeEmail.PreviewProps = { name: 'John Doe', welcomeLink: 'https://example.com/dashboard' } satisfies WelcomeEmailProps; export default WelcomeEmail; export { WelcomeEmail }; ``` ## Add a Sending Function The repository does not use an email service class. Add a focused exported function to `lib/email/emails.ts`, following the same pattern as the shipped authentication and billing emails: ```typescript filename="lib/email/emails.ts" lineNumbers import { render } from '@react-email/render'; import { sendEmail } from './resend'; import type { WelcomeEmailProps } from './templates/welcome-email'; export async function sendWelcomeEmail( input: WelcomeEmailProps & { recipient: string } ): Promise { const { WelcomeEmail } = await import('./templates/welcome-email'); const component = WelcomeEmail(input); const html = await render(component); const text = await render(component, { plainText: true }); await sendEmail({ recipient: input.recipient, subject: 'Welcome to our platform!', html, text }); } ``` `lib/email/index.ts` already re-exports `lib/email/emails.ts`, so the new function becomes available from `@/lib/email` without adding another export. ## Using Email Templates Send emails using the email service: ```typescript filename="lib/actions/send-welcome.ts" lineNumbers import { sendWelcomeEmail } from '@/lib/email'; export async function handleUserCreated(user: { email: string; name: string }) { await sendWelcomeEmail({ recipient: user.email, name: user.name, welcomeLink: 'https://yourdomain.com/dashboard' }); } ``` In application code, build links with the same trusted base-URL helper used by the shipped authentication flows. Do not construct email links from an unvalidated request `Host` header. ## Available Components React Email provides many components for building emails: - **Container** - Main wrapper - **Section** - Content sections - **Heading** - Headings (h1-h6) - **Text** - Paragraph text - **Button** - Call-to-action buttons - **Link** - Hyperlinks - **Image** - Images - **Hr** - Horizontal rules - **Code** - Inline code - **CodeBlock** - Code blocks See the [React Email documentation](https://react.email/docs/components/html) for a complete list. ## Styling ### Using Tailwind React Email supports Tailwind CSS: ```tsx filename="lib/email/templates/example.tsx" lineNumbers Hello World ``` ### Inline Styles You can also use inline styles: ```tsx filename="lib/email/templates/example.tsx" lineNumbers Hello World ``` ## Preview Props Each template should export `PreviewProps` for the React Email preview: ```typescript filename="lib/email/templates/welcome-email.tsx" lineNumbers WelcomeEmail.PreviewProps = { name: 'John Doe', welcomeLink: 'https://example.com/dashboard' } satisfies WelcomeEmailProps; ``` ## Previewing Emails You can preview your email templates during development. See the [React Email Preview](/docs/starter-kits/pro-nextjs-prisma/email/react-email-preview) documentation for details. ### Running the Preview Server ```bash filename="Terminal" lineNumbers npm run email:dev ``` This starts a local server at `http://localhost:3001` where you can preview all your email templates. ## Available Templates The starter kit includes the following email templates: - **Verify Email Address** - Email verification link - **Password Reset** - Password reset instructions - **Organization Invitation** - Invite users to organizations - **Payment Failed** - Notify about failed payments - **Subscription Canceled** - Notify about canceled subscriptions - **Trial Ending Soon** - Remind about trial expiration - **Contact Form** - Contact form submissions - **Revoked Invitation** - Notify about revoked invitations - **Email Address Change** - Confirm email address change - **Dispute Received** - Alert administrators when Stripe reports a dispute All templates are located in `lib/email/templates/` and can be customized to match your brand. ## Customizing Templates ### Update Branding Update the logo and colors in your templates: ```tsx filename="lib/email/templates/welcome-email.tsx" lineNumbers Your Company {/* ... rest of template */} ``` ### Add Custom Styles Create a shared styles file: ```typescript filename="lib/email/styles.ts" lineNumbers export const emailStyles = { primaryColor: '#000000', secondaryColor: '#666666', borderRadius: '4px', fontFamily: 'Arial, sans-serif' }; ``` Use in templates: ```tsx filename="lib/email/templates/example.tsx" lineNumbers import { emailStyles } from '../styles'; ; ``` ## Best Practices 1. **Use Preview Props** - Always define preview props for development 2. **Test across clients** - Test emails in Gmail, Outlook, Apple Mail 3. **Keep it simple** - Avoid complex layouts that break in email clients 4. **Use Tailwind** - Leverage Tailwind for consistent styling 5. **Include plain text** - Always provide a plain text version 6. **Mobile responsive** - Ensure emails look good on mobile devices 7. **Accessible** - Use semantic HTML and alt text for images --- ## FAQ **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/faq **Description**: Frequently asked questions about the starter kit. ## What is a starter kit? A starter kit is a pre-built, fullstack foundation designed to help you create scalable, production-ready web applications quickly and efficiently. It includes all the essential tools, components and best practices for building modern SaaS platforms, so you can focus on developing your unique features instead of spending time on generic setup tasks. ## What is the difference between a starter kit and boilerplate? In everyday conversations the terms are often used interchangeably. Feel free to use whichever one you prefer! ## How do I update the starter kit? Keep your product repository as `origin`, add the private Achromatic repository as a separate remote and merge updates on a dedicated branch. Review migrations, environment changes and security-sensitive code before applying an update. See the [updating guide](/docs/starter-kits/pro-nextjs-prisma/codebase/updating) for the complete workflow. --- ## Folder Structure **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/folder-structure **Description**: An overview of the project's organization and file structure. The Pro Next.js Prisma starter kit follows a clean and logical structure designed for scalability and ease of maintenance in a single-repo setup. ## Directory Descriptions ### `app/` Next.js App Router directory containing all routes and pages. Uses route groups `(marketing)` and `(saas)` to organize public and protected pages. ### `components/` React components organized by feature. The `ui/` subdirectory contains reusable UI primitives from shadcn/ui. ### `config/` Application configuration files. Each feature has its own configuration file for easy management. ### `content/` MDX content for blog posts, documentation, and legal pages. Uses Content Collections for type-safe content management. ### `hooks/` Custom React hooks for shared functionality like session management, storage, and theming. ### `lib/` Core business logic and service libraries. Each subdirectory represents a major feature: - **`auth/`**: Better Auth setup and utilities - **`billing/`**: Stripe integration and billing logic - **`db/`**: Prisma client and database utilities - **`email/`**: Email service and React Email templates - **`storage/`**: File storage service (S3-compatible) ### `prisma/` Prisma ORM configuration and migrations. The `schema.prisma` file defines your database schema, and `migrations/` contains the migration history. ### `schemas/` Zod validation schemas for form validation and API input validation. Organized by feature domain. ### `trpc/` tRPC API layer providing end-to-end type safety. Routers are organized by feature, and the context provides session and organization scoping. ### `types/` Shared TypeScript type definitions used across the application. ## Highlights - **`(marketing)` & `(saas)` Route Groups**: Clearly separates public-facing pages from the protected dashboard application. - **`lib/` Directory**: Centralizes all core services like database, authentication, and billing, making them easy to test and maintain. - **`schemas/` Directory**: Centralizes all Zod validation schemas for consistent validation across the application. - **`trpc/` Directory**: Contains your entire API layer, ensuring end-to-end type safety with React Query integration. - **`prisma/` Directory**: Houses your database schema and migration history, managed by Prisma ORM. - **`components/ui/`**: Houses reusable UI primitives, following the shadcn/ui pattern. - **`hooks/` Directory**: Custom React hooks for shared functionality, reducing code duplication. --- ## Introduction **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma **Description**: Set up, understand and customize the Prisma edition of the Achromatic Next.js SaaS starter kit. ## Start here Achromatic is a production-grade Next.js SaaS starter kit built as a single, approachable application. This documentation explains what the repository ships, how its systems fit together and where to customize them safely. ## What ships The repository includes working implementations for: - **Next.js 16.3, React 19 and TypeScript 7** with the App Router, Server Components and streaming. - **Prisma ORM and PostgreSQL** with schemas, migrations and local Docker setup. - **Better Auth** with email and password authentication, Google OAuth, email verification, password recovery, TOTP two-factor authentication and administration. - **Organizations** with invitations, roles, permissions and organization-scoped data. - **Stripe billing** with subscriptions, one-time purchases, credits, per-seat billing, a customer portal and webhook synchronization. - **Email** with Resend, React Email templates and local template previews. - **AI** with a working Vercel AI SDK chatbot, persistence and credit consumption. - **Image storage** with S3-compatible presigned uploads for avatars and organization logos. - **Developer tooling** with tests, strict TypeScript, [Oxlint and Oxfmt](/docs/starter-kits/pro-nextjs-prisma/codebase/formatting-linting) and a [local, read-only MCP server](/docs/starter-kits/pro-nextjs-prisma/codebase/mcp-server) for repository-aware coding assistance. - **Operations** with Pino logging, Sentry, Vercel Analytics and Speed Insights. - **Product surfaces** including marketing pages, a blog, legal pages, account settings, organization settings and the admin dashboard. Included code versus integration guidesA page can explain how to add a compatible service without claiming that the service is installed. For example, the background-task section contains integration guides for Trigger.dev, QStash, Inngest and Vercel Workflow. Those packages are not part of the repository until you choose and install one. ## Architecture Achromatic deliberately uses a single repository and a single Next.js application. Marketing, authentication, the SaaS dashboard and API routes share one dependency graph and deployment. This keeps local development, upgrades and cross-cutting changes direct. It is an intentional alternative to a monorepo, not a reduced version of one. The application is organized around feature boundaries: - `app/` contains routes, layouts and route handlers. - `components/` contains reusable interface and feature components. - `config/` contains typed product configuration. - `lib/` contains server integrations and application services. - `trpc/` contains the type-safe API layer. - The database schema and migration directories contain the persistence model. Use the [folder structure](/docs/starter-kits/pro-nextjs-prisma/folder-structure) and [codebase overview](/docs/starter-kits/pro-nextjs-prisma/codebase/overview) before moving large features. ## Choose the right database kit Both repositories ship the same product features and user experience. Choose Prisma when your team prefers Prisma ORM. If you are still deciding, use the [starter kit chooser](/docs/starter-kits) before selecting the repository that will become the foundation for your product. ## Recommended path 1. Complete [Setup](/docs/starter-kits/pro-nextjs-prisma/setup) without product customizations. 2. Confirm the application, database, signup and email verification flows work locally. 3. Read [Configuration](/docs/starter-kits/pro-nextjs-prisma/configuration) and replace the product identity. 4. Configure only the integrations your first release needs. 5. Run the test and quality checks before changing architecture. 6. Use the [deployment guide](/docs/starter-kits/pro-nextjs-prisma/deployment) as a production checklist. Starting from a verified baseline makes later failures much easier to isolate. ## Scope of this documentation Use these guides as the source of truth for the code Achromatic ships: repository paths, package scripts, configuration files, environment variable names and how the included features are connected. Database-specific pages are kept separate where schema or migration workflows differ between editions. Use the upstream documentation when you need the complete API of an underlying library: - [Next.js documentation](https://nextjs.org/docs) for framework behavior and App Router APIs. - [Better Auth documentation](https://www.better-auth.com/docs) for plugin APIs and authentication concepts. - [Stripe documentation](https://docs.stripe.com/) for account configuration, Checkout and webhook behavior. - The official Prisma ORM documentation for queries, schema syntax and advanced database features. Check the versions pinned in `package.json` before following a newly published upstream example. If an upstream guide conflicts with this documentation, first confirm that it targets the same installed version and the same runtime. --- ## Blog **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/marketing/blog **Description**: Learn how to write blog posts using Content Collections. The starter kit uses [Content Collections](https://www.content-collections.dev/) for managing blog content. All blog posts are written using `.mdx` files, which combine markdown with React components. Why choose Content Collections? Content Collections is a powerful, type-safe content management solution. It's powered by MDX, free, open-source and saves content directly in your repository. The blog is configured in `content-collections.ts` and uses [Fumadocs](https://www.fumadocs.dev/) for rendering documentation-style content. ## Add a new blog post To create a new blog post, follow these steps: 1. **Create a new file** Navigate to the `content/blog` directory and create a new `.mdx` file. The file name will act as the URL slug for the post. For example: - File name: `hello-world.mdx` - URL: `https://your-app.com/blog/hello-world` 2. **Add metadata** At the top of the `.mdx` file, include a frontmatter block. This block contains key metadata about your post, written in a YAML-like format enclosed by three dashes (`---`). Here's an example: ```mdx filename="content/posts/hello-world.mdx" lineNumbers --- title: How to create a blog post date: 2025-01-20T12:00:00.000Z authorName: John Doe authorImage: /authors/john.jpg authorLink: https://example.com excerpt: A short description of your blog post. tags: [Innovation, Tutorial] published: true content: | Your blog post content goes here... --- ``` ### Frontmatter Fields The blog post schema supports the following fields: - `title` (required) - The title of the blog post - `date` (required) - ISO 8601 date string for publication date - `authorName` (required) - Name of the author - `authorImage` (optional) - URL to author's image - `authorLink` (optional) - Link to author's profile - `excerpt` (optional) - A short description/excerpt of the post - `tags` (required) - Array of tag strings - `published` (required) - Boolean to control visibility - `image` (optional) - Featured image URL - `content` (required) - The full content of the post ## Using MDX Components You can use React components directly in your MDX files. The starter kit provides several custom components: ```mdx filename="content/blog/example.mdx" lineNumbers --- title: Example Post description: An example blog post --- import { Callout } from '@/components/mdx-components'; # My Blog Post This is a callout component! Regular markdown content here. ``` ## Code Blocks Code blocks are automatically highlighted and support line numbers: ```typescript filename="example.ts" lineNumbers export function example() { return 'Hello, World!'; } ``` ## Images You can include images in your blog posts: ```mdx filename="content/blog/example.mdx" lineNumbers ![Alt text](/path/to/image.png) ``` Or use the Image component for more control: ```mdx filename="content/blog/example.mdx" lineNumbers import { Image } from '@/components/mdx-components'; Alt text ``` ## Building Content Content Collections are automatically built during the Next.js build process. During development, they are rebuilt when files change. ## Querying Blog Posts You can query blog posts in your application: ```typescript filename="app/blog/page.tsx" lineNumbers import { allDocuments } from 'content-collections/generated'; export default function BlogPage() { const posts = allDocuments('posts') .filter((post) => post.published) .sort((a, b) => { const dateA = new Date(a.date); const dateB = new Date(b.date); return dateB.getTime() - dateA.getTime(); }); return (
{posts.map((post) => (

{post.title}

{post.excerpt}

))}
); } ``` ## Configuration The blog collection is configured in `content-collections.ts`: ```typescript filename="content-collections.ts" lineNumbers const posts = defineCollection({ name: 'posts', directory: 'content/posts', include: '**/*.{mdx,md}', schema: z.object({ title: z.string(), date: z.string(), authorName: z.string(), excerpt: z.string().optional(), tags: z.array(z.string()), published: z.boolean(), content: z.string() }) }); export default defineConfig({ collections: [posts] }); ``` ## Best Practices 1. **Use descriptive filenames** - The filename becomes the URL slug 2. **Add descriptions** - Help with SEO and preview cards 3. **Use categories** - Organize related posts 4. **Set publication dates** - Control when posts appear 5. **Test locally** - Always preview posts before publishing --- ## Contact Page **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/marketing/contact **Description**: Learn how to customize the contact page and configure contact information. The contact page (`/contact`) provides a way for visitors to get in touch with your team. It includes a contact form and displays your contact information. ## Page Structure The contact page is located at `app/contact/page.tsx` and includes: - **Contact Hero Section** - Headline and description - **Contact Form** - Form for visitors to send messages - **FAQ Section** - Frequently asked questions ## Configuration Contact information is configured in `config/app.config.ts`: ```typescript filename="config/app.config.ts" lineNumbers export const appConfig = { // ... other config contact: { enabled: true, email: 'hello@yourdomain.com', phone: '(123) 456-7890', address: '123 Main St, San Francisco, CA' } }; ``` ### Contact Form The contact form is handled by the `ContactHeroSection` component. To customize the form: 1. **Update form fields** - Edit the form component in `components/sections/contact-hero-section.tsx` 2. **Configure email** - Set up email sending in your email service (see [Email documentation](/docs/starter-kits/pro-nextjs-prisma/email/overview)) 3. **Add validation** - Add client and server-side validation as needed ## Customization ### Update Contact Content Edit the `ContactHeroSection` component: ```typescript filename="components/sections/contact-hero-section.tsx" lineNumbers export function ContactHeroSection(): React.JSX.Element { return (

Get in Touch

We'd love to hear from you

{/* Contact form */}
); } ``` ### Display Contact Information Contact information from `app.config.ts` is automatically displayed. You can customize how it's shown by editing the contact section component. ## Email Integration To send emails when the contact form is submitted: 1. **Create a tRPC endpoint** - Handle form submission server-side 2. **Use email service** - Send emails using your configured email provider 3. **Add validation** - Validate form data before sending See the [Email documentation](/docs/starter-kits/pro-nextjs-prisma/email/overview) for more details. ## SEO The contact page includes structured data (JSON-LD) for: - ContactPage schema - WebPage schema - Breadcrumb schema ## Best Practices 1. **Clear contact options** - Provide multiple ways to get in touch 2. **Quick response** - Set expectations for response time 3. **Form validation** - Validate all form fields 4. **Spam protection** - Consider adding reCAPTCHA or similar 5. **Confirmation message** - Show a success message after submission --- ## Documentation **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/marketing/documentation **Description**: Write and organize product documentation with Fumadocs MDX. The starter kit includes a Fumadocs documentation site at `/docs`. Its pages are local MDX files in `content/docs`, so the documentation stays versioned with the application code. Both starter kit repositories use the same documentation structure. Do not create an ORM-specific folder inside `content/docs`. ## How documentation is connected | File | Responsibility | | ------------------------------- | ------------------------------------------------------- | | `content/docs/*.mdx` | Documentation content and page metadata | | `content/docs/meta.json` | Sidebar groups, labels and page order | | `source.config.ts` | Declares `content/docs` as the Fumadocs MDX source | | `lib/marketing/docs/source.ts` | Loads the content and assigns the `/docs` base URL | | `app/docs/layout.tsx` | Configures the documentation layout and navigation tree | | `app/docs/[[...slug]]/page.tsx` | Renders each page and generates its metadata | The kit already connects these files. You normally only need to edit `content/docs` when writing product documentation. ## Add a page ### Create the MDX file Add a file directly under `content/docs`. Its path becomes the URL after `/docs`. ```mdx filename="content/docs/getting-started.mdx" --- title: Getting started description: Configure the application for local development. icon: Rocket --- ## Prerequisites Add your guide here. ``` This example is available at `/docs/getting-started`. The optional `icon` value must match an icon exported by Lucide React. ### Add the page to the sidebar Add the filename without `.mdx` to the `pages` array in `content/docs/meta.json`: ```json filename="content/docs/meta.json" { "title": "Documentation", "root": true, "pages": ["index", "getting-started"] } ``` Keep this array in the order you want readers to follow. Fumadocs also supports separators and external links in this file. ### Preview the page Start the application and open the new route: ```bash npm run dev ``` Visit `http://localhost:3000/docs/getting-started` and check the page on both desktop and mobile. ## Organize a section For a larger topic, put its pages in a folder and add a `meta.json` inside that folder. The folder name becomes the URL segment. ```text content/docs/ ├── meta.json └── billing/ ├── meta.json ├── overview.mdx └── webhooks.mdx ``` ```json filename="content/docs/billing/meta.json" { "title": "Billing", "pages": ["overview", "webhooks"] } ``` The pages are then available at `/docs/billing/overview` and `/docs/billing/webhooks`. Add `billing` to the root `content/docs/meta.json` where that section should appear. ## Use the included MDX components The page renderer registers Fumadocs components including `Callout`, `Cards`, `Tabs`, `Steps`, `Files` and `ImageZoom`. You can use them directly in an MDX page without importing them. ```mdx filename="content/docs/getting-started.mdx" Copy `.env.example` to `.env` and provide the required values. Use your local service credentials. Use credentials from the production project. ``` Standard fenced code blocks support syntax highlighting. Add a `filename` attribute when the file location helps the reader. ## Change the documentation UI - Edit `app/docs/layout.tsx` to change the documentation shell or sidebar behavior. - Edit `lib/marketing/docs/layout.config.tsx` to change shared layout options such as navigation links. - Edit `app/docs/[[...slug]]/page.tsx` to register another MDX component or change page rendering. - Edit `lib/marketing/docs/source.ts` only when changing how the content source is loaded. Keep content changes in `content/docs` and layout changes in the application files above. This separation makes upgrades easier and keeps navigation generated from the same source as the pages. ## Validate before publishing Run the same checks used for application changes: ```bash npm run typecheck npm run lint npm run build ``` Also open every new documentation route locally. A successful build confirms that Fumadocs can compile the MDX, while the browser check catches navigation, layout and readability problems. For advanced navigation and MDX options, see the [Fumadocs documentation](https://fumadocs.dev/docs/mdx). --- ## Landing Page **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/marketing/landing **Description**: Learn how to customize the landing page and its sections. The landing page (`/`) is the main homepage of your marketing site. It showcases your product with multiple sections designed to convert visitors into customers. ## Page Structure The landing page is located at `app/page.tsx` and consists of the following sections: - **Hero Section** - Main headline, value proposition and call-to-action buttons - **Pre-built Section** - Highlights key features and benefits - **Features Section** - Detailed feature list - **Auth Section** - Authentication capabilities showcase - **Multi-tenancy Section** - Organization management features - **Billing Section** - Payment and subscription features - **Code Section** - Developer experience highlights - **Trusted Section** - Social proof and testimonials - **Pricing Section** - Pricing preview with call-to-action - **FAQ Section** - Frequently asked questions ## Customization ### Update Hero Content Edit the `HeroSection` component to change the headline, description and CTA buttons: ```typescript filename="components/sections/hero-section.tsx" lineNumbers export function HeroSection(): React.JSX.Element { return (

Your Headline

Your value proposition

); } ``` ### Modify Sections Each section is a separate component in `components/sections/`. You can: - **Reorder sections** - Change the order in `app/page.tsx` - **Remove sections** - Comment out or delete unused sections - **Add custom sections** - Create new section components and add them to the page - **Customize styling** - Update Tailwind classes in each section component ### Update Metadata Modify the page metadata in `app/page.tsx`: ```typescript filename="app/page.tsx" lineNumbers export const metadata: Metadata = { title: 'Your App Name', description: 'Your app description' // ... other metadata }; ``` ## SEO The landing page includes structured data (JSON-LD) for: - Website schema - Software application schema - FAQ schema - Site navigation schema These are automatically generated and help with search engine optimization. ## Best Practices 1. **Clear value proposition** - Make it immediately clear what your product does 2. **Strong CTAs** - Use action-oriented button text (e.g., "Get Started", "Start Free Trial") 3. **Social proof** - Include testimonials, logos or usage statistics 4. **Mobile responsive** - Ensure all sections work well on mobile devices 5. **Fast loading** - Optimize images and use Next.js Image component --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/marketing/overview **Description**: Learn about all marketing pages and how to manage content using Content Collections and Fumadocs. The starter kit includes a comprehensive set of marketing pages to cover every touchpoint of your SaaS. These pages are organized in the `(marketing)` route group and include landing pages, blog, documentation, pricing, contact and legal pages. ## Marketing Pages The starter kit includes the following marketing pages: - **Landing Page** (`/`) - Main homepage showcasing your product with hero section, features, pricing preview and call-to-action - **Blog** (`/blog`) - Integrated blogging system using Content Collections, statically generated at build time - **Documentation** (`/docs`) - Documentation pages using Content Collections and Fumadocs - **Pricing** (`/pricing`) - Responsive pricing tables with plan comparisons and conversion-optimized design - **Contact** (`/contact`) - Contact form with email integration - **Legal Pages** - Terms of Service, Privacy Policy and Cookie Policy (create these pages as needed) All marketing pages are located in the `app/(marketing)/` directory and can be enabled/disabled via the `app.config.ts` file. ## Content Management For blog posts and documentation, the starter kit uses [Content Collections](https://www.content-collections.dev/) for managing content and [Fumadocs](https://www.fumadocs.dev/) for rendering documentation. This provides a powerful, type-safe content management system that's easy to use and maintain. Why Content Collections? Content Collections is a powerful, type-safe content management solution. It's powered by MDX, free, open-source and saves content directly in your repository. This means your content is version-controlled and easy to manage. ### Features - **Type-safe content** - Full TypeScript support with automatic type generation - **MDX support** - Write content using Markdown with React components - **Version control** - Content is stored in your repository, making it easy to track changes - **Fast builds** - Content is compiled at build time for optimal performance - **Developer-friendly** - Edit content using your favorite code editor - **No database required** - Content is stored as files, not in a database ## Content Collections Content Collections provides: - **Schema validation** - Define schemas for your content using Zod - **Automatic type generation** - TypeScript types are generated from your schemas - **Query API** - Easy-to-use API for querying content - **Transform functions** - Process and transform content during build ## Fumadocs Fumadocs provides: - **Beautiful UI** - Pre-built documentation UI components - **Search** - Full-text search across your documentation - **Dark mode** - Automatic theme switching - **Responsive design** - Mobile-friendly layouts - **Table of contents** - Automatically generated from headings ## Configuration Content Collections is configured in `content-collections.ts`. The starter kit includes multiple collections: ```typescript filename="content-collections.ts" lineNumbers import { defineCollection, defineConfig } from '@content-collections/core'; import { compileMDX } from '@content-collections/mdx'; import { z } from 'zod'; // Blog posts collection const posts = defineCollection({ name: 'posts', directory: 'content/posts', include: '**/*.{mdx,md}', schema: z.object({ title: z.string(), date: z.string(), authorName: z.string(), excerpt: z.string().optional(), tags: z.array(z.string()), published: z.boolean(), content: z.string() }), transform: async (document, context) => { const body = await compileMDX(context, document); return { ...document, body, path: document._meta.path.replace(/\.mdx?$/, '') }; } }); export default defineConfig({ collections: [posts] }); ``` ## Building Content Content Collections are automatically built during the Next.js build process. During development, they are rebuilt when files change. ## Querying Content You can query content in your application: ```typescript filename="app/blog/page.tsx" lineNumbers import { allDocuments } from 'content-collections/generated'; export default function BlogPage() { const posts = allDocuments('posts') .filter((post) => post.published) .sort((a, b) => { const dateA = new Date(a.date); const dateB = new Date(b.date); return dateB.getTime() - dateA.getTime(); }); return (
{posts.map((post) => (

{post.title}

{post.excerpt}

))}
); } ``` ## Best Practices 1. **Organize content** - Use clear directory structures 2. **Use schemas** - Define schemas for type safety 3. **Version control** - Commit content changes to git 4. **Test locally** - Always preview content before publishing 5. **Use MDX components** - Leverage React components in your content --- ## Pricing Page **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/marketing/pricing **Description**: Learn how to customize the pricing page and integrate it with your billing configuration. The pricing page (`/pricing`) displays your subscription plans and pricing information. It's integrated with your billing configuration and automatically displays plans from `config/billing.config.ts`. ## Page Structure The pricing page is located at `app/pricing/page.tsx` and includes: - **Pricing Hero Section** - Headline and description for the pricing page - **Pricing Section** - Displays all plans from your billing configuration - **FAQ Section** - Frequently asked questions about pricing ## Integration with Billing Config The pricing page automatically reads plans from `config/billing.config.ts`. Plans are displayed based on: - **Plan visibility** - Plans with `hidden: true` are not shown - **Plan order** - Plans are displayed in the order they appear in the config - **Recommended plans** - Plans with `recommended: true` are highlighted - **Enterprise plans** - Plans with `isEnterprise: true` show a "Contact Sales" button ## Customization ### Update Pricing Content Edit the `PricingHeroSection` component: ```typescript filename="components/sections/pricing-hero-section.tsx" lineNumbers export function PricingHeroSection(): React.JSX.Element { return (

Choose Your Plan

Select the perfect plan for your needs

); } ``` ### Customize Plan Display The `PricingSection` component automatically renders plans from your billing config. To customize how plans are displayed, edit: ```typescript filename="components/sections/pricing-section.tsx" lineNumbers // Customize plan card styling, features display, etc. ``` ### Update FAQ Modify the FAQ section in `app/pricing/page.tsx`: ```typescript filename="app/pricing/page.tsx" lineNumbers const faqSchema = { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: [ { '@type': 'Question', name: 'Your question?', acceptedAnswer: { '@type': 'Answer', text: 'Your answer.' } } ] }; ``` ## SEO The pricing page includes structured data (JSON-LD) for: - Product schema - Breadcrumb schema - FAQ schema ## Best Practices 1. **Clear pricing** - Make prices and features easy to understand 2. **Highlight recommended plan** - Use the `recommended` flag to guide users 3. **Show value** - Include feature comparisons and benefits 4. **Mobile friendly** - Ensure pricing tables work on all screen sizes 5. **Clear CTAs** - Use action-oriented button text --- ## Observability **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/observability **Description**: Monitor your application's performance and track errors with Sentry and Vercel Analytics. The Pro Next.js Prisma starter kit includes structured logging and optional integrations for error, traffic and performance monitoring. External services still require your own accounts and project configuration. ## Structured Logging The kit includes a high-performance logging system based on **Pino**. ### Usage Use the `logger` to record events with structured metadata. ```typescript filename="lib/actions/billing.ts" lineNumbers import { logger } from '@/lib/logger'; logger.info({ userId, amount }, 'Payment processed successfully'); ``` ### Log Levels You can control the verbosity of logs via the `NEXT_PUBLIC_LOG_LEVEL` environment variable. ```env filename=".env" lineNumbers NEXT_PUBLIC_LOG_LEVEL="debug" # trace, debug, info, warn, error, fatal ``` Available log levels (from most to least verbose): - `trace` - Very detailed debugging information - `debug` - Debug information - `info` - General informational messages (default) - `warn` - Warning messages - `error` - Error messages - `fatal` - Critical errors --- ## Logging **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/observability/logging **Description**: Learn how to use Pino for structured logging in your application. The starter kit uses [Pino](https://getpino.io/), a high-performance structured logging library for Node.js. Pino is one of the fastest logging libraries available and provides excellent performance with minimal overhead. ## Basic Usage Import and use the default logger: ```typescript filename="lib/actions/billing.ts" lineNumbers import { logger } from '@/lib/logger'; // Simple message logger.info('Payment processed successfully'); // With structured data logger.info( { userId, amount, transactionId }, 'Payment processed successfully' ); // Error logging logger.error({ error, userId }, 'Failed to process payment'); ``` ## Log Levels Pino supports six log levels, from most to least verbose: - `trace` - Very detailed debugging information - `debug` - Debug information - `info` - General informational messages (default) - `warn` - Warning messages - `error` - Error messages - `fatal` - Critical errors ### Setting Log Level Control the verbosity of logs via the `NEXT_PUBLIC_LOG_LEVEL` environment variable: ```env filename=".env.local" lineNumbers NEXT_PUBLIC_LOG_LEVEL="debug" # trace, debug, info, warn, error, fatal ``` The default log level is `info`. In production, you typically want `info` or `warn` to reduce noise. ## Grouped Loggers Create loggers for specific modules or features using the `LoggerFactory`: ```typescript filename="lib/billing/payment-processor.ts" lineNumbers import { LoggerFactory } from '@/lib/logger/factory'; const logger = LoggerFactory.getLogger('Billing'); logger.info({ userId, amount }, 'Processing payment'); // Output: [INFO] Billing: Processing payment ``` Predefined groups include: - `Billing` - Payment and subscription related logs - `Auth` - Authentication related logs - `Webhook` - Webhook processing logs - `Database` - Database operation logs - `API` - API request logs - `Organization` - Organization management logs - `User` - User management logs - `Email` - Email sending logs - `Storage` - File storage logs You can also create custom groups: ```typescript filename="lib/features/analytics.ts" lineNumbers import { LoggerFactory } from '@/lib/logger/factory'; const logger = LoggerFactory.getLogger('Analytics'); logger.info({ event: 'page_view', page: '/dashboard' }, 'Page viewed'); ``` ## Request Context The logger can merge request context from the `AsyncLocalStorage` helpers in `lib/logger/context.ts`. The repositories do not install a global request wrapper, so ordinary logger calls include only the fields you pass unless your code runs inside `runWithRequestContext` or `runWithAdditionalContext`. The context type supports: - `requestId` - Unique request identifier - `userId` - Current user ID - `userEmail` - Current user email - `userRole` - Current user role - `organizationId` - Active organization ID - `userAgent` - User agent string - `ip` - Client IP address - `endpoint` - API endpoint - `method` - HTTP method - `trpcProcedure` - tRPC procedure name - `trpcType` - tRPC call type (query/mutation) - `webhookType` - Webhook event type - `sessionId` - Session ID Initialize the context at a request boundary before relying on automatic enrichment: ```typescript filename="app/api/users/route.ts" lineNumbers import { headers } from 'next/headers'; import { logger } from '@/lib/logger'; import { runWithRequestContext } from '@/lib/logger/server'; export async function GET() { const requestHeaders = await headers(); return runWithRequestContext( { requestId: requestHeaders.get('x-request-id') ?? undefined, userAgent: requestHeaders.get('user-agent') ?? undefined, endpoint: '/api/users', method: 'GET' }, async () => { logger.info({ action: 'list_users' }, 'Fetching user list'); return Response.json({ users: [] }); } ); } ``` The shipped tRPC middleware does not use this storage wrapper. On failed tRPC procedures it explicitly logs the procedure, duration, request metadata and any available user or organization fields. Successful procedure calls are not logged automatically. User email, IP address and user-agent values are supported context fields. Add only what you need, define a retention policy and never log session cookies, authorization headers, passwords, API keys or payment details. ## Development vs Production In development, logs are formatted with colors and readable output: ``` [INFO] Billing: Payment processed successfully (userId=123 amount=29.99 transactionId=txn_abc) ``` In production, logs are output as JSON for easy parsing by log aggregation services: ```json { "level": 30, "time": 1234567890, "group": "Billing", "msg": "Payment processed successfully", "userId": "123", "amount": 29.99, "transactionId": "txn_abc" } ``` ## Examples ### Server Actions ```typescript filename="app/actions/create-user.ts" lineNumbers 'use server'; import { logger } from '@/lib/logger'; export async function createUser(email: string, name: string) { try { logger.info({ email, name }, 'Creating new user'); // ... create user logic ... logger.info({ userId: user.id, email }, 'User created successfully'); return { success: true, user }; } catch (error) { logger.error({ error, email }, 'Failed to create user'); throw error; } } ``` ### API Routes ```typescript filename="app/api/webhooks/stripe/route.ts" lineNumbers import { logger } from '@/lib/logger'; export async function POST(request: Request) { const body = await request.json(); logger.info( { webhookType: body.type, eventId: body.id }, 'Received Stripe webhook' ); // ... process webhook ... logger.info({ webhookType: body.type }, 'Webhook processed successfully'); return new Response('OK', { status: 200 }); } ``` ### tRPC Procedures ```typescript filename="trpc/routers/user.ts" lineNumbers import { createTRPCRouter, protectedProcedure } from '@/trpc/init'; import { z } from 'zod'; import { logger } from '@/lib/logger'; export const userRouter = createTRPCRouter({ update: protectedProcedure .input(z.object({ name: z.string() })) .mutation(async ({ ctx, input }) => { logger.info({ userId: ctx.user.id, name: input.name }, 'Updating user'); // ... update logic ... logger.info({ userId: ctx.user.id }, 'User updated successfully'); return user; }) }); ``` ### Error Handling ```typescript filename="lib/utils/error-handler.ts" lineNumbers import { logger } from '@/lib/logger'; export function handleError(error: unknown, context?: Record) { if (error instanceof Error) { logger.error( { error: error.message, stack: error.stack, ...context }, 'Error occurred' ); } else { logger.error({ error, ...context }, 'Unknown error occurred'); } } ``` ## Best Practices 1. **Use appropriate log levels**: Use `info` for normal operations, `warn` for recoverable issues, and `error` for failures. 2. **Include structured data**: Always include relevant context in your logs: ```typescript // ✅ Good logger.info({ userId, orderId, amount }, 'Order processed'); // ❌ Less useful logger.info('Order processed'); ``` 3. **Use grouped loggers**: Create loggers for different modules to make logs easier to filter and search. 4. **Don't log sensitive data**: Avoid logging passwords, tokens, or other sensitive information. 5. **Initialize request context deliberately**: Wrap the request before expecting automatic enrichment, or pass identifiers explicitly at the log call. 6. **Log at appropriate times**: Log important state changes, errors, and significant events, but avoid excessive logging that can impact performance. ## Integration with Log Aggregation Services The JSON output in production is compatible with popular log aggregation services: - **Vercel Logs**: Automatically captured in Vercel deployments - **Datadog**: Can parse JSON logs - **LogRocket**: Supports structured logging - **CloudWatch**: AWS CloudWatch Logs - **Google Cloud Logging**: Supports JSON logs ## Next Steps - Learn about [Sentry](/docs/starter-kits/pro-nextjs-prisma/observability/sentry) for error tracking - Check out [Vercel Analytics](/docs/starter-kits/pro-nextjs-prisma/observability/vercel) for traffic monitoring - Explore [Speed Insights](/docs/starter-kits/pro-nextjs-prisma/observability/speed-insights) for performance monitoring --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/observability/overview **Description**: Monitor your application's performance and track errors. The Pro Next.js Prisma starter kit includes structured logging and optional integrations for error, traffic and performance monitoring. Installing code is only the first step: Sentry and Vercel services must be enabled and verified in the environments where you expect them to collect data. ## What works after setup | Capability | Included in the repository | Required activation | | --------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------ | | Structured logs | Pino logger, grouped loggers and request context | Choose `NEXT_PUBLIC_LOG_LEVEL` and configure a log destination if needed | | Sentry | Client, server and edge instrumentation plus build config | Add a Sentry project and DSN; add build credentials for source maps | | Vercel Analytics | `` in the root layout | Enable Web Analytics for the deployed Vercel project | | Vercel Speed Insights | `` in the root layout | Enable Speed Insights for the deployed Vercel project | Leaving a provider unconfigured should not stop the application from running, but that provider will not give you useful production telemetry. ## Verify production monitoring After deploying, prove that each enabled signal reaches its destination: 1. Write a uniquely named structured log and find it in the deployment logs. 2. Send a controlled test exception to Sentry and confirm that its stack trace resolves to the original source. 3. Visit two or three production routes, then confirm page views appear in Vercel Analytics. 4. Load a public page from a real browser and confirm Speed Insights begins collecting field data. This data may not appear immediately. 5. Remove the test exception after verification and configure alerts for business-critical failures such as authentication, checkout and webhooks. Use separate Sentry environments or projects for preview and production. Development noise and deliberate test failures should not trigger production incident alerts. --- ## Sentry **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/observability/sentry **Description**: Learn how to configure and use Sentry for error tracking and performance monitoring. The starter kit includes a Sentry integration for client, server and edge error tracking. It remains inactive in development and when `NEXT_PUBLIC_SENTRY_DSN` is not configured. ## Setup ### 1. Create a Sentry Account 1. Go to [sentry.io](https://sentry.io/welcome/) and create an account 2. Create a new project (select **Next.js** as the platform) 3. Copy your **DSN** (Data Source Name) ### 2. Configure Environment Variables Add your Sentry credentials to your `.env` file: ```env filename=".env" lineNumbers NEXT_PUBLIC_SENTRY_DSN="https://xxxxx@xxxxx.ingest.sentry.io/xxxxx" SENTRY_ORG="your-org" SENTRY_PROJECT="your-project" SENTRY_AUTH_TOKEN="sntrys_xxxxx" ``` Getting Your Auth Token Create an auth token in Sentry: Settings →{' '} Auth TokensCreate New Token. Grant it{' '} project:read and project:releases scopes. ### 3. Verify Configuration Sentry is automatically configured in `instrumentation.ts`, `instrumentation-server.ts`, `instrumentation-edge.ts`, and `instrumentation-client.ts`. The configuration includes: - Automatic error capture for client and server - Performance monitoring - Production source map uploads through `withSentryConfig` in `next.config.ts` - User and request context for tRPC procedures ## Automatic Instrumentation Sentry is pre-configured to automatically capture: ### Client-Side - Unhandled JavaScript exceptions - Unhandled promise rejections - React component errors (via Error Boundaries) - Performance metrics (Core Web Vitals) - User session replays (optional) ### Server-Side - API route errors - Server component errors - Server action errors - tRPC procedure errors (via Sentry tRPC middleware) - Database query errors ## tRPC Integration The starter kit uses the **Sentry tRPC middleware** to automatically capture errors and performance metrics from all tRPC procedures. The middleware is configured in your tRPC setup and automatically: - Captures all tRPC procedure errors with full context - Tracks procedure execution time and performance - Associates errors with the procedure path and input parameters - Includes user context when available The middleware is automatically applied to all procedures, so you don't need to manually instrument your tRPC endpoints. The shipped tRPC middleware uses attachRpcInput: true and adds the authenticated user's ID and email to the Sentry scope. Server instrumentation also sets sendDefaultPii: true. Remove unnecessary input fields, disable default PII or scrub events in{' '} beforeSend before processing real customer data. Never send passwords, tokens, payment details or other secrets to Sentry. ## Manual Error Tracking ### Capture Errors You can manually capture errors in your code: ```typescript filename="lib/actions/example.ts" lineNumbers import * as Sentry from '@sentry/nextjs'; try { // Your code } catch (error) { Sentry.captureException(error, { tags: { section: 'billing' }, extra: { userId: user.id, amount: 100 } }); throw error; } ``` ### Capture Messages Log important events: ```typescript filename="lib/actions/example.ts" lineNumbers import * as Sentry from '@sentry/nextjs'; Sentry.captureMessage('Payment processed', { level: 'info', tags: { feature: 'billing' } }); ``` ### Set User Context Associate errors with users: ```typescript filename="lib/auth/session.ts" lineNumbers import * as Sentry from '@sentry/nextjs'; export function setSentryUser(user: { id: string; email: string }) { Sentry.setUser({ id: user.id, email: user.email }); } ``` ## Performance Monitoring Sentry automatically tracks: - **Page Load Performance** - Time to first byte, first contentful paint - **API Route Performance** - Response times for API routes - **Core Web Vitals** - LCP, FID, CLS metrics ### Custom Performance Monitoring Track custom operations using `startSpan`: ```typescript filename="lib/actions/example.ts" lineNumbers import * as Sentry from '@sentry/nextjs'; await Sentry.startSpan( { name: 'Process Payment', op: 'payment' }, async () => { // Your payment processing code await processPayment(); } ); ``` ## Source Maps Source maps are automatically uploaded during build to provide readable stack traces in production. ### Configuration Source maps are configured by `withSentryConfig` in `next.config.ts`. During a production Vercel or CI build, the configuration: 1. Generates source maps during build 2. Uploads them to Sentry (if `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, and `SENTRY_PROJECT` are set) 3. Associates them with releases ### Releases Releases help you track which version of your code caused an error. They are automatically configured in the instrumentation files: ```typescript filename="instrumentation-server.ts" lineNumbers import { init } from '@sentry/nextjs'; import { env } from '@/lib/env'; init({ dsn: env.NEXT_PUBLIC_SENTRY_DSN, environment: process.env.NODE_ENV // Release is automatically set by @sentry/nextjs from Vercel environment variables }); ``` ## Session Replay Session Replay is already enabled in `instrumentation-client.ts` when Sentry is active. The shipped sample rates capture 10% of ordinary sessions and 100% of sessions containing an error: ```typescript filename="instrumentation-client.ts" lineNumbers import { init, replayIntegration } from '@sentry/nextjs'; init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, integrations: [replayIntegration()], replaysSessionSampleRate: 0.1, replaysOnErrorSampleRate: 1.0 }); ``` Privacy Note Session Replay can capture sensitive data. Review Sentry's masking defaults, add product-specific masking rules and reduce or disable the sample rates if replay is not appropriate for your application. ## Filtering Errors The starter kit already includes error filtering in `instrumentation-server.ts` and `instrumentation-edge.ts`. You can customize it: ```typescript filename="instrumentation-server.ts" lineNumbers import { init } from '@sentry/nextjs'; import { env } from '@/lib/env'; init({ dsn: env.NEXT_PUBLIC_SENTRY_DSN, beforeSend(event) { // Filter out specific errors const exception = event.exception?.values?.[0]; if (exception?.value?.includes('ResizeObserver')) { return null; // Don't send this error } return event; } }); ``` ## Environment-Specific Configuration Sentry is automatically disabled in development. The configuration in `instrumentation-server.ts` and `instrumentation-edge.ts` includes: - Automatic disabling in development mode - Sample rate of 0.1 (10%) for performance traces - Error filtering for common noisy errors (TRPCError NOT_FOUND, ChunkLoadError, network errors) ## Best Practices 1. **Set appropriate sample rates** - Use lower sample rates in production to reduce costs 2. **Filter sensitive data** - Don't send passwords, tokens, or PII 3. **Use tags** - Add tags to categorize errors (e.g., `feature: "billing"`) 4. **Set user context** - Always set user context for better error tracking 5. **Monitor performance** - Track slow operations and optimize them 6. **Review errors regularly** - Set up alerts for critical errors --- ## Vercel Speed Insights **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/observability/speed-insights **Description**: Learn how to use Vercel Speed Insights for real-time performance monitoring. The starter kit already renders **Vercel Speed Insights** from the root layout. Enable it in Vercel to collect performance measurements from real visits to your deployed application. ## What is already configured The repository includes `@vercel/speed-insights` and renders its Next.js component in `app/layout.tsx`: ```tsx filename="app/layout.tsx" lineNumbers import { SpeedInsights } from '@vercel/speed-insights/next'; // Inside the root layout body ; ``` No environment variable is required. ## Enable Speed Insights 1. Open your project in the [Vercel dashboard](https://vercel.com/dashboard). 2. Select **Speed Insights** in the project sidebar. 3. Enable Speed Insights. 4. Deploy the application again. 5. Visit several pages on the deployed application, then check the Speed Insights dashboard. Expect real-user data Speed Insights is not a synthetic benchmark. Results appear as people use the deployed application, so a new or low-traffic project may take time to show a representative score. ## Read the dashboard Start with the page and device filters, then inspect the metrics that explain the experience: - **LCP** measures how quickly the main content becomes visible. - **INP** measures responsiveness across user interactions. - **CLS** measures unexpected layout movement. - **FCP** measures when the first content is painted. - **TTFB** helps identify slow initial server responses. Use field data to identify a slow page or device class. Reproduce the problem locally with browser performance tools before changing code. ## Control usage The default component sends all supported measurements. If usage becomes material, reduce the sample rate in the existing root-layout integration: ```tsx filename="app/layout.tsx" lineNumbers ``` A value of `0.5` samples approximately half of eligible page views. Keep the default while traffic is low so early measurements are not unnecessarily sparse. ## Verify the integration If no data appears: 1. Confirm Speed Insights is enabled for the correct Vercel project. 2. Confirm the latest deployment contains ``. 3. Visit the deployed application with tracking protection or ad blockers disabled. 4. Check that a reverse proxy or Content Security Policy is not blocking Vercel's collection requests. 5. Allow time for real visits to produce data. See the [Vercel Speed Insights quickstart](https://vercel.com/docs/speed-insights/quickstart) for current provider setup details and the [usage guide](https://vercel.com/docs/speed-insights/managing-usage) before changing the sample rate. --- ## Vercel Analytics **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/observability/vercel **Description**: Learn how to use Vercel Analytics for real-time traffic data. The starter kit already renders **Vercel Web Analytics** from the root layout. You only need to enable the service for your Vercel project and deploy the application. ## What is already configured The repository includes `@vercel/analytics` and renders its Next.js component in `app/layout.tsx`: ```tsx filename="app/layout.tsx" lineNumbers import { Analytics } from '@vercel/analytics/next'; // Inside the root layout body ; ``` This records page views after Web Analytics is enabled. It does not require an environment variable. ## Enable Web Analytics 1. Open your project in the [Vercel dashboard](https://vercel.com/dashboard). 2. Select **Analytics** in the project sidebar. 3. Enable Web Analytics. 4. Deploy the application again so Vercel can add the analytics routes to the deployment. 5. Visit the deployed application, then return to the Analytics dashboard to confirm that data arrives. Dashboard activation is required The package and component are already present, but they do not replace enabling Web Analytics for the Vercel project. ## Track a product event Automatic page views answer traffic questions. Add custom events only for product actions that matter, such as completing onboarding or starting checkout: ```tsx filename="components/checkout-button.tsx" lineNumbers 'use client'; import { track } from '@vercel/analytics'; export function CheckoutButton() { return ( ); } ``` Do not send email addresses, names or other personal data as event properties. ## Verify the integration If the dashboard remains empty: 1. Confirm Web Analytics is enabled for the correct Vercel project. 2. Confirm the deployment was created after activation. 3. Visit the production deployment with tracking protection or ad blockers disabled. 4. Check that requests to `/_vercel/insights/*` are not blocked by a reverse proxy or Content Security Policy. 5. Confirm `app/layout.tsx` still renders ``. Use [Vercel's Web Analytics troubleshooting guide](https://vercel.com/docs/analytics/troubleshooting) for provider-specific diagnostics. ## Privacy and consent Web Analytics is designed without cookies, but privacy obligations depend on your users, configuration and jurisdiction. Document the service in your privacy notice and review [Vercel's privacy guidance](https://vercel.com/docs/analytics/privacy-policy) before launch. Do not treat the default integration as a substitute for legal review. --- ## Configure **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/organizations/configure **Description**: Learn how to configure organizations in your application. Organizations are configured in the Better Auth setup in `lib/auth/index.ts`. The organization plugin is already enabled by default. ## Organization Plugin Configuration The organization plugin is configured in `lib/auth/index.ts`: ```typescript filename="lib/auth/index.ts" lineNumbers import { organization } from 'better-auth/plugins'; export const auth = betterAuth({ // ... other config plugins: [ organization({ // Organization configuration }) ] }); ``` ## Default Behavior By default, organizations are: - **Enabled** - Users can create and join organizations - **Optional** - Users don't need to be in an organization to use the app - **Visible** - Organization selection is shown in the UI - **User-creatable** - Users can create new organizations ## Customizing Organization Behavior ### Require Organization To require users to be in an organization to access the application, you can add middleware or route protection: ```typescript filename="middleware.ts" lineNumbers import { redirect } from 'next/navigation'; import { getSession } from '@/lib/auth/server'; export async function middleware(request: NextRequest) { const session = await getSession(); if (!session) { return redirect('/auth/sign-in'); } // Check if user has an active organization if (!session.session.activeOrganizationId) { // Redirect to organization creation/selection return redirect('/dashboard/onboarding'); } } ``` ### Hide Organization Selection If you want to build a single-tenant application where users should only be members of one organization, you can hide the organization switcher in your UI components. ### Disable Organization Creation To block regular users from creating organizations through the starter's tRPC procedure, change the shipped app configuration: ```typescript filename="config/app.config.ts" lineNumbers export const appConfig = { // ... other config organizations: { allowUserCreation: false } }; ``` The `trpc.organization.create` procedure enforces this setting for non-admin users. Platform admins can still create organizations through that procedure. The setting does not configure Better Auth's organization endpoint. Passing `allowUserToCreateOrganization: false` to that plugin blocks everyone through the direct endpoint. Use a function for an endpoint policy that still permits selected users such as platform admins. ## Shipped Organization Hooks The Better Auth organization plugin is configured in `lib/auth/index.ts`. The shipped integration provides a custom invitation email callback and hooks that synchronize subscription seats after membership changes: ```typescript filename="lib/auth/index.ts" lineNumbers organization({ sendInvitationEmail: async ({ email, inviter, id, organization }) => { // Check plan limits, build the invitation URL and send the email. }, organizationHooks: { afterAddMember: async ({ organization }) => { await syncOrganizationSeats(organization.id); }, afterRemoveMember: async ({ organization }) => { await syncOrganizationSeats(organization.id); }, afterAcceptInvitation: async ({ organization }) => { await syncOrganizationSeats(organization.id); } } }); ``` The repositories do not pass `memberRoles`, `invitation.expiresIn` or a top-level `hooks` object to this plugin. Add only options supported by the installed Better Auth version. ## Invite-Only Organizations There is no single invite-only organization switch. The starter includes member invitations and the `allowUserCreation` setting, but you must compose and enforce the policy your product needs. For an invite-only organization setup: 1. **Disable organization creation** - Remove or restrict the create organization functionality 2. **Require invitations** - Only allow users to join via invitations 3. **Control invitations** - Only allow admins/owners to send invitations The Better Auth organization plugin supplies the invitation workflow. Your application must still enforce who may create organizations and send invitations on every server-side path. ## Best Practices 1. **Use organization slugs** - Use URL-friendly slugs for organization identification 2. **Validate membership** - Always verify user membership before allowing access 3. **Scope data** - Always scope data queries by organization ID 4. **Handle edge cases** - Handle cases where users have no organizations 5. **Role-based access** - Use roles to control what users can do --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/organizations/overview **Description**: Learn how organizations work in the starter kit. Organizations are a way to share data between users. Organizations can have members with different roles and permissions. In the starter kit, organizations are handled by [Better Auth](https://better-auth.com) and therefore you have full control over the organization management and all data is stored in the database. ## How Organizations Work Organizations enable multi-tenancy in your application. Users can: - **Create organizations** - Users can create their own organizations - **Join organizations** - Users can be invited to join organizations - **Switch between organizations** - Users can be members of multiple organizations - **Have different roles** - Users can have different roles in different organizations ## Active Organization The active organization is stored in the Better Auth session. The `activeOrganizationId` is available in `session.activeOrganizationId` and can be accessed using Better Auth's hooks and APIs. This approach provides several benefits: 1. **Session-based** - The active organization persists across page navigations 2. **Simple access** - Use `authClient.useActiveOrganization()` on the client or `getSession()` on the server 3. **Automatic scoping** - tRPC's `protectedOrganizationProcedure` automatically uses the active organization 4. **Consistent state** - The active organization is managed by Better Auth and stays in sync ## Roles Organizations support the following roles: - **Owner** - Full control over the organization - **Admin** - Can manage members and organization settings - **Member** - Can access organization data A user can have different roles in different organizations. ## Default Permission Matrix The shipped settings and billing flows use the following policy: | Action | Owner | Admin | Member | Outsider | | ------------------------ | ----- | ------- | ------ | -------- | | Delete organization | Yes | No | No | No | | Manage billing | Yes | Yes | No | No | | Invite or revoke members | Yes | Yes | No | No | | Change roles | Yes | Limited | No | No | | Upload organization logo | Yes | Yes | No | No | An admin can change member and admin roles, but cannot modify an owner or assign the owner role. Application-level administrators do not automatically receive access to an organization: they must also be a member with the required organization role. Use the shared helpers in `lib/auth/organization-permissions.ts` for custom organization procedures instead of inferring access from the user's global role. --- ## Store Data **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/organizations/store-data **Description**: Learn how to store data for organizations in your application. When working with organizations, you typically want to store data that belongs to each organization and can be accessed by organization members. ## Database Schema ### Add Organization ID to Your Tables Add an `organizationId` field to tables that should be scoped to organizations: ```prisma filename="prisma/schema.prisma" lineNumbers model Post { id String @id @default(cuid()) title String content String authorId String author User @relation(fields: [authorId], references: [id], onDelete: Cascade) organizationId String organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model Organization { // ... other fields posts Post[] } model User { // ... other fields posts Post[] } ``` This allows: - All members of an organization to access the posts - The author to be tracked separately - Data to be properly scoped to organizations ## Creating Organization-Scoped Data ### Using tRPC Use `protectedOrganizationProcedure` to automatically scope data to the active organization: ```typescript filename="trpc/routers/posts.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; import { z } from 'zod'; import { assertUserIsOrgMember } from '@/lib/auth/server'; import { prisma } from '@/lib/db'; export const postsRouter = createTRPCRouter({ create: protectedOrganizationProcedure .input( z.object({ title: z.string().min(1), content: z.string().min(1) }) ) .mutation(async ({ input, ctx }) => { // ctx.organization is guaranteed to exist // User membership is already verified const post = await prisma.post.create({ data: { title: input.title, content: input.content, authorId: ctx.user.id, organizationId: ctx.organization.id } }); return post; }) }); ``` ### Verifying Membership If you need to verify membership manually: ```typescript filename="lib/auth/verify-membership.ts" lineNumbers import { assertUserIsOrgMember } from '@/lib/auth/server'; export async function verifyMembership(organizationId: string, userId: string) { // This will throw an error if user is not a member const { organization, membership } = await assertUserIsOrgMember( organizationId, userId ); return { organization, membership }; } ``` ## Querying Organization Data ### List Organization Posts Query posts for the active organization: ```typescript filename="trpc/routers/posts.ts" lineNumbers list: protectedOrganizationProcedure.query(async ({ ctx }) => { // Automatically scoped to ctx.organization.id const posts = await prisma.post.findMany({ where: { organizationId: ctx.organization.id, }, orderBy: { createdAt: "desc", }, }); return posts; }), ``` ### Get Single Post Get a single post, ensuring it belongs to the organization: ```typescript filename="trpc/routers/posts.ts" lineNumbers getById: protectedOrganizationProcedure .input(z.object({ id: z.string() })) .query(async ({ input, ctx }) => { const post = await prisma.post.findFirst({ where: { id: input.id, organizationId: ctx.organization.id, }, }); if (!post) { throw new TRPCError({ code: "NOT_FOUND", message: "Post not found", }); } return post; }), ``` ## Client-Side Usage ### Creating Posts Create posts from the UI: ```tsx filename="components/create-post-form.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; import { useForm } from 'react-hook-form'; import { useActiveOrganization } from '@/hooks/use-active-organization'; export function CreatePostForm() { const { activeOrganization } = useActiveOrganization(); const utils = trpc.useUtils(); const createPost = trpc.posts.create.useMutation({ onSuccess: () => { utils.posts.list.invalidate(); } }); const onSubmit = async (data: { title: string; content: string }) => { if (!activeOrganization) { throw new Error('No active organization'); } await createPost.mutateAsync(data); }; return
{/* form fields */}
; } ``` ### Listing Posts List posts for the active organization: ```tsx filename="components/posts-list.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function PostsList() { const { data: posts, isLoading } = trpc.posts.list.useQuery(); if (isLoading) return
Loading...
; if (!posts?.length) return
No posts found
; return (
{posts.map((post) => (

{post.title}

{post.content}

))}
); } ``` ## Updating Organization Data ### Update with Permission Check Only allow admins to update organization-scoped data: ```typescript filename="trpc/routers/posts.ts" lineNumbers update: protectedOrganizationProcedure .input( z.object({ id: z.string(), title: z.string().optional(), content: z.string().optional(), }) ) .mutation(async ({ input, ctx }) => { // Check if user is admin or owner const isAdmin = ctx.membership.role === "admin" || ctx.membership.role === "owner"; const post = await prisma.post.findFirst({ where: { id: input.id, organizationId: ctx.organization.id, }, }); if (!post) { throw new TRPCError({ code: "NOT_FOUND" }); } // Only author or admin can update if (post.authorId !== ctx.user.id && !isAdmin) { throw new TRPCError({ code: "FORBIDDEN", message: "You can only edit your own posts or be an admin", }); } const updatedPost = await prisma.post.update({ where: { id: input.id }, data: { title: input.title, content: input.content, }, }); return updatedPost; }), ``` ## Best Practices 1. **Always scope by organizationId** - Never query without organization scope 2. **Use protectedOrganizationProcedure** - Automatically handles scoping 3. **Verify membership** - Always verify user is a member before operations 4. **Check permissions** - Verify roles before allowing modifications 5. **Cascade deletes** - Use `onDelete: "cascade"` for organization-scoped data 6. **Index organizationId** - Add database indexes on `organizationId` for performance --- ## Use Organizations **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/organizations/use-organizations **Description**: Learn how to use organizations in your application. In the starter kit, the active organization is stored in the Better Auth session. The active organization ID is available in `session.activeOrganizationId` and can be accessed using Better Auth's hooks and APIs. ## How Active Organization Works The active organization is managed by Better Auth and stored in the user's session. When a user switches organizations, the `activeOrganizationId` in the session is updated, and this organization becomes available throughout your application. ## Client-Side Usage ### Using Better Auth's Hook Use `authClient.useActiveOrganization()` to access the active organization: ```tsx filename="components/organization-content.tsx" lineNumbers 'use client'; import { authClient } from '@/lib/auth/client'; export function OrganizationContent() { const { data: activeOrganization, isPending } = authClient.useActiveOrganization(); if (isPending) { return
Loading...
; } if (!activeOrganization) { return
No active organization found
; } return (

{activeOrganization.name}

); } ``` ### Switching Organizations Switch organizations using `authClient.organization.setActive()`: ```tsx filename="components/organization-switcher.tsx" lineNumbers 'use client'; import { useRouter } from 'next/navigation'; import { authClient } from '@/lib/auth/client'; export function OrganizationSwitcher() { const router = useRouter(); const handleSwitch = async (organizationId: string) => { // Set the active organization in Better Auth session await authClient.organization.setActive({ organizationId }); // Navigate to the organization dashboard router.push('/dashboard/organization'); }; return ( ); } ``` ### Getting Active Organization from Session You can also access the active organization ID directly from the session: ```tsx filename="components/example.tsx" lineNumbers 'use client'; import { useSession } from '@/hooks/use-session'; export function Example() { const { session } = useSession(); const activeOrganizationId = session?.activeOrganizationId; return
Active Org ID: {activeOrganizationId}
; } ``` ## Server-Side Usage ### Get Active Organization from Session Get the active organization from the session: ```tsx filename="app/(saas)/dashboard/organization/page.tsx" lineNumbers import { getOrganizationById, getSession } from '@/lib/auth/server'; export default async function OrganizationPage() { const session = await getSession(); if (!session?.session.activeOrganizationId) { return
No active organization
; } const organization = await getOrganizationById( session.session.activeOrganizationId ); if (!organization) { return
Organization not found
; } return
Active organization: {organization.name}
; } ``` ### Get Organization by ID Get organization data for a specific organization ID: ```typescript filename="lib/organization/get-organization.ts" lineNumbers import { getOrganizationById } from '@/lib/auth/server'; export async function getOrganization(organizationId: string) { const organization = await getOrganizationById(organizationId); return organization; } ``` ### Using in tRPC The active organization is automatically available in `protectedOrganizationProcedure`: ```typescript filename="trpc/routers/organization/index.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; export const organizationRouter = createTRPCRouter({ get: protectedOrganizationProcedure.query(async ({ ctx }) => { // ctx.organization is guaranteed to exist // ctx.membership contains the user's role return { organization: ctx.organization, role: ctx.membership.role }; }), update: protectedOrganizationProcedure .input(z.object({ name: z.string() })) .mutation(async ({ input, ctx }) => { // Only allow admins/owners to update if (ctx.membership.role !== 'admin' && ctx.membership.role !== 'owner') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only admins can update organizations' }); } // Update organization using Better Auth API await authClient.organization.update({ organizationId: ctx.organization.id, name: input.name }); return { success: true }; }) }); ``` ## Organization Switching ### Client-Side Switching Switch organizations using Better Auth's API: ```tsx filename="components/organization-switcher.tsx" lineNumbers 'use client'; import { useRouter } from 'next/navigation'; import { authClient } from '@/lib/auth/client'; export function OrganizationSwitcher() { const router = useRouter(); const handleSwitch = async (organizationId: string) => { try { // Set the active organization in Better Auth session await authClient.organization.setActive({ organizationId }); // Navigate to the organization dashboard router.push('/dashboard/organization'); } catch (error) { console.error('Failed to switch organization:', error); } }; return ( ); } ``` ### Server-Side Switching Update the active organization in the session: ```typescript filename="app/api/organization/switch/route.ts" lineNumbers import { headers } from 'next/headers'; import { NextResponse } from 'next/server'; import { auth } from '@/lib/auth'; import { assertUserIsOrgMember, getSession } from '@/lib/auth/server'; export async function POST(request: Request) { const { organizationId } = await request.json(); const session = await getSession(); if (!session) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } // Verify user is member of organization await assertUserIsOrgMember(organizationId, session.user.id); // Update active organization in session await auth.api.setActiveOrganization({ headers: await headers(), body: { organizationId } }); return NextResponse.json({ success: true }); } ``` ## Listing User's Organizations Get all organizations a user is a member of: ```typescript filename="trpc/routers/organization/index.ts" lineNumbers import { createTRPCRouter, protectedProcedure } from '@/trpc/init'; import { prisma } from '@/lib/db'; export const organizationRouter = createTRPCRouter({ list: protectedProcedure.query(async ({ ctx }) => { const organizations = await prisma.organization.findMany({ where: { members: { some: { userId: ctx.user.id } } }, orderBy: { createdAt: 'asc' }, include: { _count: { select: { members: true } } } }); return organizations.map((org) => ({ ...org, slug: org.slug ?? '', membersCount: org._count.members })); }) }); ``` Client-side: ```tsx filename="components/organizations-list.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function OrganizationsList() { const { data: organizations, isLoading } = trpc.organization.list.useQuery(); if (isLoading) return
Loading...
; return (
{organizations?.map((org) => (
{org.name}
))}
); } ``` ## Best Practices 1. **Always check membership** - Verify user is a member before allowing access 2. **Use protectedOrganizationProcedure** - Automatically handles organization scoping 3. **Handle loading states** - Show loading indicators while fetching organization 4. **Handle missing organizations** - Provide fallback UI when no organization is active 5. **Validate permissions** - Check roles before allowing actions 6. **Use session-based approach** - The active organization is stored in the session, not the URL --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/recipes/overview **Description**: Common recipes and guides to help you extend your application. Recipes are step-by-step guides for common tasks and integrations that help you extend your application with additional features and services. --- ## Supabase Setup **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/recipes/supabase-setup **Description**: Learn how to set up your application with Supabase as the database and storage provider. In this guide, we'll show you how to set up your application with Supabase as the database and storage provider. Note We will use Supabase as the database and storage provider. The authentication feature of Supabase is not used, as we use Better Auth for authentication, which stores user data directly in your database. Before we start, make sure you have a Supabase account. If you don't have one yet, you can create one for free at [supabase.com](https://supabase.com/). ## 1. Create a new Supabase project 1. Go to [supabase.com](https://supabase.com/) and sign in 2. Click **New Project** 3. Fill in your project details: - **Name**: Your project name - **Database Password**: Choose a strong password (save this!) - **Region**: Choose a region close to your deployment (see note below) Region Selection Make sure your application and database are physically close. If you use Vercel and Supabase, make sure they are in the same AWS region. For example: - Vercel (D.C.) and Supabase (Virginia): Both are in the us-east-1 region, which results in a fast application - Vercel (D.C.) and Supabase (Ohio): You are dealing with two different AWS regions, the app can be up to 3-12x slower! ## 2. Get connection strings In the Supabase dashboard, click the **Connect** button in the top row. Select the **ORM** tab and **Prisma** as the tool. The shipped repository uses `DATABASE_URL` for both the application and Prisma CLI. If you want to keep a pooled runtime URL and a direct migration URL separate, copy both connection strings and apply the optional configuration in the next steps. ## 3. Set environment variables Open your `.env` file and set the environment variables as follows: ```env filename=".env" lineNumbers # Connection pooling URL (for runtime - recommended for production) DATABASE_URL="postgres://postgres.[your-supabase-project]:[password]@aws-0-[aws-region].pooler.supabase.com:6543/postgres?pgbouncer=true" # Direct connection URL (for migrations and Prisma CLI) DIRECT_URL="postgresql://postgres:[password]@db.[your-project-ref].supabase.co:5432/postgres" ``` Important Make sure to replace the password and project ref placeholders with your own values from the Supabase dashboard. ## 4. Optionally separate the Prisma migration URL The shipped `prisma.config.ts` reads `DATABASE_URL`. To use the pooled URL at runtime while sending Prisma CLI operations through a direct connection, add `DIRECT_URL` to `.env` and change the Prisma config as shown below. Update your `prisma/schema.prisma` file or create a `prisma.config.ts` file: ```typescript filename="prisma.config.ts" lineNumbers import 'dotenv/config'; import { defineConfig, env } from 'prisma/config'; export default defineConfig({ schema: './prisma/schema.prisma', datasource: { url: env('DIRECT_URL') } }); ``` At runtime, Prisma Client will use the pooled `DATABASE_URL` from your environment variables. This keeps the direct connection string scoped to Prisma CLI workflows (migrations and introspection) while your application connections continue to flow through Supavisor connection pooling. ## 5. Run migrations To push the database schema to Supabase, run the following command: ```bash filename="Terminal" lineNumbers npm run db:push ``` Or if you prefer to use migrations: ```bash filename="Terminal" lineNumbers npm run db:migrate:dev ``` Database access and RLS The shipped app uses Prisma on the server. It does not configure the Supabase Data API or map Better Auth sessions or JWT claims into RLS policies. Better Auth does not replace RLS. If you expose the Data API or query Supabase from client code, design and test RLS policies for every exposed table first. ## 6. Connect Supabase storage for file uploads To enable the shipped user avatar and organization logo uploads, you can use Supabase Storage through its S3-compatible endpoint. ### Create a storage bucket 1. Go to the **Storage** tab in the Supabase dashboard 2. Click the **Create bucket** button 3. Name the bucket, for example `avatars` 4. Deactivate the **Public bucket** switch to prevent direct anonymous object access 5. Optionally, define a maximum file size and restrict file types for this bucket Application access remains public by key The private bucket setting does not add authorization to the starter kit. The shipped /storage/[...path] route does not read a session or check ownership. Anyone who knows an image key can ask the application for a signed download redirect. Add a protected route and file ownership metadata before storing private files. ### Get storage credentials 1. Navigate to **Project settings** from the sidebar 2. Select the **Storage** tab 3. Scroll down to the **S3 access keys** section 4. Click the **New access key** button 5. Enter a description for your access key 6. After clicking **Create access key**, copy the **Access key ID** and **Secret access key** ### Configure environment variables Add the following environment variables to your `.env` file: ```env filename=".env" lineNumbers S3_ACCESS_KEY_ID="your-access-key" S3_SECRET_ACCESS_KEY="your-secret-key" S3_ENDPOINT="https://[YOUR-PROJECT-REF].storage.supabase.co/storage/v1/s3" S3_REGION="[YOUR-PROJECT-REGION]" NEXT_PUBLIC_IMAGES_BUCKET_NAME="avatars" ``` Copy the endpoint and region shown with the S3 access keys in the Supabase dashboard. The region participates in request signing, so do not substitute a region from another project. ## 7. Confirm storage configuration No storage code change is required for the bucket name. The shipped configuration reads `NEXT_PUBLIC_IMAGES_BUCKET_NAME`: ```typescript filename="config/storage.config.ts" lineNumbers import { env } from '@/lib/env'; export const storageConfig = { bucketNames: { images: env.NEXT_PUBLIC_IMAGES_BUCKET_NAME ?? '' } } satisfies StorageConfig; ``` ## 8. Run development server Now you should be able to start the development server: ```bash filename="Terminal" lineNumbers npm run dev ``` The Prisma client is automatically generated during migrations. If you need to generate it manually, you can run: ```bash filename="Terminal" lineNumbers npm run db:generate ``` ## Troubleshooting ### Connection issues If you're experiencing connection issues: 1. Verify your connection string is correct 2. Check that your IP is allowed in Supabase (if IP restrictions are enabled) 3. Ensure you're using the correct region 4. Try using the direct connection URL instead of the pooled connection ### Migration issues If migrations fail: 1. If you opted into separate runtime and migration URLs, verify that migrations use `DIRECT_URL` 2. Check that your database password is correct 3. Verify that your project has the necessary permissions 4. If you kept the shipped single-URL setup, ensure `prisma.config.ts` and the application both use `DATABASE_URL` ### Storage issues If file uploads aren't working: 1. Verify your S3 credentials are correct 2. Check that the bucket exists and is accessible 3. Ensure the bucket name matches your configuration 4. Verify the endpoint URL is correct That's all it takes to set up your application with Supabase! If you have questions or need help, refer to the [Supabase documentation](https://supabase.com/docs). --- ## Setup **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/setup **Description**: Get your Pro Next.js Prisma project up and running in less than 30 minutes. This guide will walk you through the steps to set up your project locally and start developing. ## Prerequisites Install these before cloning the repository: - Node.js `22.21.1`, matching the version in `package.json` - npm, included with Node.js - Git - Docker Desktop or another PostgreSQL 17 installation - A Resend account for password signups. A verified domain is required before sending to arbitrary recipients, but Resend's test sender can be used with your own account email during initial local setup. Start from a clean baseline Complete this guide and verify the included application before renaming the product, replacing providers or moving routes. This keeps setup failures separate from customization failures. ## Step 1: Clone the Repository Clone the project to your local machine: ```bash filename="Terminal" lineNumbers git clone my-saas-app cd my-saas-app ``` ## Step 2: Install Dependencies We use `npm` for dependency management: ```bash filename="Terminal" lineNumbers npm install ``` ## Step 3: Configure Environment Variables Copy the example environment file: ```bash filename="Terminal" lineNumbers cp .env.example .env ``` Generate a secret for Better Auth and add it to `.env`: You can use the generated value above or create one from your terminal: ```bash filename="Terminal" lineNumbers openssl rand -base64 32 ``` ```ini filename=".env" lineNumbers BETTER_AUTH_SECRET="paste-the-generated-secret-here" ``` ## Step 4: Database Setup 1. Make sure you have PostgreSQL running. We provide a `docker-compose.yml` for convenience: ```bash filename="Terminal" lineNumbers npm run docker:up ``` 2. The database is automatically created by Docker Compose. If you're using a local PostgreSQL installation, create the database: ```bash filename="Terminal" lineNumbers createdb database ``` 3. Update `DATABASE_URL` in `.env` to match your local setup: ```ini filename=".env" lineNumbers # For Docker (default): DATABASE_URL="postgresql://postgres:password@localhost:5432/database" # For local PostgreSQL: DATABASE_URL="postgresql://your_user:your_password@localhost:5432/database" ``` 4. Apply the migrations committed with the starter kit: ```bash filename="Terminal" lineNumbers npm run db:migrate ``` Start from the committed migration history A fresh clone already contains the migrations required by the shipped schema. Applying them keeps your local database aligned with staging and production. When you later change `prisma/schema.prisma`, create and apply a development migration with a descriptive name: ```bash filename="Terminal" lineNumbers npm run db:migrate:dev -- --name describe_your_change ``` Commit the generated directory in `prisma/migrations/` with the schema change. On staging and production, run only `npm run db:migrate` against the target database. Do not author migrations during deployment. ## Step 5: Configure Email Email verification is required for password signups. Configure `RESEND_API_KEY` and `EMAIL_FROM` before creating an account. For the quickest local check, use `onboarding@resend.dev` as the sender and sign up with the email address attached to your Resend account. Verify a domain before testing other recipients or deploying the application. Follow the [email configuration guide](/docs/starter-kits/pro-nextjs-prisma/email/configuration) to create a Resend API key and verify your sending domain. ## Step 6: Start Development Server ```bash filename="Terminal" lineNumbers npm run dev ``` Open [http://localhost:3000](http://localhost:3000) - your app is running! ## Step 7: Create Your First Account 1. Go to [http://localhost:3000/auth/sign-up](http://localhost:3000/auth/sign-up) 2. Enter your name, email and password 3. Open the verification email sent through Resend 4. Click the link to verify your email 5. You're in! ## Step 8: Verify the Baseline Before customizing the product, confirm that the repository passes its included quality checks: ```bash filename="Terminal" lineNumbers npm run typecheck npm run lint npm run format npm run test -- --run ``` The explicit `--run` makes Vitest execute once and exit instead of opening its local watch workflow. Then verify these flows in the browser: - Create and verify an account - Create an organization - Invite a second member if you have another test email - Open account and organization settings - Confirm the dashboard loads without server errors The repository also includes authenticated Playwright coverage for sign-in, organizations, settings, two-factor authentication, AI credit enforcement and the admin area. Point `DATABASE_URL` at an isolated disposable test database, install the browser once and run the suite: ```bash filename="Terminal" lineNumbers npm run test:e2e:setup npm run test:e2e ``` The E2E seed resets deterministic users and authentication state. Never run it against a development database containing data you need or against any production database. See the [E2E testing guide](/docs/starter-kits/pro-nextjs-prisma/tests/e2e) for the fixture accounts and browser workflow. ## Step 9: Make Yourself an Admin The first user should be a platform admin to access the admin dashboard (`/dashboard/admin`). **Option A: Using Prisma Studio (Recommended)** ```bash filename="Terminal" lineNumbers # Open Prisma Studio npm run db:studio ``` 1. Open Prisma Studio (usually at [http://localhost:5555](http://localhost:5555)) in your browser 2. Click on the `user` table 3. Find your user and click to edit 4. Change `role` from `user` to `admin` 5. Save **Option B: Using SQL directly** ```bash filename="Terminal" lineNumbers # If using Docker (container name may vary based on directory name): docker compose exec postgres psql -U postgres -d database # If using local PostgreSQL: psql -d database # Then run: UPDATE "user" SET role = 'admin' WHERE email = 'your@email.com'; \q ``` Now you can access the admin panel at [http://localhost:3000/dashboard/admin](http://localhost:3000/dashboard/admin). ## Next Steps Keep the verified baseline working while you turn it into your product: 1. Bookmark the [common commands](/docs/starter-kits/pro-nextjs-prisma/codebase/commands) used for development, tests and local services. 2. Update the product name, theme and assets using the [customization guide](/docs/starter-kits/pro-nextjs-prisma/customization/overview). 3. Enable only the providers your product needs in [configuration](/docs/starter-kits/pro-nextjs-prisma/configuration). 4. Add or change the product-specific data model through the [Prisma database guide](/docs/starter-kits/pro-nextjs-prisma/database). 5. Complete the [production deployment checklist](/docs/starter-kits/pro-nextjs-prisma/deployment) before inviting real users. Change one subsystem at a time Keep authentication, email and the database working while you customize the product. Run the typecheck, lint and test commands after each meaningful change so failures remain easy to trace. --- ## Storage **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/storage **Description**: Understand the image storage integration that ships with the Pro Next.js Prisma starter kit. The starter kit includes an S3-compatible image storage integration for user avatars and organization logos. It is configured for Cloudflare R2 and can be adapted to another provider that supports the S3 API. Security boundary The upload signer requires an authenticated user. The download route does not. The shipped /storage/[...path] route will generate a one-hour signed download URL for anyone who knows a valid image key. A signed URL limits how long storage credentials can be used, but it does not prove file ownership or organization membership. ## What ships - `lib/storage/s3.ts` creates an S3 client and presigns `PutObject` and `GetObject` commands. - `trpc/routers/storage/index.ts` exposes an authenticated `storage.signedUploadUrl` mutation for the configured images bucket. - `app/storage/[...path]/route.ts` exposes a public image redirect route. - `hooks/use-storage.tsx` converts a stored image key into the public route URL. - Avatar and organization logo components crop an image, upload it directly and save its key through Better Auth. The included flow is intended for display images such as avatars and logos. The generated UUID-based keys make accidental discovery less likely, but an unguessable key is not authorization. ## What does not ship The repositories do not include: - A `File` model or file metadata table - User or organization ownership checks for storage objects - Private download, listing or deletion procedures - File quota enforcement, malware scanning or audit logs - Automatic object deletion when an avatar or logo is removed - Server-enforced file size or MIME type validation Add those controls before using the storage integration for invoices, exports, identity documents or other private files. See [Access Files](/docs/starter-kits/pro-nextjs-prisma/storage/access) for the required design changes. ## Environment variables ```env filename=".env" lineNumbers S3_ACCESS_KEY_ID="your-access-key" S3_SECRET_ACCESS_KEY="your-secret-key" S3_ENDPOINT="https://your-s3-endpoint" S3_REGION="auto" NEXT_PUBLIC_IMAGES_BUCKET_NAME="your-images-bucket" ``` `NEXT_PUBLIC_IMAGES_BUCKET_NAME` is public configuration. Keep the access key and secret key server-only. --- ## Access Files **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/storage/access **Description**: Understand the shipped public image route and how to add private file authorization. The kit stores image object keys rather than permanent provider URLs. `useStorage` converts those keys into a URL handled by the application. ## Shipped behavior The current route is `app/storage/[...path]/route.ts`: ```typescript filename="app/storage/[...path]/route.ts" lineNumbers import { NextResponse } from 'next/server'; import { storageConfig } from '@/config/storage.config'; import { getSignedUrl } from '@/lib/storage'; export const GET = async ( _req: Request, { params }: { params: Promise<{ path: string[] }> } ) => { const { path } = await params; const [bucket, filePath] = path; if (!(bucket && filePath)) { return new Response('Invalid path', { status: 400 }); } if (bucket === storageConfig.bucketNames.images) { const signedUrl = await getSignedUrl(filePath, bucket, 60 * 60); return NextResponse.redirect(signedUrl, { headers: { 'Cache-Control': 'max-age=3600' } }); } return new Response('Not found', { status: 404 }); }; ``` This route: - Is public and does not read a session - Allows only the configured images bucket - Generates a signed `GetObject` URL that expires after one hour - Caches the redirect for up to one hour - Does not query a file record or verify user or organization ownership Anyone who knows a valid key can request a signed download URL through this route. Keeping the bucket itself private prevents direct anonymous bucket access, but it does not make this application route private. ## Using `useStorage` ```tsx filename="components/user/user-avatar.tsx" lineNumbers import { useStorage } from '@/hooks/use-storage'; export function Image({ imageKey }: { imageKey: string }) { const src = useStorage(imageKey); return ( ); } ``` For a local key, the hook returns: ```text /storage/{NEXT_PUBLIC_IMAGES_BUCKET_NAME}/{imageKey} ``` If the value starts with `http`, the hook returns it unchanged. If the value is empty, it returns the optional fallback. ## Flat keys only Although the route uses a catch-all segment, the shipped handler reads only the first two segments: ```typescript const [bucket, filePath] = path; ``` The included avatar and logo components therefore use flat keys such as `user-id-uuid.png`. A nested key such as `users/user-id/avatar.png` will not be reconstructed by the current route. To support nested keys, change the handler to read `[bucket, ...filePath]` and join the remaining segments after validation. ## Signed URLs are not authorization A signed URL is a temporary bearer credential. Anyone who receives it can use it until it expires. Signing a URL proves that your server authorized the storage operation, but the shipped public route does not decide whether the requester owns the object. The included route is suitable for avatars and logos that are expected to be visible. Do not use it for private documents or tenant-confidential exports. ## Adding private file access The following work is a customization. It is not included in either Pro repository. 1. Add a file metadata table with the object key, bucket, owner or organization ID, content type, byte size and lifecycle status. 2. Create object keys on the server from the authenticated user or active organization. Do not accept an unrestricted owner prefix from the client. 3. Replace the public image route for private files with a protected tRPC procedure or route handler. 4. Load the file record and verify current organization membership and resource permission before signing a short-lived download URL. 5. Use private cache headers or `no-store` for protected redirects. 6. Add rate limits, access logs and deletion cleanup for your requirements. Keep public display images and private documents in separate buckets or separate route policies. This makes it harder to expose a private object through the convenience image route. ## Listing and deleting The storage module exports only `getSignedUploadUrl` and `getSignedUrl`. It does not export the S3 client and there are no shipped list or delete procedures. Removing an avatar or organization logo clears the database reference but does not delete the object from storage. Implement listing, deletion and orphan cleanup only after adding file metadata and ownership checks. Examples that refer to a `File` model, `filesTable`, `verifyFileAccess` or `storageService.getS3Client()` are custom designs rather than repository APIs. --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/storage/overview **Description**: Learn how the shipped S3-compatible image storage flow works. The Pro Next.js Prisma kit ships a focused image storage flow for avatars and organization logos. Both Pro kits use the same storage implementation. ## Upload flow 1. A signed-in user selects and crops an image in the browser. 2. The client calls `storage.signedUploadUrl` with an object key and the configured images bucket. 3. The protected tRPC procedure checks only that the bucket equals `storageConfig.bucketNames.images`. 4. `getSignedUploadUrl` validates the key syntax and returns a `PutObject` URL that expires after 60 seconds. 5. The browser uploads directly to the storage provider. 6. Better Auth stores the object key in the user `image` field or organization `logo` field. The server does not create an ownership record for the object. It also does not derive the key from the authenticated user, enforce a size limit or inspect the uploaded bytes. ## Read flow 1. `useStorage(image)` returns `/storage/{imagesBucket}/{image}` for a local image key. 2. The public route reads the bucket and image key from the URL. 3. If the bucket matches the configured images bucket, it returns a redirect to a signed `GetObject` URL. 4. The signed URL and redirect cache both use a one-hour lifetime. The route does not read the current session. It does not query the database or verify ownership. This is suitable for product images that are intended to be displayed wherever their key is known, not for confidential files. The upload signer accepts keys containing slashes, but the current read route destructures only the bucket and the first segment after it. Avatar and logo keys must therefore be flat, such as 550e8400.png. If you need nested keys such as users/123/avatar.png, update the route to join all remaining path segments before requesting the object. ## Included functions ```typescript filename="lib/storage/s3.ts" lineNumbers getSignedUploadUrl(path, bucket); // PutObject URL, 60 seconds getSignedUrl(path, bucket, expiresIn); // GetObject URL ``` Both functions validate that a path: - Is not absolute - Contains no `..`, null byte or hidden path segment - Uses only letters, numbers, hyphens, underscores, slashes and dots Path validation prevents malformed object keys. It is not a user or organization authorization check. ## Current scope | Capability | Shipped behavior | | ---------------------------- | ---------------- | | Avatar and logo upload | Included | | Direct browser upload | Included | | Authenticated upload signing | Included | | Public image redirect | Included | | Private file authorization | Not included | | File ownership metadata | Not included | | Listing and deletion APIs | Not included | | Storage quota enforcement | Not included | | Nested-key download routing | Not included | Use the included integration as a starting point for public display images. Build a separate authorized download flow for private files. --- ## Setup **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/storage/setup **Description**: Configure the shipped S3-compatible image storage integration. The storage implementation uses `S3Client` from the AWS SDK with a configurable endpoint and `forcePathStyle: true`. Cloudflare R2 is the documented default. Other S3-compatible providers can work, but you must verify their endpoint, path-style support and CORS behavior. ## Environment variables The repositories read these exact names. `S3_REGION` is optional and defaults to `auto`; the other values are required for the included image flow. ```env filename=".env" lineNumbers S3_ACCESS_KEY_ID="your-access-key" S3_SECRET_ACCESS_KEY="your-secret-key" S3_ENDPOINT="https://your-s3-endpoint" S3_REGION="auto" NEXT_PUBLIC_IMAGES_BUCKET_NAME="your-images-bucket" ``` There is no `S3_BUCKET` environment variable in the shipped configuration. `NEXT_PUBLIC_IMAGES_BUCKET_NAME` supplies the only configured bucket name. ## Cloudflare R2 ### 1. Create a bucket 1. Open the Cloudflare dashboard and select **R2 Object Storage**. 2. Create a bucket for avatars and organization logos. 3. Keep direct public bucket access disabled. The application still exposes a public image redirect route. A private bucket stops direct anonymous requests to R2, but it does not add user or organization checks to `/storage/[...path]`. ### 2. Create credentials Create an R2 API token with object read and write permission scoped to this bucket. The current code signs `PutObject` and `GetObject` operations. It does not need account-wide administration permission. Copy the access key ID and secret access key when the token is created. ### 3. Configure the endpoint ```env filename=".env" lineNumbers S3_ACCESS_KEY_ID="your-r2-access-key-id" S3_SECRET_ACCESS_KEY="your-r2-secret-access-key" S3_ENDPOINT="https://.r2.cloudflarestorage.com" S3_REGION="auto" NEXT_PUBLIC_IMAGES_BUCKET_NAME="my-app-images" ``` ### 4. Configure CORS Direct browser uploads are part of the shipped avatar and logo flow, so the bucket must allow `PUT` from every application origin you use. ```json filename="R2 CORS policy" lineNumbers [ { "AllowedOrigins": ["http://localhost:3000", "https://yourdomain.com"], "AllowedMethods": ["GET", "PUT"], "AllowedHeaders": ["Content-Type"], "ExposeHeaders": ["ETag"], "MaxAgeSeconds": 3600 } ] ``` Replace the example production origin. Do not use `*` for production origins unless your application intentionally accepts uploads from every website. ## AWS S3 Create a private bucket and credentials that are limited to the required object operations. A minimal starting policy for the shipped flow is: ```json filename="IAM policy" lineNumbers { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject"], "Resource": "arn:aws:s3:::YOUR_BUCKET/*" } ] } ``` Configure the same CORS origins and methods on the bucket, then set: ```env filename=".env" lineNumbers S3_ACCESS_KEY_ID="your-aws-access-key-id" S3_SECRET_ACCESS_KEY="your-aws-secret-access-key" S3_ENDPOINT="https://s3.us-east-1.amazonaws.com" S3_REGION="us-east-1" NEXT_PUBLIC_IMAGES_BUCKET_NAME="my-app-images" ``` Use your bucket region in both the endpoint and `S3_REGION`. ## Other providers DigitalOcean Spaces, MinIO and providers with an S3 gateway require the same five variables. Provider compatibility is not abstracted behind separate adapters. The single client in `lib/storage/s3.ts` always sets `forcePathStyle: true`, so change that option if your provider requires virtual-hosted bucket URLs. For Supabase's S3 gateway, follow the [Supabase setup guide](/docs/starter-kits/pro-nextjs-prisma/recipes/supabase-setup) and keep the public application route limitation in mind. ## Storage configuration The bucket is exposed through `config/storage.config.ts`: ```typescript filename="config/storage.config.ts" lineNumbers import { env } from '@/lib/env'; export const storageConfig = { bucketNames: { images: env.NEXT_PUBLIC_IMAGES_BUCKET_NAME ?? '' } } satisfies StorageConfig; ``` The S3 client and signing functions are in `lib/storage/s3.ts`. There is no `lib/storage/service.ts` or `storageService` object. ## Verify the complete flow Generating a presigned URL happens locally and does not prove that the credentials or bucket are valid. Test the full shipped flow: 1. Start the application with the storage variables set. 2. Sign in and upload a user avatar or organization logo. 3. Confirm that the direct `PUT` request returns a successful status. 4. Confirm that the object key is saved to the user or organization record. 5. Request `/storage/{bucket}/{key}` and confirm that it redirects and displays the image. Keep `{key}` to one URL segment with the shipped route. Although the signer validates nested S3 keys, `/storage/[...path]` currently reads only the first segment after the bucket. Extend that route before introducing folder-style keys. If the upload returns a signature error, verify the endpoint, region, clock and request content type. The current signer uses `image/jpeg`, while the included crop components send `image/png`. Some providers require those values to match. --- ## Upload Files **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/storage/upload **Description**: Use the shipped image upload flow and understand the validation you must add for other files. Before uploading images, [configure the storage provider](/docs/starter-kits/pro-nextjs-prisma/storage/setup). ## Shipped upload flow The repository includes purpose-specific tRPC mutations that return presigned `PutObject` URLs. The server derives the object key from the authenticated user or active organization: ```typescript filename="trpc/routers/storage/index.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure, protectedProcedure } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; import { storageConfig } from '@/config/storage.config'; import { canUploadOrganizationLogo } from '@/lib/auth/organization-permissions'; import { getSignedUploadUrl } from '@/lib/storage'; export const storageRouter = createTRPCRouter({ userAvatarUploadUrl: protectedProcedure.mutation(async ({ ctx }) => { const path = `${ctx.user.id}-${crypto.randomUUID()}.png`; const signedUrl = await getSignedUploadUrl( path, storageConfig.bucketNames.images ); return { path, signedUrl }; }), organizationLogoUploadUrl: protectedOrganizationProcedure.mutation( async ({ ctx }) => { if (!canUploadOrganizationLogo(ctx.membership.role)) { throw new TRPCError({ code: 'FORBIDDEN' }); } const path = `logo-${ctx.organization.id}-${crypto.randomUUID()}.png`; const signedUrl = await getSignedUploadUrl( path, storageConfig.bucketNames.images ); return { path, signedUrl }; } ) }); ``` The avatar procedure requires a signed-in user. The organization-logo procedure additionally requires owner or admin membership in the active organization. Neither procedure accepts a client-selected bucket or object path. The avatar and organization logo components then: 1. Accept PNG or JPEG input in the browser. 2. Open `CropImageModal` and produce a cropped image blob. 3. Request a signed upload URL and server-generated `.png` object key. 4. Upload the blob directly with `PUT`. 5. Save the object key to Better Auth after the upload succeeds. ## Requesting an upload URL ```tsx filename="components/example-image-upload.tsx" lineNumbers const { path, signedUrl } = await trpc.storage.userAvatarUploadUrl.mutateAsync(); const response = await fetch(signedUrl, { method: 'PUT', body: imageBlob, headers: { 'Content-Type': 'image/png' } }); if (!response.ok) { throw new Error('Failed to upload image'); } ``` This example mirrors the included avatar and logo components. It is not a generic file upload API. ## Current validation `getSignedUploadUrl` rejects absolute paths, hidden path segments, null bytes, `..` and characters outside its allowlist. It signs the URL for 60 seconds. The shipped server derives avatar and organization-logo keys from the authenticated context. It does not: - Enforce a maximum byte size - Inspect the uploaded file contents - Create file metadata or enforce storage plan limits The browser file picker accepts image types, but client validation is not a security boundary. ## Content type detail The current `PutObjectCommand` sets `ContentType` to `image/jpeg`, while the included crop upload components send `Content-Type: image/png`. Providers can enforce signed headers differently. If uploads fail with a signature mismatch, make the signer and client use the same content type. When adding multiple upload types, accept a small server-validated content type enum and pass the validated value into `PutObjectCommand`. Do not forward an arbitrary header from the client. ## Production hardening The following controls are customizations and do not ship in the repository: 1. Validate an allowed content type and file size before signing. 2. Enforce provider-side upload limits where your S3-compatible provider supports them. 3. Add a file metadata record with ownership and an upload lifecycle state. 4. Confirm the object after upload before marking the record ready. 5. Add rate limits, quotas, malware scanning and orphan cleanup as required. Do not add private document uploads to the existing image signer without also implementing the authorized read flow described in [Access Files](/docs/starter-kits/pro-nextjs-prisma/storage/access). --- ## Tech Stack **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/tech-stack **Description**: An overview of the modern and powerful technologies used in the Pro Next.js Prisma starter kit. We've selected the best-in-class tools to provide a professional, scalable, and high-performance foundation for your SaaS. ## Core Framework - **[Next.js](https://nextjs.org/)**: The React framework for the web, using the App Router for modern features like Server Components and Streaming. - **[TypeScript](https://www.typescriptlang.org/)**: For end-to-end type safety and an excellent developer experience. ## Database & Authentication - **[Prisma ORM](https://www.prisma.io/)**: The most popular TypeScript ORM for building data-driven applications. - **[PostgreSQL](https://www.postgresql.org/)**: The world's most advanced open-source relational database. - **[Better Auth](https://better-auth.com/)**: A robust and flexible authentication library for Next.js. ## API & State Management - **[tRPC](https://trpc.io/)**: For building end-to-end type-safe APIs without the boilerplate. - **[TanStack Query](https://tanstack.com/query/latest)**: For powerful data fetching, caching, and state management on the client. ## Styling & UI - **[Tailwind CSS](https://tailwindcss.com/)**: A utility-first CSS framework for rapid UI development. - **[shadcn/ui](https://ui.shadcn.com/)**: Beautifully designed components built with Base UI and Tailwind CSS. - **[Lucide React](https://lucide.dev/)**: Flexible and beautiful icons. ## Advanced Features - **[Stripe](https://stripe.com/en-ch)**: For payments and subscription management. - **[Vercel AI SDK](https://ai-sdk.dev/)**: For building AI-powered features with ease. - **[React Email](https://react.email/)**: For creating beautiful, responsive email templates. - **[Sentry](https://sentry.io/welcome/)**: For error tracking and performance monitoring. - **[Cloudflare R2](https://www.cloudflare.com/products/r2/)**: For S3-compatible file storage. --- ## E2E Tests **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/tests/e2e **Description**: Test complete user flows and interactions with Playwright. End-to-end (E2E) tests verify that your application works correctly from a user's perspective. They test complete user flows by simulating real user interactions in a browser. The Pro Next.js Prisma starter kit uses [Playwright](https://playwright.dev) for E2E testing. Playwright is a modern, reliable testing framework that supports multiple browsers and provides excellent debugging tools. Playwright provides automatic waiting, network interception and useful debugging tools. Playwright supports Chromium, Firefox and WebKit. The shipped configuration runs Desktop Chrome only so the default suite stays focused and reasonably fast. ## Why write E2E tests? E2E tests verify that your application works correctly as a whole. They catch issues that unit tests might miss, such as: - **Integration problems**: Issues between different parts of your application - **User flow bugs**: Problems with complete user journeys - **UI regressions**: Visual or interaction issues - **Browser behavior**: Issues that only appear in a real browser E2E tests are slower than unit tests, so use them strategically for critical user flows rather than trying to test everything. ## Configuration The Playwright configuration is in `playwright.config.ts`: ```typescript filename="playwright.config.ts" lineNumbers import path from 'node:path'; import { defineConfig, devices } from '@playwright/test'; import dotenv from 'dotenv'; dotenv.config({ path: path.resolve(__dirname, '.env') }); const isCI = !!process.env.CI; export default defineConfig({ testDir: './tests/e2e', fullyParallel: false, forbidOnly: isCI, retries: isCI ? 1 : 0, workers: 1, reporter: [['html']], use: { baseURL: 'http://localhost:3000', trace: 'on-first-retry', video: { mode: 'retain-on-failure', size: { width: 640, height: 480 } } }, projects: [ { name: 'setup', testMatch: /.*\.setup\.ts/ }, { name: 'chromium', use: { ...devices['Desktop Chrome'] } } ], webServer: { command: 'npm run build && npm run start', url: 'http://localhost:3000', reuseExistingServer: !isCI, stdout: 'pipe', timeout: 180 * 1000 } }); ``` Key features: - **Test directory**: Tests are located in `./tests/e2e` - **Automatic server**: Builds and starts the app automatically - **Video recording**: Records videos of failed tests - **Trace viewer**: Captures traces for debugging failed tests - **Deterministic execution**: Uses one worker because authenticated tests reset shared database fixtures such as users, organizations and two-factor state - **Configured browser**: Runs the Chromium project with the Desktop Chrome device profile. Add Firefox or WebKit projects explicitly if your support policy requires them ## Running E2E tests ### Run all E2E tests ```bash filename="Terminal" lineNumbers npm run test:e2e ``` This runs all E2E tests. The app is automatically built and started before tests run. ### Run with UI mode ```bash filename="Terminal" lineNumbers npm run test:e2e:ui ``` This opens Playwright's UI mode, which provides a visual interface for running and debugging tests. ### Run in debug mode ```bash filename="Terminal" lineNumbers npm run test:e2e:debug ``` This opens Playwright Inspector, allowing you to step through tests and see what's happening. ### Run in headed mode ```bash filename="Terminal" lineNumbers npm run test:e2e:headed ``` This runs tests with a visible browser window, useful for debugging visual issues. ### Setup Playwright Install Playwright browsers (first time only): ```bash filename="Terminal" lineNumbers npm run test:e2e:setup ``` ## Writing E2E tests ### Example: Testing authentication pages Here's an example of testing authentication pages: ```typescript filename="tests/e2e/auth.spec.ts" lineNumbers import { expect, test } from '@playwright/test'; test.describe('Authentication Pages', () => { test('sign-in page loads correctly', async ({ page }) => { await page.goto('/auth/sign-in'); // Check page title await expect(page).toHaveTitle(/Sign in/); await expect( page.getByText('Sign in to your account', { exact: true }) ).toBeVisible(); // Check form elements await expect(page.getByLabel('Email')).toBeVisible(); await expect(page.getByLabel('Password', { exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible(); // Check links await expect( page.getByRole('link', { name: 'Forgot password?' }) ).toBeVisible(); await expect(page.getByRole('link', { name: 'Sign up' })).toBeVisible(); }); test('sign-up page loads correctly', async ({ page }) => { await page.goto('/auth/sign-up'); // Check page title await expect(page).toHaveTitle(/Create an account/); await expect( page.getByText('Create your account', { exact: true }) ).toBeVisible(); }); }); ``` ### Common patterns #### Navigation ```typescript filename="tests/e2e/navigation.spec.ts" lineNumbers import { test } from '@playwright/test'; test('navigates to dashboard', async ({ page }) => { await page.goto('/'); await page.click('text=Dashboard'); await expect(page).toHaveURL('/dashboard'); }); ``` #### Form interactions ```typescript filename="tests/e2e/forms.spec.ts" lineNumbers import { expect, test } from '@playwright/test'; test('fills out and submits form', async ({ page }) => { await page.goto('/contact'); await page.fill('input[name="name"]', 'John Doe'); await page.fill('input[name="email"]', 'john@example.com'); await page.fill('textarea[name="message"]', 'Test message'); await page.click('button[type="submit"]'); await expect(page.locator('text=Message sent')).toBeVisible(); }); ``` #### Waiting for elements ```typescript filename="tests/e2e/waiting.spec.ts" lineNumbers import { expect, test } from '@playwright/test'; test('waits for dynamic content', async ({ page }) => { await page.goto('/dashboard'); // Wait for data to load await page.waitForSelector('text=Loading...', { state: 'hidden' }); // Check that data is displayed await expect(page.locator('text=Total Users')).toBeVisible(); }); ``` #### Assertions ```typescript filename="tests/e2e/assertions.spec.ts" lineNumbers import { expect, test } from '@playwright/test'; test('checks various assertions', async ({ page }) => { await page.goto('/'); // Check visibility await expect(page.locator('h1')).toBeVisible(); // Check text content await expect(page.locator('h1')).toHaveText('Welcome'); // Check URL await expect(page).toHaveURL('http://localhost:3000/'); // Check element count await expect(page.locator('button')).toHaveCount(3); }); ``` ## Best practices ### Test user flows, not implementation Focus on what users do, not how the code works. Test complete user journeys rather than individual components. ```typescript // ✅ Good - tests user flow test('user can sign up and access dashboard', async ({ page }) => { await page.goto('/auth/sign-up'); await page.fill('input[name="email"]', 'test@example.com'); await page.fill('input[name="password"]', 'password123'); await page.click('button[type="submit"]'); await expect(page).toHaveURL('/dashboard'); }); // ❌ Not so good - tests implementation test('calls signup API', async ({ page }) => { // Testing API calls directly }); ``` ### Use page object model for complex flows For complex pages or flows, use the page object model to keep tests maintainable. ```typescript class SignInPage { constructor(private page: Page) {} async goto() { await this.page.goto('/auth/sign-in'); } async signIn(email: string, password: string) { await this.page.fill('input[name="email"]', email); await this.page.fill('input[name="password"]', password); await this.page.click('button[type="submit"]'); } } test('user can sign in', async ({ page }) => { const signInPage = new SignInPage(page); await signInPage.goto(); await signInPage.signIn('test@example.com', 'password123'); await expect(page).toHaveURL('/dashboard'); }); ``` ### Use data-testid for stable selectors Use `data-testid` attributes for elements that are likely to change, making tests more resilient. ```typescript // In your component // In your test await page.click('[data-testid="submit-button"]'); ``` ### Make shared state explicit Prefer independent tests when a flow can create and remove its own data. The shipped authenticated application suite is deliberately serial because it shares deterministic users and resets mutable authentication state between security scenarios. The authenticated suite uses `tests/e2e/seed.mjs` to create test-only owner and administrator accounts in the configured test database. It also resets mutable security state such as TOTP enrollment before the relevant flow. Never point the E2E environment at a development, staging or production database containing real users. Set `DATABASE_URL` in the local `.env` file to a disposable E2E database. The seed script inserts predictable fixtures and is intentionally safe to rerun, but it must never operate on customer data. The seed is executed from `tests/e2e/application.spec.ts` before the authenticated suite. Run it through Node with the test environment loaded when you need to restore those fixtures manually: ```sh filename="Terminal" lineNumbers node --env-file=.env tests/e2e/seed.mjs ``` Do not add a setup project or saved browser authentication state unless you also change the tests to consume it. The current tests sign in through the UI so they exercise the real authentication flow. ## Debugging failed tests When a test fails, Playwright provides several tools to help debug: ### View test report ```bash filename="Terminal" lineNumbers npx playwright show-report ``` This opens the HTML test report showing all test results, screenshots, and videos. ### Use trace viewer The configuration captures a trace on the first retry. CI retries failures once, so its failed-test artifacts can include a trace. Local runs use no retries; enable tracing explicitly or reproduce the failure with debug mode when needed. View a captured trace with: ```bash filename="Terminal" lineNumbers npx playwright show-trace trace.zip ``` The trace viewer shows a timeline of all actions, network requests, and console logs. ### Videos and screenshots The shipped configuration retains video for failed tests in `test-results/`. Screenshots are not enabled by default. Add `screenshot: 'only-on-failure'` to the Playwright `use` configuration if your CI artifacts should include them. ## Next steps With E2E tests set up, you can now: - **Test complete user flows** to ensure everything works together - **Catch integration issues** before they reach production - **Verify UI behavior** across different browsers - **Debug failures** with powerful debugging tools For faster feedback during development, use [Unit Tests](/docs/starter-kits/pro-nextjs-prisma/tests/unit) to test individual functions and components. --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/tests/overview **Description**: Learn about the testing setup and how to write tests for your application. The Pro Next.js Prisma starter kit includes a comprehensive testing setup with unit tests and end-to-end (E2E) tests to help you build reliable applications. ## Testing Stack The starter kit uses modern testing tools: - **[Vitest](https://vitest.dev)**: Fast unit testing framework built on Vite - **[Playwright](https://playwright.dev)**: Reliable end-to-end testing framework - **[Testcontainers](https://testcontainers.com)**: For database integration tests ## Test Structure Tests are organized in the `tests/` directory: ``` tests/ e2e/ # End-to-end tests (Playwright) lib/ # Unit tests for utility functions trpc/ # tRPC router tests support/ # Test setup and utilities ``` ## Running Tests ### Unit Tests Run all unit tests: ```bash filename="Terminal" lineNumbers npm run test:unit ``` Run tests in watch mode: ```bash filename="Terminal" lineNumbers npm run test:watch ``` Generate coverage report: ```bash filename="Terminal" lineNumbers npm run test:coverage ``` ### E2E Tests Run E2E tests: ```bash filename="Terminal" lineNumbers npm run test:e2e ``` Run E2E tests with UI: ```bash filename="Terminal" lineNumbers npm run test:e2e:ui ``` Run E2E tests in debug mode: ```bash filename="Terminal" lineNumbers npm run test:e2e:debug ``` ### Database Tests Run database integration tests (requires Docker): ```bash filename="Terminal" lineNumbers npm run test:db ``` ## Test Configuration ### Vitest Configuration The Vitest configuration is in `vitest.config.mts`: - Uses `vite-tsconfig-paths` for path resolution - Includes tests from `tests/**/*.{test,spec}.?(c|m)[jt]s?(x)` and `lib/**/*.test.ts` - Excludes database tests unless `RUN_DB_TESTS=true` - Uses Testcontainers for database tests when enabled ### Playwright Configuration The Playwright configuration is in `playwright.config.ts`: - Tests located in `./tests/e2e` - Automatically builds and starts the app for testing - Uses Chromium by default - Includes video recording and tracing for failed tests ## Best Practices ### Write focused unit tests Unit tests should test individual functions or components in isolation. Keep them fast and focused on specific behavior. ### Use E2E tests for user flows E2E tests should cover complete user journeys, not individual components. They're slower but catch integration issues. ### Test critical paths Focus on testing the most important user flows and business logic. Don't try to achieve 100% coverage. ### Keep tests maintainable Write clear, readable tests that serve as documentation. If a test is hard to understand, refactor it. ## Next Steps Ready to start writing tests? Check out the guides for [Unit Tests](/docs/starter-kits/pro-nextjs-prisma/tests/unit) and [E2E Tests](/docs/starter-kits/pro-nextjs-prisma/tests/e2e) to learn more. --- ## Unit Tests **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/tests/unit **Description**: Write and run fast unit tests for individual functions and components with instant feedback. Unit tests are a type of automated test where individual units or components are tested. The "unit" in "unit test" refers to the smallest testable parts of an application. These tests are designed to verify that each unit of code performs as expected. The Pro Next.js Prisma starter kit uses [Vitest](https://vitest.dev) as the unit testing framework. It's a blazing-fast test runner built on top of [Vite](https://vite.dev), designed for modern JavaScript and TypeScript projects. If you've used [Jest](https://jestjs.io) before, you already know Vitest - it shares the same API. But Vitest is built for speed: native TypeScript support without transpilation, parallel test execution, and a smart watch mode that only re-runs tests affected by your changes. The repository configures coverage, environment stubs, database isolation and TypeScript path aliases for you. You can extend those defaults in `vitest.config.mts` as the application grows. ## Why write unit tests? Unit tests give you **fast, focused feedback** on small pieces of your code - individual functions, hooks, or components. Instead of debugging an entire page or flow, you can verify just the logic you care about in isolation. They also act as **living documentation**: a good test tells you how a function is supposed to behave, which edge cases are important, and what assumptions the code makes. This makes it much easier to safely refactor or extend features later. In the starter kit, unit tests are designed to be **cheap and quick to run**, so you can keep Vitest running in watch mode while you code. Every change you make is immediately checked, helping you catch regressions before they ever reach integration or end‑to‑end tests. ## Configuration The Vitest configuration is in `vitest.config.mts`: ```typescript filename="vitest.config.mts" lineNumbers import { defineConfig } from 'vitest/config'; const runDbTests = process.env.RUN_DB_TESTS === 'true'; export default defineConfig({ resolve: { tsconfigPaths: true }, test: { coverage: { provider: 'v8' }, passWithNoTests: true, watch: false, testTimeout: 10_000, exclude: [ '**/node_modules/**', '**/dist/**', '**/.next/**', '**/e2e/**', // Exclude database tests unless RUN_DB_TESTS is true ...(runDbTests ? [] : [ '**/organizations.test.ts', '**/tests/trpc/routers/**', '**/*db*.test.ts' ]) ], include: ['tests/**/*.{test,spec}.?(c|m)[jt]s?(x)', 'lib/**/*.test.ts'], environment: 'node', pool: runDbTests ? 'forks' : 'threads', fileParallelism: !runDbTests, sequence: { concurrent: !runDbTests }, globalSetup: runDbTests ? './tests/support/setup-global.ts' : undefined, setupFiles: runDbTests ? ['./tests/support/setup-shared-db.ts'] : ['./tests/support/setup-env.ts'] } }); ``` Key features: - **Path resolution**: Uses Vitest's native `tsconfigPaths` support for aliases - **Coverage**: Uses v8 provider for code coverage - **Database tests**: Optional Testcontainers suite using a shared PostgreSQL container and an isolated schema (requires Docker) - **Test locations**: Includes tests from `tests/` directory and `lib/**/*.test.ts` files ## Running tests There are several ways to run unit tests: ### Run all tests ```bash filename="Terminal" lineNumbers npm run test:unit ``` This runs all unit tests once and exits. Perfect for CI/CD pipelines. ### Watch mode ```bash filename="Terminal" lineNumbers npm run test:watch ``` This starts Vitest in watch mode. As you edit files, only the affected tests are re-run, giving you fast feedback while you work. ### Code coverage Generate a code coverage report: ```bash filename="Terminal" lineNumbers npm run test:coverage ``` This runs all tests and generates a coverage report showing which lines, branches, and functions are covered by tests. ### Database tests Run database integration tests (requires Docker): ```bash filename="Terminal" lineNumbers npm run test:db ``` This starts one temporary PostgreSQL container through Testcontainers. Database tests receive an isolated schema and truncate its tables before each test. The global teardown stops the container after the run. Start Docker Desktop or your Docker daemon before running this command. The suite creates and destroys its own PostgreSQL container; it does not use the database from your normal `DATABASE_URL`. ## Writing unit tests ### Example: Testing utility functions Here's an example of testing a utility function: ```typescript filename="tests/lib/utils.test.ts" lineNumbers import { describe, expect, it } from 'vitest'; import { capitalize, getInitials } from '@/lib/utils'; describe('capitalize', () => { it('capitalizes the first letter of a word', () => { expect(capitalize('hello')).toBe('Hello'); }); it('returns empty string if input is empty', () => { expect(capitalize('')).toBe(''); }); it('capitalizes a single character', () => { expect(capitalize('a')).toBe('A'); }); }); describe('getInitials', () => { it('returns initials for a two-word name', () => { expect(getInitials('John Doe')).toBe('JD'); }); it('handles single name', () => { expect(getInitials('John')).toBe('J'); }); it('handles empty string', () => { expect(getInitials('')).toBe(''); }); }); ``` ### Test structure - **`describe`**: Groups related tests together - **`it` or `test`**: Defines an individual test case - **`expect`**: Makes assertions about the code being tested ### Common assertions ```typescript filename="tests/example.test.ts" lineNumbers import { describe, expect, it } from 'vitest'; describe('Common assertions', () => { it('checks equality', () => { expect(1 + 1).toBe(2); }); it('checks object equality', () => { expect({ name: 'John' }).toEqual({ name: 'John' }); }); it('checks truthiness', () => { expect(true).toBeTruthy(); expect(false).toBeFalsy(); }); it('checks for null/undefined', () => { expect(null).toBeNull(); expect(undefined).toBeUndefined(); }); it('checks strings', () => { expect('hello').toContain('ell'); expect('hello').toMatch(/^h/); }); it('checks arrays', () => { expect([1, 2, 3]).toContain(2); expect([1, 2, 3]).toHaveLength(3); }); it('checks errors', () => { expect(() => { throw new Error('test'); }).toThrow('test'); }); }); ``` ## Best practices Unit tests should work **for you**, not the other way around. Focus on writing tests that make it easier to change code with confidence, not on satisfying arbitrary rules or reaching a magic number in a dashboard. ### Test behavior, not implementation Focus on what the function does, not how it does it. This makes tests more resilient to refactoring. ```typescript // ✅ Good - tests behavior expect(capitalize('hello')).toBe('Hello'); // ❌ Not so good - tests implementation details expect(capitalize.toString()).toContain('charAt'); ``` ### Keep tests focused Each test should verify one specific behavior. If a test is checking multiple things, split it into multiple tests. ```typescript // ✅ Good - focused test it('capitalizes the first letter', () => { expect(capitalize('hello')).toBe('Hello'); }); // ❌ Not so good - testing multiple things it('handles various inputs', () => { expect(capitalize('hello')).toBe('Hello'); expect(capitalize('')).toBe(''); expect(capitalize('a')).toBe('A'); }); ``` ### Use descriptive test names Test names should clearly describe what is being tested. ```typescript // ✅ Good - descriptive it('returns empty string if input is empty', () => { expect(capitalize('')).toBe(''); }); // ❌ Not so good - unclear it('handles edge case', () => { expect(capitalize('')).toBe(''); }); ``` ### Test edge cases Don't just test the happy path. Test edge cases like empty strings, null values, and boundary conditions. ```typescript describe('capitalize', () => { it('handles normal input', () => { expect(capitalize('hello')).toBe('Hello'); }); it('handles empty string', () => { expect(capitalize('')).toBe(''); }); it('handles single character', () => { expect(capitalize('a')).toBe('A'); }); }); ``` ### Code coverage is a guide, not a goal Code coverage helps you find untested code, but it shouldn't be the primary goal. Focus on testing critical paths and edge cases, not achieving 100% coverage. ## Next steps With unit tests set up, you can now: - **Test utility functions** to ensure they work correctly - **Test business logic** in isolation - **Catch regressions** before they reach production - **Refactor with confidence** knowing tests will catch breaking changes Ready to test complete user flows? Check out the [E2E Tests](/docs/starter-kits/pro-nextjs-prisma/tests/e2e) guide. --- ## Authentication **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/troubleshooting/authentication **Description**: Learn about authentication troubles and their solutions. Start with the first server-side error produced by the failed request. Browser messages such as `Failed to fetch` often hide a database, environment or OAuth configuration error logged by the server. ## Sign-up or sign-in fails Check the shared prerequisites before changing authentication code: 1. Confirm the root `.env` contains a valid `DATABASE_URL` and a non-empty `BETTER_AUTH_SECRET`. 2. Apply the committed schema with `npm run db:migrate`. 3. Restart `npm run dev` after changing environment variables. 4. Reproduce the request while watching the terminal that runs Next.js. Use a unique `BETTER_AUTH_SECRET` in every deployed environment. Do not copy the development value from `.env.example` into production. ## Production redirects to localhost Better Auth receives its `baseURL` in `lib/auth/index.ts`. The value comes from `getBaseUrl()` in `lib/utils.ts` in this order: 1. A Vercel branch URL for a non-staging Preview deployment. 2. `NEXT_PUBLIC_SITE_URL`. 3. Vercel's generated deployment URL. 4. `http://localhost:3000` when none of the values above exist. Set `NEXT_PUBLIC_SITE_URL` to the final HTTPS production origin, without a path or trailing route, then redeploy. Confirm the variable exists in the Production environment rather than only Development or Preview. ## Google OAuth returns a callback error Verify the configuration as one complete set: 1. Set both `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in the environment where the deployment runs. 2. Register the exact callback URL `https://yourdomain.com/api/auth/callback/google` in Google Cloud. 3. Register the matching application origin. 4. Keep `enableSocialLogin` enabled in `config/auth.config.ts` only when the credentials are ready. 5. Confirm `google` remains in `lib/auth/oauth-providers.tsx` and in the `socialProviders` configuration in `lib/auth/index.ts`. Google compares callback URLs exactly. Scheme, hostname, port and path must all match. A Vercel branch preview can use a different hostname from production, so register that exact preview callback or test OAuth on a stable staging domain. Use the [OAuth setup guide](/docs/starter-kits/pro-nextjs-prisma/authentication/oauth) to generate the callback URL for each environment. ## Request rejected because of its origin `config/auth.config.ts` builds Better Auth's trusted origins from `getBaseUrl()`, the configured site URL and Vercel deployment URLs. If a custom frontend origin calls the auth API, add that exact HTTPS origin deliberately. Do not use a broad wildcard for production origins. After changing trusted origins, restart or redeploy the application and test sign-in, sign-out and an authenticated request from the intended frontend. --- ## Codebase **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/troubleshooting/codebase **Description**: Learn about codebase troubles and their solutions. ## Build errors or TypeScript errors Make sure you've installed all dependencies: ```sh filename="Terminal" lineNumbers npm install ``` If errors persist, try clearing the Next.js cache: ```sh filename="Terminal" lineNumbers rm -rf .next npm run build ``` ## Port already in use If port 3000 is already in use, you can change it: ```sh filename="Terminal" lineNumbers PORT=3001 npm run dev ``` Or update the port in your `package.json` scripts. ## Module not found errors If you're seeing module not found errors: 1. Make sure all dependencies are installed: `npm install` 2. Check that the import path is correct 3. Restart your development server 4. Clear the Next.js cache: `rm -rf .next` --- ## Customization **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/troubleshooting/customization **Description**: Learn about customization troubles and their solutions. ## Tailwind styles not applying If Tailwind styles aren't being applied: 1. Make sure the file is included in `app/globals.css` using `@source` directives 2. Check that `@import 'tailwindcss'` is present in your global CSS 3. Restart your development server after changing Tailwind configuration 4. Clear the Next.js cache: `rm -rf .next` ## Component styles not working If component styles aren't working: 1. Verify that the component is importing the correct CSS 2. Check that Tailwind is properly configured 3. Make sure you're using the correct class names 4. Check for any CSS conflicts or overrides ## Theme not applying If theme changes aren't working: 1. Check that the theme provider is wrapping your app 2. Verify theme configuration in your config files 3. Clear browser cache and cookies 4. Check that theme variables are correctly defined --- ## Database **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/troubleshooting/database **Description**: Learn about database troubles and their solutions. Use the first database error from the server log as the starting point. Do not paste a complete `DATABASE_URL` into an issue or support message because it contains credentials. ## The application cannot connect Check the connection in this order: 1. Confirm `DATABASE_URL` exists in the root `.env` file for local development or in the deployed environment's settings. 2. Restart the development server or redeploy after changing the value. 3. For the included local PostgreSQL service, run `npm run docker:up` and inspect startup failures with `npm run docker:logs`. 4. For a managed database, confirm the hostname, database name, user, password and required SSL query parameters with the provider. 5. Confirm the provider allows connections from the application environment. Run `npm run db:studio` only after the connection is available. If Studio also fails, diagnose the database connection before changing application code. ## Production queries are slow Place the application and PostgreSQL database in nearby regions. Cross-region network latency is paid on every query and transaction, but the exact impact depends on the query count and provider network. Measure a slow request in server traces or logs before changing regions. Check whether it issues repeated queries, waits for a connection or spends most of its time on one database operation. Moving regions does not fix missing indexes or an inefficient query. ## A committed migration fails Treat a production migration as a release operation: 1. Back up data you need before applying a schema change. 2. Confirm the deployment uses the intended `DATABASE_URL` without printing its value. 3. Review the committed Prisma migration files included with the release. 4. Confirm the database user can change the required schema objects. 5. Run `npm run db:migrate` once from CI or a one-off release task. Do not replace a failed production migration with `npm run db:push`. Do not delete committed migration history to make the current environment appear clean. Resolve the reported migration or schema difference and test the fix on a disposable copy first. Follow the [Prisma database guide](/docs/starter-kits/pro-nextjs-prisma/database) for the development workflow used to author new migrations. --- ## Troubleshooting **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/troubleshooting/overview **Description**: Common troubles and their solutions. Find answers to questions other developers have asked - it might be just what you need! ## Common Issues ### Environment variables from .env are not being loaded Make sure you are running the `npm run dev` command from the root directory of your project (where the `package.json` file is located). Note Next.js automatically loads environment variables from `.env` when running the development server. Make sure the file exists in the root directory and contains all required variables. Also make sure that the environment variable you are trying to access in your application is prefixed with `NEXT_PUBLIC_` if you want to use it in client-side code. ### Application is very slow in production The most common reason for a slow application in production is the physical distance between the server or serverless functions and the database. Make sure to deploy your application to a region that is close to your database. For example when you are using Vercel, you can select the region of the Vercel serverless functions in the project settings under the **Functions** tab. ### Database connection issues If you're experiencing database connection issues: 1. Verify your `DATABASE_URL` in `.env` is correct 2. Make sure your database is running and accessible 3. Check if your database allows connections from your IP address (for managed databases) 4. Verify the database credentials are correct ### Prisma Client not generated If you're getting errors about Prisma Client not being found: 1. Run `npm run db:migrate:dev` to generate the Prisma Client and apply migrations 2. The Prisma client is automatically generated during migrations and in the build process via `postinstall` script ### Port already in use If port 3000 is already in use, you can change it by setting the `PORT` environment variable: ```sh filename="Terminal" lineNumbers PORT=3001 npm run dev ``` --- ## tRPC **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/trpc **Description**: Build end-to-end type-safe APIs with tRPC. The Pro Next.js Prisma starter kit uses **tRPC** for its API layer, providing seamless type safety between your server-side logic and client-side components. ## Architecture Our tRPC setup is designed for performance and maintainability, with built-in support for authentication and organization-scoped data. ### Root Router The root router is located in `trpc/routers/app.ts` and aggregates all feature-specific routers using lazy loading. ```typescript filename="trpc/routers/app.ts" lineNumbers import { createTRPCRouter } from '@/trpc/init'; import { lazy } from '@trpc/server'; export const appRouter = createTRPCRouter({ admin: lazy(() => import('./admin')), organization: lazy(() => import('./organization')), user: lazy(() => import('./user')) // ... other routers }); export type AppRouter = typeof appRouter; ``` ## Procedures We provide several base procedures to simplify development: - `publicProcedure`: No authentication required. - `protectedProcedure`: Requires a valid user session. - `protectedOrganizationProcedure`: Requires a valid session and an active organization. ### Example Procedure ```typescript filename="trpc/routers/organization/organization-lead-router.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; import { prisma } from '@/lib/db'; export const organizationLeadRouter = createTRPCRouter({ list: protectedOrganizationProcedure.query(async ({ ctx }) => { return await prisma.lead.findMany({ where: { organizationId: ctx.organization.id } }); }) }); ``` ## Client Usage ### React Hooks On the client, use the `trpc` object to access your API procedures via React Query hooks. ```tsx filename="components/leads-list.tsx" lineNumbers const { data, isLoading } = trpc.organization.lead.list.useQuery(); ``` ### Mutations For actions that modify data, use mutations. ```tsx filename="components/create-lead-form.tsx" lineNumbers const mutation = trpc.organization.lead.create.useMutation({ onSuccess: () => { // Invalidate the list query to refetch fresh data trpc.useUtils().organization.lead.list.invalidate(); } }); ``` ## Server-Side Usage ### Prefetching For better performance, you can prefetch data on the server in your Next.js Server Components. ```tsx filename="app/(saas)/dashboard/leads/page.tsx" lineNumbers import { HydrateClient, trpc } from '@/trpc/server'; export default async function LeadsPage() { await trpc.organization.lead.list.prefetch({}); return ( ); } ``` ## Type Inference Extract types from your procedures for use in your components. ```typescript filename="types/lead.ts" lineNumbers import type { AppRouter } from '@/trpc/routers/app'; import type { inferRouterOutputs } from '@trpc/server'; type RouterOutputs = inferRouterOutputs; export type Lead = RouterOutputs['organization']['lead']['list'][number]; ``` --- ## Define Endpoint **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/trpc/define-endpoint **Description**: Learn how to create new tRPC endpoints. This guide shows you how to create new tRPC endpoints in your application. We'll create a complete CRUD example for a `posts` feature. ## Creating a Router Create a new router file in `trpc/routers/`: ```typescript filename="trpc/routers/posts.ts" lineNumbers import { createTRPCRouter, protectedProcedure } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { prisma } from '@/lib/db'; export const postsRouter = createTRPCRouter({ // Endpoints will go here }); ``` ## List Posts (Query) Create a query to list posts: ```typescript filename="trpc/routers/posts.ts" lineNumbers list: protectedProcedure .input( z.object({ limit: z.number().min(1).max(100).default(10), offset: z.number().min(0).default(0), }) ) .query(async ({ input }) => { const posts = await prisma.post.findMany({ take: input.limit, skip: input.offset, orderBy: { createdAt: 'desc' }, }); return posts; }), ``` ## Create Post (Mutation) Create a mutation to create a new post: ```typescript filename="trpc/routers/posts.ts" lineNumbers create: protectedProcedure .input( z.object({ title: z.string().min(1).max(255), content: z.string().min(1), }) ) .mutation(async ({ input, ctx }) => { const post = await prisma.post.create({ data: { title: input.title, content: input.content, authorId: ctx.user.id, }, }); return post; }), ``` ## Get Post by ID (Query) Create a query to get a single post: ```typescript filename="trpc/routers/posts.ts" lineNumbers getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ input }) => { const post = await prisma.post.findUnique({ where: { id: input.id }, }); if (!post) { throw new TRPCError({ code: "NOT_FOUND", message: "Post not found", }); } return post; }), ``` ## Update Post (Mutation) Create a mutation to update a post: ```typescript filename="trpc/routers/posts.ts" lineNumbers update: protectedProcedure .input( z.object({ id: z.string(), title: z.string().min(1).max(255).optional(), content: z.string().min(1).optional(), }) ) .mutation(async ({ input, ctx }) => { // Verify post exists and user is author const existingPost = await prisma.post.findUnique({ where: { id: input.id }, }); if (!existingPost) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Post not found', }); } if (existingPost.authorId !== ctx.session.user.id) { throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not the author of this post', }); } const updatedPost = await prisma.post.update({ where: { id: input.id }, data: { title: input.title, content: input.content, }, }); return updatedPost; }), ``` ## Delete Post (Mutation) Create a mutation to delete a post: ```typescript filename="trpc/routers/posts.ts" lineNumbers delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ input, ctx }) => { // Verify post exists and user is author const existingPost = await prisma.post.findUnique({ where: { id: input.id }, }); if (!existingPost) { throw new TRPCError({ code: "NOT_FOUND", message: "Post not found", }); } if (existingPost.authorId !== ctx.user.id) { throw new TRPCError({ code: "FORBIDDEN", message: "You are not the author of this post", }); } await prisma.post.delete({ where: { id: input.id }, }); return { success: true }; }), ``` ## Complete Router Example Here's the complete router: ```typescript filename="trpc/routers/posts.ts" lineNumbers import { createTRPCRouter, protectedProcedure } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { prisma } from '@/lib/db'; export const postsRouter = createTRPCRouter({ list: protectedProcedure .input( z.object({ limit: z.number().min(1).max(100).default(10), offset: z.number().min(0).default(0) }) ) .query(async ({ input }) => { return await prisma.post.findMany({ take: input.limit, skip: input.offset, orderBy: { createdAt: 'desc' } }); }), getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ input }) => { const post = await prisma.post.findUnique({ where: { id: input.id } }); if (!post) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Post not found' }); } return post; }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(255), content: z.string().min(1) }) ) .mutation(async ({ input, ctx }) => { const post = await prisma.post.create({ data: { title: input.title, content: input.content, authorId: ctx.session.user.id } }); return post; }), update: protectedProcedure .input( z.object({ id: z.string(), title: z.string().min(1).max(255).optional(), content: z.string().min(1).optional() }) ) .mutation(async ({ input, ctx }) => { const existingPost = await prisma.post.findUnique({ where: { id: input.id } }); if (!existingPost) { throw new TRPCError({ code: 'NOT_FOUND' }); } if (existingPost.authorId !== ctx.session.user.id) { throw new TRPCError({ code: 'FORBIDDEN' }); } const updatedPost = await prisma.post.update({ where: { id: input.id }, data: { title: input.title, content: input.content } }); return updatedPost; }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ input, ctx }) => { const existingPost = await prisma.post.findUnique({ where: { id: input.id } }); if (!existingPost) { throw new TRPCError({ code: 'NOT_FOUND' }); } if (existingPost.authorId !== ctx.session.user.id) { throw new TRPCError({ code: 'FORBIDDEN' }); } await prisma.post.delete({ where: { id: input.id } }); return { success: true }; }) }); ``` ## Adding Router to App Router Add your new router to the main app router: ```typescript filename="trpc/routers/app.ts" lineNumbers import { createTRPCRouter } from '@/trpc/init'; import { lazy } from '@trpc/server'; export const appRouter = createTRPCRouter({ admin: lazy(() => import('./admin')), organization: lazy(() => import('./organization')), user: lazy(() => import('./user')), posts: lazy(() => import('./posts')) // Add your new router // ... other routers }); export type AppRouter = typeof appRouter; ``` ## Using the Endpoint ### Client-Side ```tsx filename="components/posts-list.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function PostsList() { const { data: posts, isLoading } = trpc.posts.list.useQuery({ limit: 10, offset: 0 }); if (isLoading) return
Loading...
; return (
{posts?.map((post) => (

{post.title}

{post.content}

))}
); } ``` ### Server-Side ```tsx filename="app/(saas)/dashboard/posts/page.tsx" lineNumbers import { HydrateClient, trpc } from '@/trpc/server'; export default async function PostsPage() { await trpc.posts.list.prefetch({ limit: 10, offset: 0 }); return ( ); } ``` ## Best Practices 1. **Use appropriate procedures** - Choose `publicProcedure`, `protectedProcedure`, or `protectedOrganizationProcedure` 2. **Validate inputs** - Always use Zod schemas for input validation 3. **Handle errors** - Use `TRPCError` with appropriate error codes 4. **Check permissions** - Verify user has access before operations 5. **Return created/updated records** - Prisma automatically returns the created/updated record by default 6. **Type safety** - Let TypeScript infer types from your procedures --- ## Protect Endpoint **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/trpc/protect-endpoint **Description**: Learn how to protect tRPC endpoints with authentication and authorization. The starter kit provides several base procedures for protecting endpoints. Choose the appropriate procedure based on your security requirements. ## Available Procedures ### Public Procedure No authentication required. Use for public endpoints: ```typescript filename="trpc/routers/public.ts" lineNumbers import { createTRPCRouter, publicProcedure } from '@/trpc/init'; export const publicRouter = createTRPCRouter({ health: publicProcedure.query(() => { return { status: 'ok', timestamp: new Date() }; }) }); ``` ### Protected Procedure Requires a valid user session. The session and user are available in `ctx`: ```typescript filename="trpc/routers/user.ts" lineNumbers import { createTRPCRouter, protectedProcedure } from '@/trpc/init'; export const userRouter = createTRPCRouter({ getProfile: protectedProcedure.query(async ({ ctx }) => { // ctx.user and ctx.session are guaranteed to exist return ctx.user; }) }); ``` ### Protected Admin Procedure Requires authentication AND admin role: ```typescript filename="trpc/routers/admin.ts" lineNumbers import { createTRPCRouter, protectedAdminProcedure } from '@/trpc/init'; export const adminRouter = createTRPCRouter({ getAllUsers: protectedAdminProcedure.query(async ({ ctx }) => { // ctx.user.role is guaranteed to be "admin" return await getAllUsers(); }) }); ``` ### Protected Organization Procedure Requires authentication AND an active organization. The organization is available in `ctx`: ```typescript filename="trpc/routers/organization.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; export const organizationRouter = createTRPCRouter({ getData: protectedOrganizationProcedure.query(async ({ ctx }) => { // ctx.organization is guaranteed to exist // ctx.membership contains the user's role in the organization return await getOrganizationData(ctx.organization.id); }) }); ``` ## Custom Authorization ### Role-Based Access Check user roles within a procedure: ```typescript filename="trpc/routers/example.ts" lineNumbers import { createTRPCRouter, protectedProcedure } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; export const exampleRouter = createTRPCRouter({ adminOnly: protectedProcedure.query(async ({ ctx }) => { if (ctx.user.role !== 'admin') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin access required' }); } return { data: 'admin data' }; }) }); ``` ### Resource Ownership Verify the user owns the resource: ```typescript filename="trpc/routers/posts.ts" lineNumbers import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { protectedProcedure } from "@/trpc/init"; import { prisma } from "@/lib/db"; update: protectedProcedure .input(z.object({ id: z.string(), title: z.string() })) .mutation(async ({ input, ctx }) => { const post = await prisma.post.findUnique({ where: { id: input.id }, }); if (!post) { throw new TRPCError({ code: "NOT_FOUND", message: "Post not found" }); } // Check ownership if (post.authorId !== ctx.user.id) { throw new TRPCError({ code: "FORBIDDEN", message: "You can only edit your own posts", }); } // Update post return await prisma.post.update({ where: { id: input.id }, data: { title: input.title }, }); }), ``` ### Organization Membership The `protectedOrganizationProcedure` automatically verifies organization membership. For additional checks: ```typescript filename="trpc/routers/organization.ts" lineNumbers import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { protectedOrganizationProcedure } from "@/trpc/init"; import { assertUserIsOrgMember } from "@/lib/auth/server"; getData: protectedOrganizationProcedure .input(z.object({ organizationId: z.string() })) .query(async ({ input, ctx }) => { // Verify user is member (if different from active org) if (input.organizationId !== ctx.organization.id) { await assertUserIsOrgMember(input.organizationId, ctx.user.id); } return await getData(input.organizationId); }), ``` ### Plan-Based Access Check if organization has required plan: ```typescript filename="trpc/routers/premium.ts" lineNumbers import { TRPCError } from "@trpc/server"; import { protectedOrganizationProcedure } from "@/trpc/init"; import { requirePaidPlan, hasSpecificPlan } from "@/lib/billing"; premiumFeature: protectedOrganizationProcedure.query(async ({ ctx }) => { // Option 1: Throw error if no paid plan await requirePaidPlan(ctx.organization.id); // Option 2: Check for specific plan (doesn't throw) const hasProPlan = await hasSpecificPlan(ctx.organization.id, "pro"); if (!hasProPlan) { throw new TRPCError({ code: "FORBIDDEN", message: "This feature requires a Pro plan", }); } return { data: "premium content" }; }), ``` ## Creating Custom Procedures You can create custom procedures for common authorization patterns: ```typescript filename="trpc/procedures.ts" lineNumbers import { protectedOrganizationProcedure, protectedProcedure } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; /** * Procedure that requires user to have completed onboarding */ export const onboardedProcedure = protectedProcedure.use( async ({ ctx, next }) => { if (!ctx.user.onboardingComplete) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Please complete onboarding first' }); } return next({ ctx }); } ); /** * Procedure that requires organization admin role */ export const organizationAdminProcedure = protectedOrganizationProcedure.use( async ({ ctx, next }) => { const isAdmin = ctx.membership.role === 'admin' || ctx.membership.role === 'owner'; if (!isAdmin) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Organization admin access required' }); } return next({ ctx }); } ); ``` Usage: ```typescript filename="trpc/routers/example.ts" lineNumbers import { createTRPCRouter } from '@/trpc/init'; import { onboardedProcedure, organizationAdminProcedure } from '@/trpc/procedures'; import { z } from 'zod'; export const exampleRouter = createTRPCRouter({ // Requires onboarding getDashboard: onboardedProcedure.query(async ({ ctx }) => { return await getDashboardData(ctx.user.id); }), // Requires org admin updateSettings: organizationAdminProcedure .input(z.object({ settings: z.object({}) })) .mutation(async ({ input, ctx }) => { return await updateOrgSettings(ctx.organization.id, input.settings); }) }); ``` ## Error Codes Use appropriate TRPC error codes: - **`UNAUTHORIZED`** - User is not authenticated - **`FORBIDDEN`** - User is authenticated but lacks permission - **`NOT_FOUND`** - Resource doesn't exist - **`BAD_REQUEST`** - Invalid input - **`INTERNAL_SERVER_ERROR`** - Server error ```typescript filename="trpc/routers/example.ts" lineNumbers import { createTRPCRouter, protectedProcedure } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; export const exampleRouter = createTRPCRouter({ getResource: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ input, ctx }) => { const resource = await getResource(input.id); if (!resource) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Resource not found' }); } // Check access if (!hasAccess(resource, ctx.user)) { throw new TRPCError({ code: 'FORBIDDEN', message: "You don't have access to this resource" }); } return resource; }) }); ``` ## Best Practices 1. **Fail fast** - Check authentication and authorization early 2. **Use appropriate procedures** - Don't use `protectedProcedure` when `publicProcedure` is sufficient 3. **Verify ownership** - Always verify resource ownership before mutations 4. **Clear error messages** - Provide helpful error messages (but don't leak sensitive info) 5. **Log access attempts** - Log failed authorization attempts for security monitoring --- ## Usage in Frontend **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-prisma/trpc/usage-frontend **Description**: Learn how to use tRPC endpoints in your React components. tRPC provides type-safe hooks for using your API in React components. All procedures are automatically typed based on your router definitions. ## Queries Use `useQuery` for data fetching: ```tsx filename="components/user-profile.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function UserProfile() { const { data: user, isLoading, error } = trpc.user.getProfile.useQuery(); if (isLoading) return
Loading...
; if (error) return
Error: {error.message}
; if (!user) return
Not found
; return
Hello, {user.name}!
; } ``` ### Query with Input Pass input parameters to queries: ```tsx filename="components/post-detail.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function PostDetail({ postId }: { postId: string }) { const { data: post, isLoading } = trpc.posts.getById.useQuery({ id: postId }); if (isLoading) return
Loading...
; if (!post) return
Post not found
; return (

{post.title}

{post.content}

); } ``` ### Conditional Queries Enable/disable queries conditionally: ```tsx filename="components/conditional-query.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function ConditionalQuery({ enabled }: { enabled: boolean }) { const { data } = trpc.posts.list.useQuery( { limit: 10 }, { enabled } // Only fetch when enabled is true ); return
{/* render data */}
; } ``` ## Mutations Use `useMutation` for data modifications: ```tsx filename="components/create-post-form.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; import { useForm } from 'react-hook-form'; export function CreatePostForm() { const utils = trpc.useUtils(); const { handleSubmit } = useForm<{ title: string; content: string }>(); const createPost = trpc.posts.create.useMutation({ onSuccess: () => { // Invalidate and refetch posts list utils.posts.list.invalidate(); } }); const onSubmit = async (data: { title: string; content: string }) => { try { await createPost.mutateAsync(data); // Handle success } catch (error) { // Handle error } }; return
{/* form fields */}
; } ``` ### Optimistic Updates Update the UI optimistically for better UX: ```tsx filename="components/optimistic-update.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function OptimisticUpdate() { const utils = trpc.useUtils(); const updatePost = trpc.posts.update.useMutation({ onMutate: async (newData) => { // Cancel outgoing refetches await utils.posts.getById.cancel({ id: newData.id }); // Snapshot previous value const previous = utils.posts.getById.getData({ id: newData.id }); // Optimistically update utils.posts.getById.setData({ id: newData.id }, (old) => ({ ...old!, ...newData })); return { previous }; }, onError: (err, newData, context) => { // Rollback on error utils.posts.getById.setData({ id: newData.id }, context?.previous); }, onSettled: (data, error, variables) => { // Refetch to ensure consistency utils.posts.getById.invalidate({ id: variables.id }); } }); return ( ); } ``` ## Server-Side Usage ### Prefetching in Server Components Prefetch data on the server for better performance: ```tsx filename="app/(saas)/dashboard/posts/page.tsx" lineNumbers import { HydrateClient, trpc } from '@/trpc/server'; export default async function PostsPage() { // Prefetch data on the server await trpc.posts.list.prefetch({ limit: 10, offset: 0 }); return ( ); } ``` ### Direct Server Calls Call tRPC procedures directly on the server: ```typescript filename="app/api/posts/route.ts" lineNumbers import { trpc } from '@/trpc/server'; export async function GET() { const posts = await trpc.posts.list({ limit: 10, offset: 0 }); return Response.json(posts); } ``` ## Error Handling Handle errors gracefully: ```tsx filename="components/error-handling.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; import { TRPCClientError } from '@trpc/client'; export function ErrorHandling() { const { data, error, isLoading } = trpc.posts.getById.useQuery( { id: '123' }, { retry: (failureCount, error) => { // Don't retry on 404 if (error.data?.code === 'NOT_FOUND') { return false; } // Retry up to 3 times for other errors return failureCount < 3; } } ); if (error) { if (error.data?.code === 'NOT_FOUND') { return
Post not found
; } if (error.data?.code === 'FORBIDDEN') { return
You don't have permission to view this post
; } return
Error: {error.message}
; } if (isLoading) return
Loading...
; return
{/* render data */}
; } ``` ## Type Inference Extract types from your procedures: ```typescript filename="types/post.ts" lineNumbers import type { AppRouter } from '@/trpc/routers/app'; import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server'; type RouterOutputs = inferRouterOutputs; type RouterInputs = inferRouterInputs; // Extract output type export type Post = RouterOutputs['posts']['getById']; // Extract input type export type CreatePostInput = RouterInputs['posts']['create']; export type UpdatePostInput = RouterInputs['posts']['update']; ``` Use in components: ```tsx filename="components/typed-component.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; import type { Post } from '@/types/post'; export function TypedComponent() { const { data: post } = trpc.posts.getById.useQuery({ id: '123' }); // post is automatically typed as Post return
{post?.title}
; } ``` ## Query Invalidation Invalidate queries to trigger refetches: ```tsx filename="components/invalidation.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function InvalidationExample() { const utils = trpc.useUtils(); const createPost = trpc.posts.create.useMutation(); const handleCreate = async (data: { title: string; content: string }) => { await createPost.mutateAsync(data); // Invalidate specific query utils.posts.list.invalidate(); // Or invalidate all posts queries utils.posts.invalidate(); }; return ( ); } ``` ## Best Practices 1. **Use prefetching** - Prefetch data on the server for better performance 2. **Handle loading states** - Always show loading indicators 3. **Handle errors** - Provide user-friendly error messages 4. **Use optimistic updates** - Update UI immediately for better UX 5. **Invalidate queries** - Invalidate related queries after mutations 6. **Type safety** - Leverage TypeScript inference for type safety --- # Drizzle documentation ## App Config **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/admin-panel/app-config **Description**: Inspect the configuration values loaded by the current deployment. The App Config page at `/dashboard/admin/app-config` is a read-only viewer. It does not edit configuration, save values to the database or keep a change history. The parent admin layout restricts the page to users whose platform role is `admin`. ## Configuration Sections `components/admin/app-config/app-config-table.tsx` imports four configuration objects and displays each one in a tab: - **App** from `config/app.config.ts` - **Auth** from `config/auth.config.ts` - **Billing** from `config/billing.config.ts` - **Storage** from `config/storage.config.ts` These are the values resolved by the configuration modules for the current deployment. ## Displayed Fields The table flattens nested objects into dot-separated keys. Each row contains: - **Key** - The configuration path, such as `pagination.defaultLimit` - **Type** - The JavaScript value type - **Value** - The resolved value Arrays remain on one row. Object items inside an array are displayed as JSON. Each value has a copy button. The page does not display field descriptions, update timestamps or a change author. It also does not include search, filters, pagination or row selection. ## Change Configuration Edit the corresponding file in `config/` and update any environment variables that file reads. Validate the application locally, then deploy the code and environment changes. The App Config page will reflect the values loaded by the new deployment. There is no App Config tRPC procedure or database table in the shipped kit. ## Security The table is rendered by a Client Component. Do not add secrets to the imported configuration objects. Keep server-only credentials in server environment variables and avoid exposing them through client-imported config modules. ## Related Documentation - [Configuration](/docs/starter-kits/pro-nextjs-drizzle/configuration) - Configure the application - [App Configuration](/docs/starter-kits/pro-nextjs-drizzle/configuration/app) - Review the app config structure - [Environment Variables](/docs/starter-kits/pro-nextjs-drizzle/codebase/environment-variables) - Manage deployment values --- ## Credits **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/admin-panel/credits **Description**: View and adjust organization credit balances from the admin Organizations page. The shipped kit manages credits from `/dashboard/admin/organizations`. It does not include a standalone `/dashboard/admin/credits` route or an `admin.credit` tRPC router. ## Included Admin Controls The Organizations table shows the current credit balance for each organization. Admins can: - Search for an organization by name - Filter organizations by credit balance - Open **Adjust credits** from an organization's row menu - Add or subtract credits with a required description The balance filters are `zero`, `low`, `medium` and `high`. Their ranges are defined by `admin.organization.list` in `trpc/routers/admin/admin-organization-router.ts`. ## List Organization Balances Credit balances are returned by the existing organization list procedure: ```tsx filename="components/admin/organizations/organizations-table.tsx" lineNumbers const { data, isPending } = trpc.admin.organization.list.useQuery({ limit: 25, offset: 0, query: '', sortBy: 'name', sortOrder: 'asc', filters: { balanceRange: ['low'] } }); const organizations = data?.organizations ?? []; // Each organization includes `credits`, the current balance. ``` The list procedure does not return an admin-wide credit transaction history. ## Adjust Organization Credits Use the procedure called by `components/admin/credits/adjust-credits-modal.tsx`: ```tsx lineNumbers const adjustCredits = trpc.admin.organization.adjustCredits.useMutation(); adjustCredits.mutate({ organizationId, amount: 500, description: 'Support credit' }); ``` A positive amount adds credits and a negative amount subtracts credits. The amount cannot be zero. The description must contain between 1 and 500 characters. The mutation records the adjustment through `lib/billing/credits.ts` and returns `newBalance` plus `transactionId`. It also stores the acting admin's ID and email in the transaction metadata. ## Not Included The current kit does not include: - A separate admin credits page - `trpc.admin.credit.list`, `trpc.admin.credit.get` or `trpc.admin.credit.listTransactions` - An admin-wide credit transaction browser - User-level credit balances The credit system is organization-scoped. The `trpc.organization.credit.getTransactions` procedure is available to the active organization through `protectedOrganizationProcedure`. It is not a global admin query. ## Related Documentation - [Organizations](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/organizations) - Use the shipped admin interface - [Credits](/docs/starter-kits/pro-nextjs-drizzle/billing/credits) - Understand the organization credit system - [Billing Overview](/docs/starter-kits/pro-nextjs-drizzle/billing/overview) - Review the billing architecture --- ## Notifications **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/admin-panel/notifications **Description**: Send targeted or broadcast in-app notifications and understand the user notification center. The starter kit includes a database-backed notification center for signed-in users and an administrative workflow for sending and reviewing notifications. ## What Ships Users receive a notification bell in the expanded application sidebar and in the mobile navigation drawer. The popover includes: - An unread count on the bell - **All** and **Unread** tabs - Information, success and warning states - Expandable message content - Optional internal action links - Individual and bulk mark-as-read actions - Loading, empty and recoverable error states The bell is hidden when the desktop sidebar is collapsed so it does not compete with the compact navigation rail. Platform administrators also receive `/dashboard/admin/notifications`. The page provides search, type and read-state filters, pagination, a notification details sheet, row selection and confirmed bulk deletion. ## Send a Notification 1. Sign in with a platform `admin` account. 2. Open **Admin Panel → Notifications**. 3. Select **Send notification**. 4. Choose one active user or all active users. 5. Enter a title, message and type. 6. Optionally add an internal application path such as `/dashboard/settings?tab=billing`. 7. Review the audience in the confirmation dialog and send. Banned users are excluded from recipient search and broadcasts. Broadcasts are inserted in batches inside a database transaction. Action URLs must be internal paths. The shared `getSafeRedirectPath` utility rejects external, protocol-relative and malformed values before creation, and the notification center validates the stored path again before navigation. ## Database Model Each recipient gets one notification row. It stores: - `userId` for the recipient - optional `createdById` for the administrator who sent it - `title`, `message` and `type` - optional `actionUrl` - nullable `readAt` - `createdAt` and `updatedAt` The schema indexes the recipient with creation time for chronological listing and the recipient with read time for unread queries. Deleting a user cascades their notifications. Deleting a creator preserves delivered notifications and sets `createdById` to `NULL`. Apply the checked-in migration before running the updated application: ```sh filename="Terminal" lineNumbers npm run db:migrate ``` No new environment variable is required. ## User Procedures The `notification` tRPC router exposes: | Procedure | Purpose | | -------------------------- | --------------------------------------- | | `notification.list` | List the current user's recent messages | | `notification.unreadCount` | Count the current user's unread rows | | `notification.get` | Read one owned notification | | `notification.markRead` | Mark one owned row as read | | `notification.markAllRead` | Mark all current-user rows as read | Every database condition includes `ctx.user.id`. A caller cannot read or change another user's notification by supplying its ID. ## Admin Procedures The `admin.notification` router uses `protectedAdminProcedure` and exposes: | Procedure | Purpose | | ------------------------------- | ---------------------------------- | | `admin.notification.list` | Search and filter delivery history | | `admin.notification.recipients` | Find active recipients | | `admin.notification.create` | Send to one user or broadcast | | `admin.notification.bulkDelete` | Delete up to 100 selected rows | Deleting a notification removes it from the recipient's notification center. The admin interface confirms destructive row and bulk actions before calling the mutation. ## Create Notifications from Application Code Product events can create notification rows directly in server-only code. Keep the same boundaries as the admin workflow: 1. Resolve recipients from trusted server state. 2. Validate an action with `getSafeRedirectPath` or store `NULL`. 3. Insert one row per recipient. 4. Keep external email or push delivery in a separate queue or integration. The shipped release is an in-app, database-backed system. It does not provide real-time push delivery. Add polling, server-sent events or a realtime provider only when the product requires live arrival. ## Customize the Notification Center The main files are: - `components/notifications/notification-center.tsx` - `components/notifications/notification-icon.tsx` - `components/admin/notifications/admin-notifications.tsx` - `components/admin/notifications/create-notification-modal.tsx` - `components/admin/notifications/notification-details-modal.tsx` - `schemas/notification-schemas.ts` - `trpc/routers/notification/index.ts` - `trpc/routers/admin/admin-notification-router.ts` Keep the user router scoped to the authenticated user and keep management procedures behind `protectedAdminProcedure` when changing the presentation or adding notification types. ## Related Guides - [Admin Panel overview](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/overview) - [Admin users](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/users) - [Permissions and access control](/docs/starter-kits/pro-nextjs-drizzle/authentication/permissions) - [Database migrations](/docs/starter-kits/pro-nextjs-drizzle/database/migrations) --- ## Organizations **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/admin-panel/organizations **Description**: View organizations and use the billing controls included in the admin table. The Organizations page at `/dashboard/admin/organizations` is the shipped admin interface for organization records, subscription summaries and credit balances. ## Table Contents Each row displays: - Organization name and logo - Member count - Pending invitation count - Latest subscription plan and status - Scheduled cancellation or trial details when present - Current credit balance - Creation date The list does not include an organization owner, slug, payment history or full credit transaction history. ## Search, Filters and Sorting Search matches organization names. The table can filter by: - Member count - Subscription status - Billing interval - Credit balance range - Creation date Sorting is supported for name, member count and creation date. ```tsx filename="components/admin/organizations/organizations-table.tsx" lineNumbers const { data, isPending } = trpc.admin.organization.list.useQuery({ limit: 25, offset: 0, query: '', sortBy: 'name', sortOrder: 'asc', filters: { membersCount: ['1-5'], subscriptionStatus: ['active'], subscriptionInterval: ['month'], balanceRange: ['low'], createdAt: ['this-month'] } }); ``` ## Row Actions The row menu includes these actions: - **Sync from Stripe** calls `trpc.admin.organization.syncFromStripe` for the selected organization - **Adjust credits** calls `trpc.admin.organization.adjustCredits` - **Open in Stripe** opens the stored subscription in the Stripe Dashboard - **Cancel at period end** calls `trpc.admin.organization.cancelSubscription` with `immediate: false` - **Delete** calls `trpc.admin.organization.delete` Cancellation is unavailable when a subscription is already canceled or scheduled to cancel. The local subscription record is updated by the Stripe webhook. ## Bulk Actions Selected organizations can be: - Exported to CSV - Exported to Excel - Synchronized with Stripe The current table does not include bulk deletion, bulk credit adjustments or bulk subscription cancellation. ## Returned Billing Fields `trpc.admin.organization.list` returns one latest subscription summary and one credit balance per organization. Relevant fields include: - `subscriptionId` - `subscriptionStatus` - `subscriptionPlan` - `cancelAtPeriodEnd` - `trialEnd` - `credits` ## Related Documentation - [Subscription controls](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/subscriptions) - Manage the latest organization subscription - [Credit controls](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/credits) - View and adjust organization credits - [Organizations Overview](/docs/starter-kits/pro-nextjs-drizzle/organizations/overview) - Understand organization features --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/admin-panel/overview **Description**: Learn which admin pages and organization management controls ship with the kit. The admin area is available only to users whose platform role is `admin`. The current kit ships four admin pages: - `/dashboard/admin/users` - `/dashboard/admin/organizations` - `/dashboard/admin/notifications` - `/dashboard/admin/app-config` Subscription and credit controls are part of the Organizations page. There is no standalone `/dashboard/admin/subscriptions` or `/dashboard/admin/credits` page. ## Access the Admin Area 1. Assign the `admin` platform role to a user. See [Admin UI](/docs/starter-kits/pro-nextjs-drizzle/authentication/superadmin). 2. Sign in with that account. 3. Open the Admin entry from the organization switcher. It links to `/dashboard/admin/users`. `app/(saas)/dashboard/(sidebar)/admin/layout.tsx` checks the server session. A non-admin user is redirected to `/dashboard`. ## Shipped Features ### Users The Users page provides the account management actions documented in the [Users guide](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/users). ### Organizations The Organizations page includes: - Search, pagination and filters - Subscription and credit balance summaries - CSV and Excel exports - Stripe synchronization for selected organizations - Credit adjustments for one organization - Subscription cancellation at period end - Organization deletion See the [Organizations guide](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/organizations) for the exact fields and procedures. ### Notifications The Notifications page provides targeted and broadcast in-app messages, delivery history, filters, details and confirmed bulk deletion. Signed-in users read their own messages from the notification center in the application sidebar. See the [Notifications guide](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/notifications) for the database model, routes and customization points. ### App Config The App Config page displays values from the app, authentication, billing and storage configuration files. It is read-only. Change configuration in the corresponding files and redeploy the application. ## Admin tRPC Routers The admin router registers only these namespaces: ```ts filename="trpc/routers/admin/index.ts" lineNumbers export const adminRouter = createTRPCRouter({ notification: adminNotificationRouter, organization: adminOrganizationRouter, user: adminUserRouter }); ``` Admin billing actions therefore use `trpc.admin.organization`. The kit does not register `trpc.admin.subscription` or `trpc.admin.credit`. ## Next Steps - [Users](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/users) - Manage accounts - [Organizations](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/organizations) - Manage organizations and billing summaries - [Notifications](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/notifications) - Send and review in-app notifications - [Subscriptions](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/subscriptions) - Use the shipped subscription controls - [Credits](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/credits) - Use the shipped credit controls - [App Config](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/app-config) - Inspect runtime configuration --- ## Subscriptions **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/admin-panel/subscriptions **Description**: Monitor and manage organization subscriptions from the admin Organizations page. The shipped kit manages subscriptions from `/dashboard/admin/organizations`. It does not include a standalone `/dashboard/admin/subscriptions` route or an `admin.subscription` tRPC router. ## Included Admin Controls The Organizations table shows the latest subscription for each organization. Admins can: - Search for an organization by name - Filter organizations by subscription status or billing interval - Open a stored subscription in Stripe - Schedule a subscription to cancel at the end of its billing period - Sync subscription and order data from Stripe The table resolves configured Stripe price IDs to plan names where possible. It also shows the subscription status, scheduled cancellation and trial end date. It does not provide revenue analytics or payment history. ## List Subscription Summaries Subscription summaries are returned by the existing organization list procedure: ```tsx filename="components/admin/organizations/organizations-table.tsx" lineNumbers const { data, isPending } = trpc.admin.organization.list.useQuery({ limit: 25, offset: 0, query: '', sortBy: 'name', sortOrder: 'asc', filters: { subscriptionStatus: ['active', 'trialing'], subscriptionInterval: ['month'] } }); const organizations = data?.organizations ?? []; // Each result includes subscriptionId, subscriptionStatus, // subscriptionPlan and cancelAtPeriodEnd. ``` There is no `trpc.admin.subscription.list` procedure. The list returns the most recent stored subscription for each organization instead of every subscription record. ## Cancel a Subscription The row action uses the admin organization router: ```tsx lineNumbers const cancelSubscription = trpc.admin.organization.cancelSubscription.useMutation(); cancelSubscription.mutate({ subscriptionId, immediate: false }); ``` The shipped interface always passes `immediate: false`, which schedules the subscription to cancel at the end of its current period. The procedure also accepts `immediate: true` for custom admin interfaces. Stripe webhooks update the local subscription record after cancellation. ## Sync Billing Data from Stripe Use the sync procedure with one or more organization IDs: ```tsx lineNumbers const syncFromStripe = trpc.admin.organization.syncFromStripe.useMutation(); syncFromStripe.mutate({ organizationIds: [organizationId] }); ``` This syncs subscriptions and one-time orders for the selected organizations. The input accepts between 1 and 1,000 organization IDs. The Organizations table supports both a row action and a bulk action for this procedure. ## Not Included The current kit does not include: - A separate admin subscriptions page - `trpc.admin.subscription.list`, `trpc.admin.subscription.cancel` or `trpc.admin.subscription.syncFromStripe` - A global subscription table with revenue or churn reporting - Admin payment history or bulk subscription cancellation Organization owners and organization admins manage their own billing from `/dashboard/organization/settings?tab=subscription` through the `trpc.organization.subscription` router. ## Related Documentation - [Organizations](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/organizations) - Use the shipped admin interface - [Subscriptions](/docs/starter-kits/pro-nextjs-drizzle/billing/subscriptions) - Understand subscription billing - [Webhooks](/docs/starter-kits/pro-nextjs-drizzle/billing/webhooks) - Keep local billing data synchronized --- ## Users **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/admin-panel/users **Description**: Manage users, ban/unban accounts, and view user details in the admin panel. The Users section of the admin panel allows you to manage all users in your application, including viewing user details, filtering users, banning/unbanning accounts, and exporting user data. ## Features ### View All Users The users table displays all registered users with the following information: - **Name and Email** - User identification - **Role** - User role (user or admin) - **Verification Status** - Whether the email is verified - **Ban Status** - Whether the user is banned - **Created Date** - When the user account was created ### Filter Users You can filter users by multiple criteria: - **Search Query** - Search by name or email - **Role** - Filter by user role (user, admin) - **Email Verification** - Filter by verification status (verified, pending) - **Ban Status** - Filter by ban status (banned, active) - **Creation Date** - Filter by when the account was created (today, this week, this month, older) ### Ban/Unban Users You can ban users temporarily or permanently: ```tsx filename="components/admin/users/ban-user-modal.tsx" lineNumbers import { banUserAdminSchema } from '@/schemas/admin-user-schemas'; // Ban user with optional expiration date const form = useZodForm({ schema: banUserAdminSchema, defaultValues: { userId, reason: '', expiresAt: undefined // Omit the date for a permanent ban } }); ``` **Ban Options:** - **Permanent Ban** - Omit `expiresAt` or set it to `undefined` - **Temporary Ban** - Set `expiresAt` to a future date to automatically unban the user - **Ban Reason** - Provide a reason for the ban (stored for audit purposes) ### Export Users Export user data to CSV format for analysis or backup: ```ts filename="trpc/routers/admin/admin-user-router.ts" lineNumbers exportSelectedToCsv: protectedAdminProcedure .input(exportUsersAdminSchema) .mutation(async ({ input }) => { const users = await db.query.userTable.findMany({ where: inArray(userTable.id, input.userIds), }); const Papa = await import('papaparse'); const csv = Papa.unparse(users); return csv; }), ``` ## Using the Admin Users API ### List Users ```tsx filename="components/admin/users/users-table.tsx" lineNumbers import { trpc } from '@/trpc/client'; export function UsersTable() { const { data, isPending } = trpc.admin.user.list.useQuery({ limit: 25, offset: 0, query: '', // Optional search query sortBy: 'name', // 'name' | 'email' | 'role' | 'createdAt' sortOrder: 'asc', // 'asc' | 'desc' filters: { role: ['user'], // Optional role filter emailVerified: ['verified'], // Optional: 'verified' | 'pending' banned: ['active'], // Optional: 'active' | 'banned' createdAt: ['today'] // Optional: 'today' | 'this-week' | 'this-month' | 'older' } }); return (
{data?.users.map((user) => (
{user.name} - {user.email}
))}
); } ``` ### Ban a User ```tsx filename="components/admin/users/ban-user-modal.tsx" lineNumbers const banUser = trpc.admin.user.banUser.useMutation({ onSuccess: () => { toast.success('User banned successfully'); utils.admin.user.list.invalidate(); } }); const handleBan = (data: BanUserInput) => { banUser.mutate({ userId: user.id, reason: data.reason, expiresAt: data.expiresAt }); }; ``` ### Unban a User ```tsx filename="components/admin/users/users-table.tsx" lineNumbers const unbanUser = trpc.admin.user.unbanUser.useMutation({ onSuccess: () => { toast.success('User unbanned successfully'); utils.admin.user.list.invalidate(); } }); const handleUnban = (userId: string) => { unbanUser.mutate({ userId }); }; ``` ## User Management Best Practices ### When to Ban Users Ban users when they: - Violate the terms of service - Engage in abusive behavior - Attempt to exploit the system - Show signs of fraudulent activity ### Temporary vs Permanent Bans - **Temporary bans** - Use for first-time violations or minor infractions - **Permanent bans** - Use for serious violations or repeat offenders - Always provide a clear reason for the ban ### User Data Privacy - Only export user data when necessary - Ensure compliance with applicable data protection regulations - Store exported data securely - Delete exported files after use ## Related Documentation - [Admin UI](/docs/starter-kits/pro-nextjs-drizzle/authentication/superadmin) - Learn how to create admin users - [Permissions and Access Control](/docs/starter-kits/pro-nextjs-drizzle/authentication/permissions) - Understand user roles and permissions --- ## AI **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/ai-integration **Description**: Learn how to leverage the built-in AI features including chatbots and LLM integration. The Pro Next.js Drizzle starter kit ships an organization-scoped chat built with **AI SDK 7**, the direct **OpenAI provider**, tRPC history and usage-based credits. This page describes the code in the repository. Sections labeled as custom examples are additions you can build yourself. ## Overview The AI system is built with a hybrid architecture to support high-performance streaming while maintaining a type-safe tRPC API for CRUD operations. | Feature | Technology | Reason | | ------------------- | ------------- | -------------------------------- | | Streaming responses | API Route | tRPC doesn't support streaming | | Chat CRUD | tRPC | Type-safe, cached queries | | State management | Vercel AI SDK | `useChat` hook handles streaming | ## Configuration Add your OpenAI API key to the `.env` file to enable the AI features. ```ini filename=".env" lineNumbers OPENAI_API_KEY=sk-... ``` ## Streaming Endpoint The complete shipped route lives at `app/api/ai/chat/route.ts`. It authenticates the request, validates the selected model, checks organization access and credits, persists the response and returns a text stream. The reduced example below shows the same message normalization and stream protocol without the product-specific billing flow. ```typescript filename="app/api/ai/example/route.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { streamText, type ModelMessage } from 'ai'; import { z } from 'zod/v4'; const messageSchema = z .object({ role: z.enum(['user', 'assistant', 'system']), content: z.string().optional(), parts: z .array( z.object({ type: z.string(), text: z.string().optional() }) ) .optional() }) .passthrough(); const requestSchema = z.object({ messages: z.array(messageSchema) }); function toModelMessages( messages: z.infer[] ): ModelMessage[] { return messages.map((message) => { const content = message.content ?? message.parts?.find((part) => part.type === 'text')?.text ?? ''; switch (message.role) { case 'system': return { role: 'system', content }; case 'assistant': return { role: 'assistant', content }; default: return { role: 'user', content }; } }); } export async function POST(req: Request) { const body = requestSchema.parse(await req.json()); const result = streamText({ model: openai('gpt-4o-mini'), messages: toModelMessages(body.messages) }); return result.toUIMessageStreamResponse({ onError: () => 'AI is temporarily unavailable. Please try again later.' }); } ``` `DefaultChatTransport` and `toUIMessageStreamResponse()` are the matched pair used by the shipped chat. The UI message protocol carries sanitized failures as well as generated text. Normalize message parts before passing them to `streamText`, as the shipped route does. ## UI Components We provide a complete suite of components to build a premium AI chat experience. ### Main Chat Component The `AiChat` component provides a full conversation interface with a history sidebar. ```tsx filename="app/(saas)/dashboard/(sidebar)/organization/chatbot/page.tsx" lineNumbers import { redirect } from 'next/navigation'; import { AiChat } from '@/components/ai/ai-chat'; import { getOrganizationById, getSession } from '@/lib/auth/server'; export default async function ChatbotPage() { const session = await getSession(); const organizationId = session?.session.activeOrganizationId; if (!organizationId) redirect('/dashboard'); const organization = await getOrganizationById(organizationId); if (!organization) redirect('/dashboard'); return ; } ``` ### Custom Hook For more control, you can use the `useChat` hook directly from the Vercel AI SDK. ```tsx filename="components/my-custom-ai.tsx" lineNumbers 'use client'; import { useState, type FormEvent } from 'react'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; type MyCustomAIProps = { chatId: string; organizationId: string; }; export function MyCustomAI({ chatId, organizationId }: MyCustomAIProps) { const [input, setInput] = useState(''); const { messages, sendMessage, status } = useChat({ id: chatId, transport: new DefaultChatTransport({ api: '/api/ai/chat', body: { chatId, organizationId } }) }); const isSending = status === 'submitted' || status === 'streaming'; function handleSubmit(event: FormEvent) { event.preventDefault(); const text = input.trim(); if (!text) return; setInput(''); sendMessage({ role: 'user', parts: [{ type: 'text', text }] }); } return (

{messages.length} messages

setInput(event.target.value)} disabled={isSending} />
); } ``` ## Custom Example: Tool Calling The shipped route does not register tools. You can add a tool definition like this and pass it to `streamText`. The UI message protocol can carry tool parts, but the client must render and handle each tool state. ```typescript filename="lib/ai/find-leads-tool.ts" lineNumbers import { tool } from 'ai'; import { ilike } from 'drizzle-orm'; import { z } from 'zod/v4'; import { db } from '@/lib/db'; import { leadTable } from '@/lib/db/schema'; export const findLeadsTool = tool({ description: 'Find leads in the database', inputSchema: z.object({ query: z.string() }), execute: async ({ query }) => { return await db.query.leadTable.findMany({ where: ilike(leadTable.name, `%${query}%`) }); } }); ``` --- ## AI **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/ai **Description**: Learn how to leverage the built-in AI features including chatbots and LLM integration. The Pro Next.js Drizzle starter kit ships an organization-scoped chat built with **AI SDK 7**, the direct **OpenAI provider**, tRPC history and usage-based credits. This page describes the code in the repository. Sections labeled as custom examples are additions you can build yourself. ## Overview The AI system is built with a hybrid architecture to support high-performance streaming while maintaining a type-safe tRPC API for CRUD operations. | Feature | Technology | Reason | | ------------------- | ------------- | -------------------------------- | | Streaming responses | API Route | tRPC doesn't support streaming | | Chat CRUD | tRPC | Type-safe, cached queries | | State management | Vercel AI SDK | `useChat` hook handles streaming | ## Configuration Add your OpenAI API key to the `.env` file to enable the AI features. ```ini filename=".env" lineNumbers OPENAI_API_KEY=sk-... ``` ## Streaming Endpoint The complete shipped route lives at `app/api/ai/chat/route.ts`. It authenticates the request, validates the selected model, checks organization access and credits, persists the response and returns a text stream. The reduced example below shows the same message normalization and stream protocol without the product-specific billing flow. ```typescript filename="app/api/ai/example/route.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { streamText, type ModelMessage } from 'ai'; import { z } from 'zod/v4'; const messageSchema = z .object({ role: z.enum(['user', 'assistant', 'system']), content: z.string().optional(), parts: z .array( z.object({ type: z.string(), text: z.string().optional() }) ) .optional() }) .passthrough(); const requestSchema = z.object({ messages: z.array(messageSchema) }); function toModelMessages( messages: z.infer[] ): ModelMessage[] { return messages.map((message) => { const content = message.content ?? message.parts?.find((part) => part.type === 'text')?.text ?? ''; switch (message.role) { case 'system': return { role: 'system', content }; case 'assistant': return { role: 'assistant', content }; default: return { role: 'user', content }; } }); } export async function POST(req: Request) { const body = requestSchema.parse(await req.json()); const result = streamText({ model: openai('gpt-4o-mini'), messages: toModelMessages(body.messages) }); return result.toUIMessageStreamResponse({ onError: () => 'AI is temporarily unavailable. Please try again later.' }); } ``` `DefaultChatTransport` and `toUIMessageStreamResponse()` are the matched pair used by the shipped chat. The UI message protocol carries sanitized failures as well as generated text. Normalize message parts before passing them to `streamText`, as the shipped route does. ## UI Components We provide a complete suite of components to build a premium AI chat experience. ### Main Chat Component The `AiChat` component provides a full conversation interface with a history sidebar. ```tsx filename="app/(saas)/dashboard/(sidebar)/organization/chatbot/page.tsx" lineNumbers import { redirect } from 'next/navigation'; import { AiChat } from '@/components/ai/ai-chat'; import { getOrganizationById, getSession } from '@/lib/auth/server'; export default async function ChatbotPage() { const session = await getSession(); const organizationId = session?.session.activeOrganizationId; if (!organizationId) redirect('/dashboard'); const organization = await getOrganizationById(organizationId); if (!organization) redirect('/dashboard'); return ; } ``` ### Custom Hook For more control, you can use the `useChat` hook directly from the Vercel AI SDK. ```tsx filename="components/my-custom-ai.tsx" lineNumbers 'use client'; import { useState, type FormEvent } from 'react'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; type MyCustomAIProps = { chatId: string; organizationId: string; }; export function MyCustomAI({ chatId, organizationId }: MyCustomAIProps) { const [input, setInput] = useState(''); const { messages, sendMessage, status } = useChat({ id: chatId, transport: new DefaultChatTransport({ api: '/api/ai/chat', body: { chatId, organizationId } }) }); const isSending = status === 'submitted' || status === 'streaming'; function handleSubmit(event: FormEvent) { event.preventDefault(); const text = input.trim(); if (!text) return; setInput(''); sendMessage({ role: 'user', parts: [{ type: 'text', text }] }); } return (

{messages.length} messages

setInput(event.target.value)} disabled={isSending} />
); } ``` ## Custom Example: Tool Calling The shipped route does not register tools. You can add a tool definition like this and pass it to `streamText`. The UI message protocol can carry tool parts, but the client must render and handle each tool state. ```typescript filename="lib/ai/find-leads-tool.ts" lineNumbers import { tool } from 'ai'; import { ilike } from 'drizzle-orm'; import { z } from 'zod/v4'; import { db } from '@/lib/db'; import { leadTable } from '@/lib/db/schema'; export const findLeadsTool = tool({ description: 'Find leads in the database', inputSchema: z.object({ query: z.string() }), execute: async ({ query }) => { return await db.query.leadTable.findMany({ where: ilike(leadTable.name, `%${query}%`) }); } }); ``` --- ## Chatbot **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/ai/chatbot **Description**: Build AI-powered chatbots with streaming responses and conversation history. The starter kit ships a complete organization chatbot with text streaming, conversation history, model selection and credit accounting. The repository uses AI SDK 7. Custom examples on this page are labeled so they are not confused with shipped files. ## Overview The chatbot uses: - **Vercel AI SDK** - For streaming responses and state management - **tRPC** - For type-safe chat CRUD operations - **OpenAI** - For the LLM backend (configurable) ## Streaming Endpoint The shipped `app/api/ai/chat/route.ts` authenticates and validates each request, checks organization ownership and credit balance, normalizes UI message parts, calls OpenAI, deducts the actual credit cost and saves the response. This reduced example keeps the same UI message protocol and normalization but omits model selection and credit accounting. ```typescript filename="app/api/ai/chat/route.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { streamText, type ModelMessage } from 'ai'; import { and, eq } from 'drizzle-orm'; import { assertUserIsOrgMember, getSession } from '@/lib/auth/server'; import { db } from '@/lib/db'; import { aiChatTable } from '@/lib/db/schema'; type ChatRequest = { messages: Array<{ role: 'user' | 'assistant' | 'system'; content?: string; parts?: Array<{ type: string; text?: string }>; }>; chatId: string; organizationId: string; }; function toModelMessages(messages: ChatRequest['messages']): ModelMessage[] { return messages.map((message) => { const content = message.content ?? message.parts?.find((part) => part.type === 'text')?.text ?? ''; switch (message.role) { case 'system': return { role: 'system', content }; case 'assistant': return { role: 'assistant', content }; default: return { role: 'user', content }; } }); } export async function POST(req: Request) { const session = await getSession(); if (!session) { return Response.json({ error: 'Unauthorized' }, { status: 401 }); } const { messages: uiMessages, chatId, organizationId }: ChatRequest = await req.json(); await assertUserIsOrgMember(organizationId, session.user.id); const messages = toModelMessages(uiMessages); const result = streamText({ model: openai('gpt-4o-mini'), messages, async onFinish({ text }) { // Save assistant's response to the database const updatedMessages = [ ...messages, { role: 'assistant' as const, content: text } ]; await db .update(aiChatTable) .set({ messages: JSON.stringify(updatedMessages) }) .where( and( eq(aiChatTable.id, chatId), eq(aiChatTable.organizationId, organizationId) ) ); } }); return result.toUIMessageStreamResponse({ onError: () => 'AI is temporarily unavailable. Please try again later.' }); } ``` `DefaultChatTransport` on the client must stay paired with `toUIMessageStreamResponse()` on the server. Do not pass the hook's raw `UIMessage[]` to `streamText`; normalize its `parts` to model content first, as the shipped route does. ## UI Components ### Main Chat Component The `AiChat` component provides a full conversation interface with a history sidebar. ```tsx filename="app/(saas)/dashboard/(sidebar)/organization/chatbot/page.tsx" lineNumbers import { AiChat } from '@/components/ai/ai-chat'; import { getSession } from '@/lib/auth/server'; export default async function AiPage() { const session = await getSession(); const organizationId = session?.session.activeOrganizationId; if (!organizationId) { return
No active organization
; } return ; } ``` ### Custom Component This optional component shows the AI SDK 7 transport and input APIs in a self-contained example. The shipped `AiChat` component has additional history, billing and error handling behavior. ```tsx filename="components/my-custom-ai.tsx" lineNumbers 'use client'; import { useState, type FormEvent } from 'react'; import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; import { MessageResponse } from '@/components/ai/message'; type MyCustomAIProps = { chatId: string; organizationId: string; }; export function MyCustomAI({ chatId, organizationId }: MyCustomAIProps) { const [input, setInput] = useState(''); const { messages, sendMessage, status } = useChat({ id: chatId, transport: new DefaultChatTransport({ api: '/api/ai/chat', body: { chatId, organizationId } }) }); const isSending = status === 'submitted' || status === 'streaming'; function handleSubmit(event: FormEvent) { event.preventDefault(); const text = input.trim(); if (!text) return; setInput(''); sendMessage({ role: 'user', parts: [{ type: 'text', text }] }); } return (
{messages.map((message) => { const text = message.parts .filter((part) => part.type === 'text') .map((part) => part.text) .join(''); return (
{message.role}:{' '} {message.role === 'assistant' ? ( {text} ) : ( {text} )}
); })}
setInput(event.target.value)} placeholder="Type a message..." disabled={isSending} />
); } ``` ## Conversation History Chats are stored in the database and can be retrieved via tRPC: ```typescript filename="trpc/routers/organization/organization-ai-router.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; import { and, desc, eq, sql } from 'drizzle-orm'; import { z } from 'zod'; import { appConfig } from '@/config/app.config'; import { db } from '@/lib/db'; import { aiChatTable } from '@/lib/db/schema'; export const organizationAiRouter = createTRPCRouter({ listChats: protectedOrganizationProcedure .input( z .object({ limit: z .number() .min(1) .max(appConfig.pagination.maxLimit) .optional() .default(appConfig.pagination.defaultLimit), offset: z.number().min(0).optional().default(0) }) .optional() ) .query(async ({ ctx, input }) => { // Use SQL builder to select only needed columns and extract first message const chats = await db .select({ id: aiChatTable.id, title: aiChatTable.title, pinned: aiChatTable.pinned, createdAt: aiChatTable.createdAt, firstMessageContent: sql` CASE WHEN ${aiChatTable.messages} IS NOT NULL AND ${aiChatTable.messages}::jsonb != '[]'::jsonb THEN (${aiChatTable.messages}::jsonb->0->>'content') ELSE NULL END `.as('first_message_content') }) .from(aiChatTable) .where(eq(aiChatTable.organizationId, ctx.organization.id)) .orderBy(desc(aiChatTable.pinned), desc(aiChatTable.createdAt)) .limit(input?.limit ?? 20) .offset(input?.offset ?? 0); return { chats }; }), getChat: protectedOrganizationProcedure .input(z.object({ id: z.string().uuid() })) .query(async ({ ctx, input }) => { const chat = await db.query.aiChatTable.findFirst({ where: and( eq(aiChatTable.id, input.id), eq(aiChatTable.organizationId, ctx.organization.id) ) }); if (!chat) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Chat not found' }); } return { chat: { ...chat, messages: chat.messages ? JSON.parse(chat.messages) : [] } }; }), createChat: protectedOrganizationProcedure .input(z.object({ title: z.string().optional() }).optional()) .mutation(async ({ ctx, input }) => { const [chat] = await db .insert(aiChatTable) .values({ organizationId: ctx.organization.id, title: input?.title || 'New Chat', messages: JSON.stringify([]) }) .returning(); return { chat }; }), deleteChat: protectedOrganizationProcedure .input(z.object({ id: z.string().uuid() })) .mutation(async ({ input, ctx }) => { await db .delete(aiChatTable) .where( and( eq(aiChatTable.id, input.id), eq(aiChatTable.organizationId, ctx.organization.id) ) ); }) }); ``` ## Custom Example: Tool Calling The shipped chat route does not register tools. This custom route expects `ModelMessage[]`, not the `UIMessage[]` returned by `useChat`. Protect it with the same authentication and organization checks as the shipped route before using it in production. ```typescript filename="app/api/ai/chat/route.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { streamText, type ModelMessage } from 'ai'; import { ilike } from 'drizzle-orm'; import { z } from 'zod/v4'; import { db } from '@/lib/db'; import { leadTable } from '@/lib/db/schema'; export async function POST(req: Request) { const { messages }: { messages: ModelMessage[] } = await req.json(); const result = streamText({ model: openai('gpt-4o-mini'), messages, tools: { findLeads: { description: 'Find leads in the database by name', inputSchema: z.object({ query: z.string().describe('The search query') }), execute: async ({ query }) => { const leads = await db.query.leadTable.findMany({ where: ilike(leadTable.name, `%${query}%`), limit: 10 }); return leads; } } } }); return result.toTextStreamResponse(); } ``` ## Custom Example: Generation Settings The shipped route only accepts model IDs from `chatModels` in `config/billing.config.ts`. For a separate fixed-model helper, use the AI SDK 7 `maxOutputTokens` setting: ```typescript filename="lib/ai/generate-short-reply.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { generateText } from 'ai'; export async function generateShortReply(prompt: string) { const { text } = await generateText({ model: openai('gpt-4o-mini'), prompt, temperature: 0.7, maxOutputTokens: 1000 }); return text; } ``` ## Custom Example: Error Handling This custom route also accepts `ModelMessage[]`. A client using `useChat` must normalize its message parts first or switch to the UI message protocol. ```typescript filename="app/api/ai/chat/route.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { streamText, type ModelMessage } from 'ai'; export async function POST(req: Request) { try { const { messages }: { messages: ModelMessage[] } = await req.json(); const result = streamText({ model: openai('gpt-4o-mini'), messages }); return result.toTextStreamResponse(); } catch (error) { console.error('AI chat error:', error); return Response.json( { error: 'Failed to process chat request' }, { status: 500 } ); } } ``` ## Custom Rate Limiting The starter kit does not ship a generic `@/lib/rate-limit` module. It checks organization credit balance before generation and deducts actual usage after generation. If you need request-frequency limits too, add a durable rate-limit provider and enforce it after authentication. ## Best Practices 1. **Stream responses** - Always use streaming for better UX 2. **Save conversations** - Store chat history in the database 3. **Implement rate limiting** - Control API costs 4. **Handle errors** - Provide user-friendly error messages 5. **Use tools wisely** - Add tools for database queries and external APIs 6. **Monitor usage** - Track token usage and costs --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/ai/overview **Description**: Learn about the built-in AI features powered by the Vercel AI SDK. The Pro Next.js Drizzle starter kit ships an organization-scoped chat powered by **AI SDK 7** and the direct **OpenAI provider**. It includes text streaming, tRPC conversation history, model selection and usage-based credits. ## Architecture The AI system is built with a hybrid architecture to support high-performance streaming while maintaining a type-safe tRPC API for CRUD operations. | Feature | Technology | Reason | | ------------------- | ------------- | -------------------------------- | | Streaming responses | API Route | tRPC doesn't support streaming | | Chat CRUD | tRPC | Type-safe, cached queries | | State management | Vercel AI SDK | `useChat` hook handles streaming | ## Configuration Add your OpenAI API key to the `.env` file to enable the AI features. ```env filename=".env" lineNumbers OPENAI_API_KEY=sk-... ``` Getting Your API Key Create an API key at{' '} OpenAI Platform . Make sure to keep it secure and never commit it to version control. OPENAI_API_KEY must contain a secret key created in the OpenAI Platform. OpenAI keys commonly begin with sk-. A value beginning with pk_test_ is a Stripe publishable test key and cannot authenticate an OpenAI request. Keep the OpenAI key server-only and never add a NEXT_PUBLIC_ prefix. A ChatGPT Plus, Pro or Team subscription does not include OpenAI API usage. The OpenAI Platform project that owns the key must have API billing enabled, available credit and a budget that permits requests. ## Shipped Provider The repository installs `@ai-sdk/openai` and configures OpenAI models in `config/billing.config.ts`. Anthropic, Google, Mistral and other provider packages are not included. Install and configure another provider package before using it in custom code. See the [AI SDK provider directory](https://ai-sdk.dev/providers/ai-sdk-providers) for provider-specific installation and configuration. ## Verify the Integration After adding `OPENAI_API_KEY`, restart the development server and verify the complete chat flow: 1. Create or select an organization with a positive credit balance. 2. Open **AI Chatbot**, create a new chat and send a short prompt with **GPT-4o Mini**. 3. Confirm assistant text streams into the page. 4. Reload the chat and confirm both messages were persisted. 5. Open **Settings → Credits** and confirm a usage transaction was recorded. An HTTP `200` from `/api/ai/chat` alone does not prove generation succeeded. Streaming responses send their headers before the provider has finished. If no assistant text appears, verify the server-side key, OpenAI quota and access to the selected model, then inspect the server logs for the provider error. ### No assistant response Use the provider error in the development server log to identify the failing layer: | Provider error | Meaning | What to check | | ------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `invalid_api_key` or HTTP `401` | OpenAI rejected the credential | Create a server key in the intended OpenAI Platform project, replace `OPENAI_API_KEY` and restart the development server | | `insufficient_quota` or HTTP `429` with a quota message | The key is valid but its project cannot spend | Enable API billing, add credit and confirm the project budget allows requests | | `model_not_found` or HTTP `404` | The project cannot use the selected model | Select a model listed in `config/billing.config.ts` that is available to the project | | `insufficient_credits` or HTTP `402` from Achromatic | The organization does not have enough application credits | Add credits in the application, then retry the message | OpenAI API keys are scoped to a project. When you create a replacement key, verify that the selected project is the same project where API billing and the budget are configured. Never paste a real key into an issue, support message or client-side environment variable. Rotate any key that has been shared. The shipped route logs the original provider failure on the server and sends a reviewed quota, credential, model, rate-limit or generic message through the UI stream. Raw provider responses stay out of the browser because they can contain sensitive request details. --- ## Prompting **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/ai/prompting **Description**: Learn how to use LLMs for text generation, completion and prompting. The starter kit ships the AI chat described in the [Chatbot guide](/docs/starter-kits/pro-nextjs-drizzle/ai/chatbot). The examples on this page are custom additions you can build with the same AI SDK 7 and OpenAI packages already installed in the repository. ## Basic Text Generation Generate text using the `generateText` function: ```typescript filename="lib/ai/generate.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { generateText } from 'ai'; export async function generateSummary(content: string) { const { text } = await generateText({ model: openai('gpt-4o-mini'), prompt: `Summarize the following content in 3 sentences:\n\n${content}` }); return text; } ``` ## Server Actions Use AI in Server Actions: ```typescript filename="app/actions/generate-content.ts" lineNumbers 'use server'; import { openai } from '@ai-sdk/openai'; import { generateText } from 'ai'; export async function generateBlogPost(topic: string) { const { text } = await generateText({ model: openai('gpt-4o-mini'), prompt: `Write a blog post about: ${topic}`, maxOutputTokens: 2000 }); return text; } ``` ## Structured Outputs Generate structured JSON outputs: ```typescript filename="lib/ai/generate-structured.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { generateText, Output } from 'ai'; import { z } from 'zod'; const ProductSchema = z.object({ name: z.string(), description: z.string(), price: z.number(), features: z.array(z.string()) }); export async function generateProduct(productType: string) { const { output } = await generateText({ model: openai('gpt-4o-mini'), output: Output.object({ schema: ProductSchema }), prompt: `Generate a product specification for: ${productType}` }); return output; } ``` ## Prompt Templates Create reusable prompt templates: ```typescript filename="lib/ai/prompts.ts" lineNumbers export const prompts = { summarize: (content: string) => `Summarize the following content in 3 sentences:\n\n${content}`, translate: (text: string, targetLanguage: string) => `Translate the following text to ${targetLanguage}:\n\n${text}`, extractKeywords: (content: string) => `Extract 5 key keywords from the following content:\n\n${content}`, generateTitle: (content: string) => `Generate a compelling title for the following content:\n\n${content}` }; ``` Usage: ```typescript filename="lib/ai/use-prompts.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { generateText } from 'ai'; import { prompts } from './prompts'; export async function summarizeContent(content: string) { const { text } = await generateText({ model: openai('gpt-4o-mini'), prompt: prompts.summarize(content) }); return text; } ``` ## System Prompts Use system prompts to guide model behavior: ```typescript filename="lib/ai/generate-with-system.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { generateText } from 'ai'; export async function generateResponse(userInput: string) { const { text } = await generateText({ model: openai('gpt-4o-mini'), system: 'You are a helpful assistant that provides concise, accurate answers.', prompt: userInput }); return text; } ``` ## Temperature and Sampling Control randomness and creativity: ```typescript filename="lib/ai/generate-creative.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { generateText } from 'ai'; // Creative writing (higher temperature) export async function generateCreativeStory(prompt: string) { const { text } = await generateText({ model: openai('gpt-4o-mini'), prompt, temperature: 0.9, // More creative maxOutputTokens: 1000 }); return text; } // Factual content (lower temperature) export async function generateFactualContent(prompt: string) { const { text } = await generateText({ model: openai('gpt-4o-mini'), prompt, temperature: 0.2, // More deterministic maxOutputTokens: 500 }); return text; } ``` ## Streaming Text Generation Stream text generation for better UX: ```typescript filename="app/api/ai/generate/route.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { streamText } from 'ai'; export async function POST(req: Request) { const { prompt } = await req.json(); const result = streamText({ model: openai('gpt-4o-mini'), prompt }); return result.toTextStreamResponse(); } ``` Client-side usage: ```tsx filename="components/streaming-generator.tsx" lineNumbers 'use client'; import { useCompletion } from '@ai-sdk/react'; import { MessageResponse } from '@/components/ai/message'; export function StreamingGenerator() { const { completion, input, handleInputChange, handleSubmit, isLoading } = useCompletion({ api: '/api/ai/generate', streamProtocol: 'text' }); return (
{completion}
); } ``` ## Custom Provider Packages The starter kit installs the OpenAI provider only. To add another provider, follow its current instructions in the [AI SDK provider directory](https://ai-sdk.dev/providers/ai-sdk-providers), install the provider package and choose a model that the provider currently supports. Provider packages and model IDs are intentionally not hard-coded here because they are not part of the shipped repository. ## Error Handling Handle API errors gracefully: ```typescript filename="lib/ai/generate-safe.ts" lineNumbers import { openai } from '@ai-sdk/openai'; import { generateText } from 'ai'; export async function generateTextSafely(prompt: string) { try { const { text } = await generateText({ model: openai('gpt-4o-mini'), prompt }); return { success: true, text }; } catch (error) { console.error('AI generation error:', error); return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; } } ``` ## Best Practices 1. **Use appropriate models** - Choose models based on task complexity 2. **Set temperature wisely** - Lower for factual, higher for creative 3. **Limit token usage** - Set `maxOutputTokens` to control costs 4. **Use system prompts** - Guide model behavior with system messages 5. **Handle errors** - Always wrap AI calls in try-catch 6. **Cache results** - Cache expensive generations when possible 7. **Monitor usage** - Track token usage and costs --- ## Authentication **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/authentication **Description**: Learn how to manage user authentication and authorization with Better Auth. The Pro Next.js Drizzle starter kit uses **Better Auth** for robust and flexible authentication. It supports various providers, multi-factor authentication and organization-based access control. ## Setup Authentication is primarily configured in `lib/auth/index.ts`. Follow the [Setup](/docs/starter-kits/pro-nextjs-drizzle/setup) to set up the basic environment variables. ```typescript filename="lib/auth/index.ts" lineNumbers import { betterAuth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { db } from '@/lib/db'; import { env } from '@/lib/env'; export const auth = betterAuth({ database: drizzleAdapter(db, { provider: 'pg' }), emailAndPassword: { enabled: true }, socialProviders: { google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET } } // ... other configurations }); ``` ## Client Usage On the client side, use the `authClient` and the `useSession` hook to access user information. ```typescript filename="hooks/use-session.tsx" lineNumbers import { authClient } from '@/lib/auth/client'; const { data: session, isPending } = authClient.useSession(); ``` ## Protecting Routes ### API Routes Better Auth handles authentication through API routes at `app/api/auth/[...all]/route.ts`. This route proxies all authentication requests to Better Auth. ### Page Protection Routes are protected by checking the session in Server Components. Use `getSession()` from `@/lib/auth/server` to verify authentication. ### Server-side (tRPC) For API routes, use `protectedProcedure` or `protectedOrganizationProcedure` in your tRPC routers. ```typescript filename="trpc/routers/organization/organization-router.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; export const organizationRouter = createTRPCRouter({ get: protectedOrganizationProcedure.query(async ({ ctx }) => { // Current organization is available in ctx.organization return ctx.organization; }) }); ``` ## Organizations Multi-tenancy is built-in. Users can create, join and switch between organizations. ### Creating an Organization ```typescript filename="components/organization-switcher.tsx" lineNumbers await authClient.organization.create({ name: 'My New Company' }); ``` ### Inviting Members ```typescript filename="lib/actions/organization.ts" lineNumbers await authClient.organization.inviteMember({ email: 'teammate@example.com', role: 'member' }); ``` ## Admin Features Platform admins can manage users and organizations through a dedicated admin panel. ### Impersonation Admins can impersonate users for debugging purposes. ```typescript filename="lib/actions/admin.ts" lineNumbers await authClient.admin.impersonateUser({ userId: 'user-id-to-impersonate' }); ``` ### Banning Users Admins can ban users permanently or for a specific duration. ```typescript filename="trpc/routers/admin/admin-user-router.ts" lineNumbers import { banUserAdminSchema } from '@/schemas/admin-user-schemas'; import { createTRPCRouter, protectedAdminProcedure } from '@/trpc/init'; export const adminUserRouter = createTRPCRouter({ banUser: protectedAdminProcedure .input(banUserAdminSchema) .mutation(async ({ input, ctx }) => { // Ban user logic return { success: true }; }) }); ``` --- ## OAuth Providers **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/authentication/oauth **Description**: Learn how to set up and configure OAuth providers. The starter kit comes with Google OAuth pre-configured, but you can easily add additional OAuth providers like Facebook, GitHub or any provider supported by Better Auth. ## Plan OAuth URLs for Every Environment OAuth providers compare the callback URL in each request with the URLs saved in their developer console. Register every environment where people will complete sign-in: | Environment | Application origin | Google callback URL | | ----------------- | -------------------------------- | --------------------------------------------------------- | | Local development | `http://localhost:3000` | `http://localhost:3000/api/auth/callback/google` | | Staging | `https://staging.yourdomain.com` | `https://staging.yourdomain.com/api/auth/callback/google` | | Production | `https://yourdomain.com` | `https://yourdomain.com/api/auth/callback/google` | The kit derives Better Auth's `baseURL` from `getBaseUrl()` in `lib/utils.ts`. On Vercel, preview deployments use `NEXT_PUBLIC_VERCEL_BRANCH_URL`. Production uses `NEXT_PUBLIC_SITE_URL` when it is set, then falls back to Vercel's generated URL. Google requires an exact registered callback URL. A new branch preview can have a new hostname, so arbitrary preview URLs are not a reliable place to test OAuth. Use a stable staging domain for repeatable pre-production testing or add the exact preview callback URL before testing that deployment. Use separate OAuth clients for local or staging work and production when your provider supports it. Store each client secret only in that environment and never expose it through a NEXT_PUBLIC_ variable. ## Google OAuth (Pre-configured) Google OAuth is already set up in the starter kit. To enable it: ### 1. Create Google OAuth Credentials 1. Visit the [Google Cloud Console](https://console.cloud.google.com/) 2. Create a new project or select an existing one 3. Navigate to **APIs & Services** > **Credentials** 4. Click **Create Credentials** > **OAuth client ID** 5. Configure the OAuth consent screen if you haven't already 6. Select **Web application** as the application type 7. Add authorized JavaScript origins: - `http://localhost:3000` (for development) - `https://yourdomain.com` (for production) 8. Add authorized redirect URIs: - `http://localhost:3000/api/auth/callback/google` (for development) - `https://yourdomain.com/api/auth/callback/google` (for production) 9. Copy the **Client ID** and **Client Secret** ### 2. Configure Environment Variables Add the credentials to your `.env` file: ```env filename=".env" lineNumbers GOOGLE_CLIENT_ID=your-google-client-id GOOGLE_CLIENT_SECRET=your-google-client-secret ``` ### 3. Verify Configuration The Google provider is already configured in `lib/auth/index.ts`: ```typescript filename="lib/auth/index.ts" lineNumbers socialProviders: { google: { prompt: "select_account", clientId: env.GOOGLE_CLIENT_ID ?? "", clientSecret: env.GOOGLE_CLIENT_SECRET ?? "", scope: ["email", "profile"], }, }, ``` The sign-in and sign-up pages render providers from `lib/auth/oauth-providers.tsx`. Google is included there by default. If the credentials are not ready, set `enableSocialLogin` to `false` in `config/auth.config.ts` so users are not shown a button that cannot complete authentication. ### 4. Test Both Environments 1. Restart the development server after changing `.env`. 2. Sign in with a Google account and confirm the callback returns to `/dashboard`. 3. Confirm a first-time Google user is created and an existing user follows the account-linking behavior you intend. 4. Repeat the flow on the final HTTPS production domain. Preview and production deployments need callback URLs accepted by the provider before they can complete OAuth. ## Adding Additional OAuth Providers To add a new OAuth provider (e.g., Facebook, GitHub), follow these steps: ### 1. Get Provider Credentials Create an application with your chosen OAuth provider and obtain the Client ID and Client Secret. ### 2. Add Environment Variables Add the provider credentials to your `.env` file: ```env filename=".env" lineNumbers FACEBOOK_CLIENT_ID=your-facebook-client-id FACEBOOK_CLIENT_SECRET=your-facebook-client-secret ``` ### 3. Update Auth Configuration Add the provider to `lib/auth/index.ts`: ```typescript filename="lib/auth/index.ts" lineNumbers import { betterAuth } from 'better-auth'; export const auth = betterAuth({ // ... other config account: { accountLinking: { enabled: true, trustedProviders: ['google', 'facebook'] // Add new provider here } }, socialProviders: { google: { // ... existing Google config }, facebook: { clientId: env.FACEBOOK_CLIENT_ID ?? '', clientSecret: env.FACEBOOK_CLIENT_SECRET ?? '' } } }); ``` ### 4. Update Environment Schema Add the new variables to `lib/env.ts`: ```typescript filename="lib/env.ts" lineNumbers server: { // ... existing variables FACEBOOK_CLIENT_ID: z.string().optional(), FACEBOOK_CLIENT_SECRET: z.string().optional(), }, ``` ```typescript filename="lib/env.ts" lineNumbers runtimeEnv: { // ... existing variables FACEBOOK_CLIENT_ID: process.env.FACEBOOK_CLIENT_ID, FACEBOOK_CLIENT_SECRET: process.env.FACEBOOK_CLIENT_SECRET, }, ``` ### 5. Add Provider to the UI Registry Add the provider's display name and icon to `lib/auth/oauth-providers.tsx`. The existing sign-in and sign-up cards iterate over this registry, so you do not need to create another button component: ```tsx filename="lib/auth/oauth-providers.tsx" lineNumbers export const oAuthProviders = { google: { name: 'Google', icon: GoogleIcon }, facebook: { name: 'Facebook', icon: FacebookIcon } } as const; ``` The registry key must match the provider key passed to Better Auth. Keep `enableSocialLogin` enabled in `config/auth.config.ts` when at least one listed provider is fully configured. ## Supported Providers Better Auth supports many OAuth providers out of the box: - Google - Facebook - GitHub - Discord - Apple - Microsoft - And many more... For configuration details and social sign-in examples, see the [Better Auth OAuth documentation](https://better-auth.com/docs/concepts/oauth). ## Account Linking The starter kit has account linking enabled, which allows users to connect multiple OAuth providers to the same account. This is configured in the `accountLinking` section: ```typescript filename="lib/auth/index.ts" lineNumbers account: { accountLinking: { enabled: true, trustedProviders: ["google"], // Providers that can be linked }, }, ``` When a user signs in with a trusted provider using the same verified email address, Better Auth can link the accounts. Only add providers you trust to verify email ownership. --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/authentication/overview **Description**: Learn more about authentication in the starter kit. Authentication is a core part of any SaaS application. The Pro Next.js Drizzle starter kit uses [Better Auth](https://better-auth.com/) to handle authentication and provides all the necessary UI for the authentication flow. Why choose Better Auth? Better Auth is a modern, type-safe authentication solution built for Next.js. It provides a simple API, excellent TypeScript support, and works seamlessly with server components and server actions. The starter kit comes with pre-configured providers, helper methods and extensions. ## Included authentication flows - Email and password registration with email verification - Password reset and email-address changes - Passwordless passkey sign-in with biometric or device-PIN verification - Google OAuth and trusted account linking - Database-backed sessions with device revocation - TOTP two-factor authentication with failed-attempt lockout - Organization invitations and active-organization sessions - Administrator bans and impersonation ### Two-factor authentication Users with a credential account can enroll an authenticator app from **Dashboard → Settings → Security**. Enrollment requires the current password, then a valid six-digit TOTP code. After enrollment, password sign-in redirects to `/auth/verify` until the TOTP challenge succeeds. Better Auth stores TOTP secrets and backup codes in the `two_factor` table. The current schema also tracks whether setup was verified, failed verification attempts and the lockout expiry. Apply committed database migrations when upgrading Better Auth so these security fields exist before deploying the new application code. The included UI verifies TOTP codes for password sign-in. It does not display recovery codes during enrollment or accept a recovery code on the verification page, even though Better Auth stores backup-code data. Google OAuth and other passwordless sign-in methods are not automatically sent through the TOTP challenge either. Add and test those flows before presenting backup-code recovery or universal 2FA enforcement as supported product behavior. ### Passkeys Users can register, rename and remove passkeys from **Dashboard → Settings → Security**, then choose **Sign in with passkey** on the authentication page. The included WebAuthn policy requires user verification through a biometric or device PIN before Better Auth creates a session. A verified passkey completes the passwordless sign-in flow without an additional TOTP challenge. Password sign-in still follows the user's configured two-factor flow. Read the [passkey guide](/docs/starter-kits/pro-nextjs-drizzle/authentication/passkeys) for configuration, migrations, security boundaries and testing guidance. Fields such as twoFactorEnabled, banned,{' '} banReason and onboardingComplete are marked with{' '} input: false. Do not expose them through the generic client user update API. Change them through an authenticated server procedure or the corresponding Better Auth endpoint. --- ## Passkeys **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/authentication/passkeys **Description**: Configure passwordless passkey sign-in and account-managed WebAuthn credentials. [Passkeys](https://www.passkeys.com/) let users authenticate with the same biometric, device PIN or external security key they use to unlock a trusted authenticator. The starter kit integrates Better Auth's [passkey plugin](https://better-auth.com/docs/plugins/passkey) with registration, sign-in and account-management UI. ## Included flow - **Sign in with passkey** appears below the configured OAuth providers. - Signed-in users manage passkeys from **Dashboard → Settings → Security**. - A user can register multiple passkeys, give each one a recognizable name, rename it later and remove it with confirmation. - Registration and authentication require WebAuthn user verification. - Browser ceremony errors are mapped to actionable messages instead of a generic authentication failure. The feature is enabled by default: ```typescript filename="config/auth.config.ts" lineNumbers export const authConfig = { // ... other settings enablePasskeys: true }; ``` Setting `enablePasskeys` to `false` removes the sign-in and account-management UI and stops registering the passkey server plugin. It therefore disables the corresponding Better Auth endpoints as well. ## Database migration Passkey metadata is stored in the `passkey` table. The table records the public credential, counter, authenticator information, optional display name and the owning user. Private key material never leaves the user's authenticator. New downloads include the ORM-specific migration. Existing projects must apply it before deploying the passkey-enabled application: ```bash filename="Terminal" npm run db:migrate ``` No new environment variable is required. ## Require user verification The server registers the plugin with `userVerification: 'required'` and checks the authentication result before creating a session: ```typescript filename="lib/auth/index.ts" lineNumbers passkey({ authenticatorSelection: { userVerification: 'required' }, authentication: { afterVerification: async ({ verification }) => { if (!verification.authenticationInfo.userVerified) { throw new APIError('UNAUTHORIZED', { code: 'PASSKEY_USER_VERIFICATION_REQUIRED', message: 'Verify your identity with a PIN or biometric to use this passkey.' }); } } } }); ``` This protects against accepting a ceremony that proves possession of an authenticator without proving the person using it. Keep both the WebAuthn option and the server-side result check when adapting the integration. ## Passkeys and TOTP A user-verified passkey is treated as the complete passwordless sign-in method. It does not redirect to a second TOTP challenge. Password sign-in still follows the user's configured Better Auth two-factor flow. This distinction avoids asking for two independent possession checks during a single passkey ceremony while preserving TOTP for password-based authentication. If your product requires a separate step-up challenge for a sensitive action, implement and test that policy around the action instead of assuming every authentication method passes through the password hook. ## HTTPS and relying-party scope WebAuthn requires a secure context in production. Browsers allow `localhost` during development, but deployed passkeys are scoped to their relying party and origin. Test registration and authentication on the same production domain your customers will use. Changing domains later can prevent existing credentials from matching the new relying party. Plan custom domains and authentication subdomains before relying on passkeys as the only recovery path. ## Test the complete ceremony The starter kits include a Playwright test backed by Chromium's virtual WebAuthn authenticator. It verifies registration, naming, rename, sign-out, passwordless sign-in and deletion. It also switches user verification off and confirms the server rejects the ceremony before retrying with verification enabled. Run the focused test with: ```bash filename="Terminal" npm run with-dev-env -- playwright test tests/e2e/passkeys.spec.ts --project=chromium ``` Keep a normal password or recovery strategy available while evaluating browser, platform-authenticator and security-key support for your customer base. --- ## Permissions and Access Control **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/authentication/permissions **Description**: Learn how to protect pages and display UI based on user roles or permissions. The [tRPC endpoint protection guide](/docs/starter-kits/pro-nextjs-drizzle/trpc/protect-endpoint) explains how to enforce access control in your API procedures. In this guide we will show you how you can protect pages and display UI based on the user's role or permissions. ## Protect a route (server side) ### For authenticated users To protect a route to be only accessible for authenticated users, you can simply get the session in the RSC component and check if the user is authenticated. _Note: When you are inside the `/app/(saas)/dashboard` directory, you don't need to check if the user is authenticated, because the session is verified in the middleware._ ```tsx filename="app/dashboard/page.tsx" lineNumbers import { redirect } from 'next/navigation'; import { getSession } from '@/lib/auth/server'; export default async function MyProtectedPage() { const session = await getSession(); if (!session) { return redirect('/auth/sign-in'); } return
My protected page
; } ``` ### For specific roles More interesting is to check if the user has the necessary permissions to access the page. For example, you can make a page only accessible for users with the `admin` role. ```tsx filename="app/admin/page.tsx" lineNumbers import { redirect } from 'next/navigation'; import { getSession } from '@/lib/auth/server'; export default async function MyAdminPage() { const session = await getSession(); if (session?.user.role !== 'admin') { return redirect('/app'); } return
This page is only accessible for admins
; } ``` ### For active or specific subscription Or if you want to check for an active subscription, you can do the following: ```tsx filename="app/premium/page.tsx" lineNumbers import { redirect } from 'next/navigation'; import { getSession } from '@/lib/auth/server'; import { getActivePlanForOrganization, requirePaidPlan, requireSpecificPlan } from '@/lib/billing/guards'; export default async function MyPremiumPage() { const session = await getSession(); if (!session?.activeOrganizationId) { return redirect('/app'); } // Check for any paid plan try { const { planId } = await requirePaidPlan(session.activeOrganizationId); // User has a paid plan } catch { return redirect('/app'); // or show a message to the user that they need to subscribe to the premium plan } // Or check for a specific plan try { const { planId } = await requireSpecificPlan(session.activeOrganizationId, [ 'pro', 'enterprise' ]); // User has pro or enterprise plan } catch { return (
This page is only accessible for users with a pro subscription
); } return (
This page is only accessible for users with an active subscription
); } ``` ### For organization role You can also check if a user has a specific role inside the current organization. For example, you might want to add features that are only available for organization owners or admins. ```tsx filename="app/[organizationSlug]/settings/page.tsx" lineNumbers import { redirect } from 'next/navigation'; import { eq } from 'drizzle-orm'; import { getOrganizationById, getSession } from '@/lib/auth/server'; import { db } from '@/lib/db'; import { organizationTable } from '@/lib/db/schema'; export default async function MyOrganizationPage({ params }: { params: Promise<{ organizationSlug: string }>; }) { const { organizationSlug } = await params; const session = await getSession(); // First, find organization by slug const [org] = await db .select({ id: organizationTable.id }) .from(organizationTable) .where(eq(organizationTable.slug, organizationSlug)) .limit(1); if (!org) { redirect('/app'); } // Then get full organization with members const organization = await getOrganizationById(org.id); if (!organization) { redirect('/app'); } const membership = organization.members.find( (member) => member.userId === session?.user.id ); if ( !membership || (membership.role !== 'admin' && membership.role !== 'owner') ) { return
This page is only accessible for organization admins
; } return
This page is only accessible for organization admins
; } ``` ## Display UI based on permissions (client side) On client side, you can use the `authClient.useSession()` hook to get the session and then check if the user has the necessary permissions. ### For authenticated users ```tsx filename="components/protected-component.tsx" lineNumbers 'use client'; import { authClient } from '@/lib/auth/client'; export function MyComponent() { const { data: session } = authClient.useSession(); if (!session) { return
You need to be logged in to access this page
; } return
You are logged in
; } ``` Security Note You always want to check the permission on the server side first to avoid any security issues. ### For specific roles ```tsx filename="components/admin-component.tsx" lineNumbers 'use client'; import { authClient } from '@/lib/auth/client'; export function MyComponent() { const { data: session } = authClient.useSession(); if (session?.user.role !== 'admin') { return
This page is only accessible for admins
; } return
This page is only accessible for admins
; } ``` ### For active or specific subscription ```tsx filename="components/premium-component.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function MyComponent() { const { data: subscriptionStatus } = trpc.organization.subscription.getStatus.useQuery(); if (!subscriptionStatus?.enabled) { return
Billing is not enabled
; } if (!subscriptionStatus.activePlan) { return
You don't have an active subscription
; } if ( subscriptionStatus.activePlan.planId !== 'pro' && subscriptionStatus.activePlan.planId !== 'enterprise' ) { return
You need to subscribe to the pro plan to access this page
; } return
You have an active subscription
; } ``` ### For organization role ```tsx filename="components/organization-component.tsx" lineNumbers 'use client'; import { authClient } from '@/lib/auth/client'; export function MyComponent() { const { data: activeOrganization } = authClient.useActiveOrganization(); const { data: session } = authClient.useSession(); if (!activeOrganization || !session) { return
No active organization
; } // Find the user's membership in the active organization const membership = activeOrganization.members.find( (member) => member.userId === session.user.id ); // Check if user is admin or owner const isOrganizationAdmin = membership && (membership.role === 'admin' || membership.role === 'owner'); if (!isOrganizationAdmin) { return
This page is only accessible for organization admins
; } if (membership.role !== 'owner') { return
This page is only accessible for organization owners
; } return
This page is only accessible for organization admins
; } ``` --- ## User and Session **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/authentication/sessions **Description**: Learn how to access the user and session in your application. ## Accessing the user and session You can access the user and session in your application using the `useSession` hook on the client or `getSession` function on the server. ### Client-side ```tsx filename="components/user-profile.tsx" lineNumbers 'use client'; import { authClient } from '@/lib/auth/client'; export function UserProfile() { const { data: session, isPending } = authClient.useSession(); if (isPending) return
Loading...
; if (!session) return
Not authenticated
; return (

Hello, {session.user.name}!

Email: {session.user.email}

); } ``` Both `user` and `session` can be `null` if the user is not authenticated, but if you use the hook inside a `/dashboard/...` route, they should always be defined. The `user` object contains the information of the authenticated user and the `session` object contains the session data. ```typescript filename="types.ts" lineNumbers type Session = { id: string; userId: string; createdAt: Date; updatedAt: Date; expiresAt: Date; token: string; ipAddress?: string | null; userAgent?: string | null; impersonatedBy?: string | null; activeOrganizationId?: string | null; }; type User = { id: string; createdAt: Date; updatedAt: Date; email: string; emailVerified: boolean; name: string; image?: string | null; role: 'admin' | 'user'; onboardingComplete: boolean; twoFactorEnabled?: boolean; banned?: boolean; }; ``` `activeOrganizationId` is selected by the organization plugin and is used by organization-protected pages and tRPC procedures. Treat it as context, not as authorization on its own: protected procedures also verify that the current user is still a member of that organization. The security and administration fields on `User` are returned to the client but are not accepted by the generic client update API. This prevents a browser from marking onboarding complete, enabling two-factor authentication or changing an account ban without the appropriate server-side flow. ### Wait until the session has been loaded In some cases you might want to wait until the session has been loaded before accessing the user and session. For this there is an `isPending` property that you can use. ```tsx filename="components/loading-example.tsx" lineNumbers 'use client'; import { authClient } from '@/lib/auth/client'; export function MyComponent() { const { data: session, isPending } = authClient.useSession(); if (!isPending && !session) { return
Not authenticated
; } if (isPending) { return
Loading...
; } return
Hello, {session.user.name}!
; } ``` ## Reload session If for some reason you need to reload the session, for example when you changed some property of the user like its name or role, you can use the `refetch` function. ```tsx filename="components/reload-session.tsx" lineNumbers 'use client'; import { authClient } from '@/lib/auth/client'; export function MyComponent() { const { data: session, refetch } = authClient.useSession(); const handleReload = async () => { await refetch(); }; return (

Hello, {session?.user.name}!

); } ``` ## Get session on server To use the session on the server, e.g. in a React Server Component, you can use the `getSession` function. ```tsx filename="app/dashboard/page.tsx" lineNumbers import { redirect } from 'next/navigation'; import { getSession } from '@/lib/auth/server'; export default async function DashboardPage() { const session = await getSession(); if (!session) { redirect('/auth/sign-in'); } return
User name: {session.user.name}
; } ``` ### Via tRPC context In tRPC procedures, the session is automatically available in the context: ```typescript filename="trpc/routers/example.ts" lineNumbers import { createTRPCRouter, protectedProcedure } from '@/trpc/init'; export const exampleRouter = createTRPCRouter({ getProfile: protectedProcedure.query(async ({ ctx }) => { // Session is available in ctx.session, user is in ctx.user const user = ctx.user; return user; }) }); ``` ## Session strategy User sessions are securely stored in the database. This allows active sessions to be managed in the security settings (i.e. you can log out all devices) and changes take effect immediately. --- ## Admin UI **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/authentication/superadmin **Description**: Learn how to create admin users and access the admin UI. The starter kit comes with an admin role and a UI for managing users and organizations out of the box. The only thing you need to do is to create a new user and assign it the admin role. ## Create admin user via database You can create an admin user directly in the database. First, start Drizzle Studio: ```bash filename="Terminal" lineNumbers npm run db:studio ``` Then: 1. Navigate to the `users` table 2. Create a new user or find an existing user 3. Set the `role` field to `admin` 4. Save the changes ## Assign admin role to existing user If you have already created a user and want to make it an admin, you can update the database entry directly. ### Using Drizzle Studio 1. Start Drizzle Studio: ```bash filename="Terminal" lineNumbers npm run db:studio ``` 2. Select the `User` table and find the user you want to make an admin 3. Click on the `role` field and change it to `admin` 4. Save the changes ### Using SQL You can also update the role directly using SQL: ```sql filename="update-user-role.sql" lineNumbers UPDATE users SET role = 'admin' WHERE email = 'admin@example.com'; ``` ## Next Steps Once you've created an admin user, you can: - **Access the Admin Panel** - Log in and navigate to the Admin section (see [Admin Panel Overview](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/overview)) - **Manage your application** - Use the admin panel to manage users, organizations, subscriptions and more --- ## Inngest **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/background-tasks/inngest **Description**: Integrate Inngest with your application for event-driven background workflows. [Inngest](https://www.inngest.com) is a developer-first platform for building reliable background jobs, scheduled functions, and event-driven workflows. It provides a simple API for creating durable functions that automatically retry on failure. The starter kits do not include the Inngest SDK, credentials, functions or API route. This page is an optional implementation recipe. Add it only when its event-driven execution model fits your product. Inngest makes it easy to build reliable background jobs with automatic retries, scheduling, and event-driven workflows. Functions are defined in your codebase and run on Inngest's infrastructure, giving you the reliability of a queue with the simplicity of writing regular functions. ## Setup Visit [Inngest](https://www.inngest.com) and create a free account. Create a new app and note down your signing key. Add your Inngest credentials to your environment variables: ```env filename=".env" lineNumbers INNGEST_EVENT_KEY=your_event_key_here INNGEST_SIGNING_KEY=your_signing_key_here ``` Register both server-only variables in `lib/env.ts` so the client example below typechecks and missing production credentials fail validation: ```diff filename="lib/env.ts" lineNumbers server: { // Existing variables... + INNGEST_EVENT_KEY: z.string().min(1), + INNGEST_SIGNING_KEY: z.string().min(1) } ``` Expose them to the validator in the same file: ```diff filename="lib/env.ts" lineNumbers runtimeEnv: { // Existing variables... + INNGEST_EVENT_KEY: process.env.INNGEST_EVENT_KEY, + INNGEST_SIGNING_KEY: process.env.INNGEST_SIGNING_KEY } ``` Keep these values server-only and use separate Inngest environments and keys for development, previews and production. ## Install dependencies This recipe uses the stable Inngest TypeScript SDK v3 API. Pin that major so a future v4 upgrade does not silently invalidate the examples: ```bash filename="Terminal" lineNumbers npm install inngest@^3 ``` ## Configure Inngest Create an Inngest client: ```typescript filename="lib/inngest.ts" lineNumbers import { EventSchemas, Inngest } from 'inngest'; import * as z from 'zod'; const schemas = new EventSchemas().fromSchema({ 'user/data.process': z.object({ userId: z.string(), operation: z.enum(['export', 'analyze', 'cleanup']) }) }); export const inngest = new Inngest({ id: 'your-app-id', schemas }); ``` The SDK reads `INNGEST_EVENT_KEY` from the server environment when sending and the Next.js handler uses `INNGEST_SIGNING_KEY` to authenticate incoming requests. The event schema gives `inngest.send()` and `event.data` one shared contract with runtime validation. ## Create your first function Create functions in a `lib/inngest/functions` directory: ```typescript filename="lib/inngest/functions/process-user-data.ts" lineNumbers import { inngest } from '@/lib/inngest'; export const processUserData = inngest.createFunction( { id: 'process-user-data' }, { event: 'user/data.process' }, async ({ event, step }) => { const { userId, operation } = event.data; await step.run('process-data', async () => { console.log('Processing user data', { userId, operation }); switch (operation) { case 'export': // Export user data await new Promise((resolve) => setTimeout(resolve, 2000)); return { success: true, result: 'Data exported to CSV' }; case 'analyze': // Analyze user data await new Promise((resolve) => setTimeout(resolve, 5000)); return { success: true, result: { totalActions: 156, avgSessionTime: '4m 32s' } }; case 'cleanup': // Cleanup user data await new Promise((resolve) => setTimeout(resolve, 3000)); return { success: true, result: 'Removed 23 obsolete records' }; default: throw new Error(`Unknown operation: ${operation}`); } }); } ); ``` Create a scheduled function: ```typescript filename="lib/inngest/functions/daily-cleanup.ts" lineNumbers import { inngest } from '@/lib/inngest'; export const dailyCleanup = inngest.createFunction( { id: 'daily-cleanup' }, { cron: '0 2 * * *' }, // Daily at 2 AM async ({ step }) => { await step.run('cleanup-logs', async () => { console.log('Cleaning up old logs'); await new Promise((resolve) => setTimeout(resolve, 5000)); return { logsCleaned: true }; }); await step.run('cleanup-temp-files', async () => { console.log('Cleaning up temporary files'); await new Promise((resolve) => setTimeout(resolve, 3000)); return { tempFilesCleaned: true }; }); await step.run('generate-reports', async () => { console.log('Generating daily reports'); await new Promise((resolve) => setTimeout(resolve, 8000)); return { reportsGenerated: true }; }); } ); ``` ## Register functions Create an API route to serve your Inngest functions: ```typescript filename="app/api/inngest/route.ts" lineNumbers import { serve } from 'inngest/next'; import { inngest } from '@/lib/inngest'; import { dailyCleanup } from '@/lib/inngest/functions/daily-cleanup'; import { processUserData } from '@/lib/inngest/functions/process-user-data'; export const { GET, POST, PUT } = serve({ client: inngest, functions: [processUserData, dailyCleanup] }); ``` ## Triggering functions ### From an API route ```typescript filename="app/api/tasks/process-user-data/route.ts" lineNumbers import { NextRequest, NextResponse } from 'next/server'; import * as z from 'zod'; import { getSession } from '@/lib/auth/server'; import { inngest } from '@/lib/inngest'; const processUserDataSchema = z.object({ operation: z.enum(['export', 'analyze', 'cleanup']) }); export async function POST(request: NextRequest) { const session = await getSession(); if (!session) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const body = await request.json(); const { operation } = processUserDataSchema.parse(body); await inngest.send({ name: 'user/data.process', data: { userId: session.user.id, operation } }); return NextResponse.json({ success: true, message: 'Background task started successfully' }); } ``` ### From a server action ```typescript filename="app/actions/user-actions.ts" lineNumbers 'use server'; import { getSession } from '@/lib/auth/server'; import { inngest } from '@/lib/inngest'; export async function processUserData( operation: 'export' | 'analyze' | 'cleanup' ) { const session = await getSession(); if (!session) { throw new Error('Unauthorized'); } try { await inngest.send({ name: 'user/data.process', data: { userId: session.user.id, operation } }); return { success: true }; } catch (error) { console.error('Failed to trigger background task:', error); throw new Error('Failed to start background task'); } } ``` The authenticated user ID is derived from the server session. Do not accept a user or organization owner from the browser and treat it as authorization. For organization work, verify membership before publishing and send only a stored job ID or the minimum identifiers the worker needs. ## Monitoring and debugging Visit the [Inngest Dashboard](https://app.inngest.com) to monitor your functions: - View function execution logs and performance metrics - Track success and failure rates - Monitor function duration and step execution - Replay failed functions - Set up alerts for function failures ## Best practices ### Use step functions for reliability Break your function into steps using `step.run()` to make it more reliable and debuggable: ```typescript await step.run('step-name', async () => { // This step will be retried independently if it fails return await processData(); }); ``` ### Use descriptive function IDs ```typescript // ✅ Good { id: 'user-data-export-csv'; } // ❌ Not so good { id: 'task1'; } ``` ### Handle errors gracefully ```typescript await step.run('process', async () => { try { return await processData(); } catch (error) { console.error('Processing failed:', error); throw error; // Re-throw to trigger retry } }); ``` ## Next steps With Inngest integrated into your application, you can now: - **Build reliable background jobs** with automatic retries - **Schedule recurring tasks** with cron expressions - **Create event-driven workflows** that respond to events - **Compose complex workflows** using step functions Ready to explore more? Check out the official documentation for advanced features like function composition, event filtering, and more. --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/background-tasks/overview **Description**: Learn about background tasks & cron jobs and how they can power your application. Background work lets an HTTP request acknowledge an operation before all of its work finishes. Use it for jobs that need retries, scheduling or more execution time than the request path should consume. The starter kits do not install or configure a background-task provider by default. The guides in this section show patterns you can add after choosing a provider. Install only the SDK you plan to operate. ## Choose the simplest execution model | Requirement | Recommended starting point | Why | | ------------------------------------------------ | ---------------------------------------------------- | ----------------------------------------------------------- | | The user needs the result immediately | Keep it in the request | The response can report success or failure directly | | Deliver an HTTP message later or on a schedule | QStash | HTTP delivery, signing and retries fit serverless routes | | Run durable steps with retries and observability | Trigger.dev, Inngest or Vercel Workflow | The provider records progress outside the request process | | Run a small recurring operation | A provider schedule that invokes a protected handler | Scheduling remains outside the web process | | Run a persistent in-process worker | A separately operated worker service | Serverless application instances are not persistent workers | Do not add a queue only because a function is asynchronous. A short operation that must succeed before the response is often clearer and safer when it stays in the request. ## Good background-task candidates - Generate exports, reports or media after accepting a request. - Send batches of notifications with provider rate limits. - Synchronize data with an external service and retry transient failures. - Process a webhook after its signature and minimum payload are validated. - Run scheduled cleanup against records that are safe to process repeatedly. - Execute a durable multi-step workflow where progress must survive a restart. Keep authentication, authorization decisions and ordinary interactive database queries in the request path. Run schema migrations as a controlled release step, not as a background job. ## Design the job before choosing a provider Every job should define: 1. **Identity**: a stable job or idempotency key. 2. **Tenant scope**: the organization or user that owns the operation. 3. **Input contract**: a small validated payload containing identifiers rather than large or sensitive objects. 4. **Retry behavior**: which failures are transient and how many attempts are safe. 5. **Completion state**: where the application records pending, successful and failed outcomes. 6. **Operations**: logs, alerts and a documented way to replay or cancel work. Retries, timeouts and provider redelivery can run the same task more than once. Make writes idempotent with a unique operation key, database constraint or transactional state transition. A queue does not make a non-idempotent operation safe automatically. ## Secure the producer and worker - Authorize the user before publishing a job. - Derive organization access from the authenticated session instead of trusting an organization ID supplied by the browser. - Verify provider signatures on public task endpoints. - Store provider tokens and signing keys as server-only environment variables. - Re-check permissions in the worker when delayed execution could outlive the user's membership or access. - Avoid putting access tokens, full customer records or other unnecessary secrets in queue payloads and logs. ## Provider guides These guides are alternatives, not steps that must all be completed: Start with one provider and one narrow job. Verify success, retry, duplicate delivery and permanent failure paths before moving business-critical work out of the request. --- ## Upstash QStash **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/background-tasks/qstash **Description**: Integrate Upstash QStash with your application for serverless-first background task processing. [Upstash QStash](https://upstash.com/docs/qstash/quickstarts/vercel-nextjs) delivers signed HTTP requests with retries, delays and schedules. It fits a serverless Next.js application because the worker is an ordinary route handler, not a persistent process. Add QStash only when its HTTP delivery model fits your job. The starter kit does not include the SDK, credentials, task tables or task routes. ## Install the SDK ```sh filename="Terminal" lineNumbers npm install @upstash/qstash ``` ## Register server-only variables Copy the token and both signing keys from the Upstash console: ```env filename=".env" lineNumbers QSTASH_TOKEN=replace-me QSTASH_CURRENT_SIGNING_KEY=replace-me QSTASH_NEXT_SIGNING_KEY=replace-me ``` Add the variables to the `server` schema in `lib/env.ts`: ```typescript filename="lib/env.ts" lineNumbers server: { // Existing variables... QSTASH_TOKEN: z.string().min(1), QSTASH_CURRENT_SIGNING_KEY: z.string().min(1), QSTASH_NEXT_SIGNING_KEY: z.string().min(1), QSTASH_URL: z.string().url().optional() } ``` Expose them to the server-side validator in the same file: ```typescript filename="lib/env.ts" lineNumbers runtimeEnv: { // Existing variables... QSTASH_TOKEN: process.env.QSTASH_TOKEN, QSTASH_CURRENT_SIGNING_KEY: process.env.QSTASH_CURRENT_SIGNING_KEY, QSTASH_NEXT_SIGNING_KEY: process.env.QSTASH_NEXT_SIGNING_KEY, QSTASH_URL: process.env.QSTASH_URL } ``` Do not prefix these values with `NEXT_PUBLIC_`. Set a separate credential set in every deployed environment. ## Create the publishing client `QSTASH_URL` is optional for the managed service and useful when connecting to a local QStash server: ```typescript filename="lib/qstash.ts" lineNumbers import 'server-only'; import { Client } from '@upstash/qstash'; import { env } from '@/lib/env'; export const qstash = new Client({ token: env.QSTASH_TOKEN, ...(env.QSTASH_URL ? { baseUrl: env.QSTASH_URL } : {}) }); ``` ## Add a signed worker route QStash calls a public HTTP endpoint. Wrap the handler with the official App Router verifier so requests without a valid QStash signature are rejected: ```typescript filename="app/api/tasks/process/route.ts" lineNumbers import { verifySignatureAppRouter } from '@upstash/qstash/nextjs'; import * as z from 'zod'; import { processStoredJob } from '@/lib/tasks/process-stored-job'; const payloadSchema = z.object({ jobId: z.string().uuid() }); async function handler(request: Request): Promise { const { jobId } = payloadSchema.parse(await request.json()); await processStoredJob(jobId); return Response.json({ success: true }); } export const POST = verifySignatureAppRouter(handler); ``` Create `processStoredJob` for your product. Load the job and its organization from the database instead of trusting tenant data carried in the message. Make the state transition idempotent so delivering the same `jobId` twice does not repeat billing, credits, email or another external side effect. Return a non-success status or throw for transient failures that QStash should retry. Record permanent failures so they can be investigated without retrying forever. ## Publish after authorization Authorize the current user and persist a pending job before publishing its ID. Use the kit's `getBaseUrl()` rather than introducing another site URL variable: ```typescript filename="lib/tasks/publish-job.ts" lineNumbers import 'server-only'; import { qstash } from '@/lib/qstash'; import { getBaseUrl } from '@/lib/utils'; export async function publishJob(jobId: string): Promise { const result = await qstash.publishJSON({ url: `${getBaseUrl()}/api/tasks/process`, body: { jobId }, retries: 3 }); return result.messageId; } ``` Do not expose this function directly to the browser. Call it from an authenticated server action, route or tRPC procedure after checking access to the organization that owns the stored job. ## Test locally The managed QStash service cannot deliver to an inaccessible localhost URL. Use the official local server or a public development tunnel. Start local QStash in one terminal: ```sh filename="Terminal" lineNumbers npx @upstash/qstash-cli dev ``` Copy the printed local URL, token and signing keys into the root `.env`, then restart `npm run dev`. Publish a test job and verify all four outcomes: 1. The producer returns a QStash message ID. 2. The signed worker changes the stored job from pending to successful. 3. A failing attempt is retried and remains observable. 4. Publishing the same `jobId` again does not repeat its side effect. ## Production checklist - Set managed QStash credentials in the production environment and remove any local `QSTASH_URL` override. - Confirm the destination uses the final HTTPS production origin. - Keep both signing keys configured so key rotation can complete safely. - Monitor QStash delivery logs and the application's stored job state. - Add alerts for exhausted retries and jobs that remain pending too long. - Avoid logging message bodies when they contain customer or organization data. For delays, schedules, queues and callbacks, use the [QStash TypeScript SDK documentation](https://upstash.com/docs/qstash/sdks/ts/gettingstarted). --- ## trigger.dev **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/background-tasks/trigger **Description**: Integrate trigger.dev with your application for reliable background task processing. [trigger.dev](https://trigger.dev) is an open-source background jobs framework that lets you write reliable workflows in plain async code. The starter kits do not include Trigger.dev packages, configuration, task definitions or credentials. Add this recipe only after choosing Trigger.dev as your background-task provider. trigger.dev provides automatic retries, real-time monitoring, and seamless scaling - all while letting you write background tasks in familiar JavaScript/TypeScript code directly in your project. ## Setup Visit [trigger.dev](https://trigger.dev) and create a free account. Create a new project and note down your API key. Add your trigger.dev API key to your environment variables: ```env filename=".env" lineNumbers TRIGGER_SECRET_KEY=your_secret_key_here ``` For production, make sure to add the production API key to your deployment environment. ## Install dependencies Install the SDK, build package and CLI. Keep all three on the same version: ```bash filename="Terminal" lineNumbers npm install @trigger.dev/sdk@latest npm install -D @trigger.dev/build@latest trigger.dev@latest ``` Commit the resolved versions from `package.json` and `package-lock.json`. Trigger.dev warns when the CLI, SDK and build package versions drift. ## Configure trigger.dev Create a `trigger.config.ts` file in the root of your project: ```typescript filename="trigger.config.ts" lineNumbers import { defineConfig } from "@trigger.dev/sdk"; export default defineConfig({ project: "your_project_id", // Replace with your actual project ID runtime: "node", logLevel: "log", maxDuration: 300, dirs: ["./src/trigger"], }); ``` Update your `package.json` to include trigger.dev scripts: ```json filename="package.json" lineNumbers { "scripts": { "trigger:dev": "trigger dev", "trigger:deploy": "trigger deploy" } } ``` ## Create your first task Create a `src/trigger` directory and add your first task: ```typescript filename="src/trigger/process-user-data.ts" lineNumbers import { task, logger, wait } from "@trigger.dev/sdk"; import * as z from "zod"; const ProcessUserDataSchema = z.object({ userId: z.string(), operation: z.enum(["export", "analyze", "cleanup"]), }); export const processUserDataTask = task({ id: "process-user-data", run: async (payload: z.infer) => { const { userId, operation } = payload; logger.info("Starting user data processing", { userId, operation }); switch (operation) { case "export": await wait.for({ seconds: 2 }); logger.info("User data exported successfully"); return { success: true, result: "Data exported to CSV" }; case "analyze": await wait.for({ seconds: 5 }); logger.info("User data analysis completed"); return { success: true, result: { totalActions: 156, avgSessionTime: "4m 32s" }, }; case "cleanup": await wait.for({ seconds: 3 }); logger.info("User data cleanup completed"); return { success: true, result: "Removed 23 obsolete records" }; default: throw new Error(`Unknown operation: ${operation}`); } }, }); ``` Create a scheduled task: ```typescript filename="src/trigger/daily-cleanup.ts" lineNumbers import { schedules, logger, wait } from "@trigger.dev/sdk"; export const dailyCleanupTask = schedules.task({ id: "daily-cleanup", cron: "0 2 * * *", run: async () => { logger.info("Starting daily cleanup"); // Cleanup old logs await wait.for({ seconds: 5 }); logger.info("Logs cleaned up"); // Cleanup temporary files await wait.for({ seconds: 3 }); logger.info("Temp files cleaned up"); // Generate daily reports await wait.for({ seconds: 8 }); logger.info("Reports generated"); return { success: true, cleanupTime: new Date().toISOString(), itemsProcessed: 1247, }; }, }); ``` A string cron is evaluated in UTC. Use the object form documented by Trigger.dev when the job must run in a named timezone. Reserve `schedules.create()` for dynamic schedules created by an authenticated server flow, not a fixed call executed when this module loads. ## Test your task You can test your tasks locally by running: ```bash filename="Terminal" lineNumbers npm run trigger:dev ``` This will deploy your tasks to trigger.dev in the development environment, allowing you to trigger them from the dashboard or programmatically. ## Deploy your tasks To deploy your tasks to production on trigger.dev, run: ```bash filename="Terminal" lineNumbers npm run trigger:deploy ``` You can also add this command as an automated deployment step in your CI/CD pipeline. Add the `TRIGGER_ACCESS_TOKEN` secret to your repository secrets, which you can create in the trigger.dev dashboard. ```yaml filename=".github/workflows/deploy-tasks.yml" lineNumbers name: Deploy to trigger.dev (prod) on: push: branches: - main jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: lts/* - name: Install dependencies run: npm install - name: Deploy trigger tasks env: TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }} run: | npm run trigger:deploy ``` ## Triggering tasks You can trigger tasks from your application using the trigger.dev SDK. ### From an API route Create an API route to handle task triggering: ```typescript filename="app/api/tasks/process-user-data/route.ts" lineNumbers import { tasks } from "@trigger.dev/sdk"; import { NextResponse } from "next/server"; import * as z from "zod"; import { getSession } from "@/lib/auth/server"; import { processUserDataTask } from "@/src/trigger/process-user-data"; const processUserDataSchema = z.object({ operation: z.enum(["export", "analyze", "cleanup"]), }); export async function POST(request: Request) { const session = await getSession(); if (!session) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const body = await request.json(); const { operation } = processUserDataSchema.parse(body); const handle = await tasks.trigger( "process-user-data", { userId: session.user.id, operation }, ); return NextResponse.json({ success: true, taskId: handle.id, message: "Background task started successfully", }); } ``` ### From a server action ```typescript filename="app/actions/user-actions.ts" lineNumbers "use server"; import { tasks } from "@trigger.dev/sdk"; import { getSession } from "@/lib/auth/server"; import { processUserDataTask } from "@/src/trigger/process-user-data"; export async function processUserData( operation: "export" | "analyze" | "cleanup", ) { const session = await getSession(); if (!session) { throw new Error("Unauthorized"); } try { const handle = await tasks.trigger( "process-user-data", { userId: session.user.id, operation }, ); return { success: true, taskId: handle.id, }; } catch (error) { console.error("Failed to trigger background task:", error); throw new Error("Failed to start background task"); } } ``` ### From the client You can call the task endpoint from your React components: ```tsx filename="components/process-data-button.tsx" lineNumbers "use client"; import { useMutation } from "@tanstack/react-query"; export function ProcessDataButton() { const { mutate: processData, isPending } = useMutation({ mutationFn: async (operation: "export" | "analyze" | "cleanup") => { const response = await fetch("/api/tasks/process-user-data", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ operation }), }); if (!response.ok) { throw new Error("Failed to start task"); } return response.json(); }, onSuccess: (data) => { console.log("Task started:", data.taskId); }, }); return ( ); } ``` The authenticated server derives `userId`; the browser never chooses the task owner. For organization-scoped work, accept an organization ID only as a lookup key, verify membership with the shipped authorization helpers and persist the authorized tenant scope before triggering the task. ## Monitoring and debugging ### Dashboard access Visit the [trigger.dev dashboard](https://trigger.dev) to monitor your tasks: - View task execution logs and performance metrics - Track success and failure rates across all your tasks - Monitor task duration and resource usage - Replay failed tasks with a single click - Set up alerts for task failures or performance issues ### Local development During development, run your tasks locally while connected to trigger.dev: ```bash filename="Terminal" lineNumbers npm run trigger:dev ``` This allows you to: - Test tasks locally with real data - Debug with breakpoints and console logs - See immediate feedback as you develop ## Best practices ### Use descriptive task IDs ```typescript // ✅ Good - Clear and descriptive id: 'user-data-export-csv'; id: 'weekly-newsletter-campaign'; id: 'cleanup-temp-files'; // ❌ Not so good - Generic and unclear id: 'task1'; id: 'job'; id: 'process'; ``` ### Include proper error handling ```typescript run: async (payload) => { try { const result = await processData(payload); logger.info("Task completed successfully", { result }); return result; } catch (error) { logger.error("Task failed:", error.message); throw error; // Re-throw to trigger retry logic } }, ``` ### Use structured logging ```typescript logger.info('Processing started', { userId: payload.userId, operation: payload.operation, timestamp: new Date().toISOString() }); ``` ### Keep tasks focused Instead of one massive task, create focused, single-purpose tasks that can be composed together for complex workflows. ### Configure appropriate retries Set retry policies based on your task's requirements: ```typescript // For critical operations retry: { maxAttempts: 5, minTimeoutInMs: 2000, maxTimeoutInMs: 30000, factor: 2, } // For less critical operations retry: { maxAttempts: 2, minTimeoutInMs: 1000, maxTimeoutInMs: 5000, factor: 1.5, } ``` ## Next steps With trigger.dev integrated into your application, you can now: - **Handle long-running operations** that would timeout in serverless functions - **Schedule recurring tasks** like reports, cleanups, and maintenance - **Process background jobs** reliably with automatic retries - **Scale your application** without worrying about task execution infrastructure Ready to explore more advanced features? Check out the official documentation for additional capabilities like webhooks, batching, and custom integrations. --- ## Vercel Workflows **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/background-tasks/vercel-workflows **Description**: Integrate Vercel Workflows with your application for serverless background tasks. [Vercel Workflows](https://vercel.com/docs/workflows) runs durable TypeScript functions that can pause, retry and resume after a deployment or process failure. It is built on the open-source Workflow SDK. The starter kit does not install or configure Workflow. Check the current pricing, limits and release status before making it part of a critical product path. Use it when a process genuinely needs durable steps, not for an ordinary short route handler. ## Install and configure Workflow Run the current setup command from the starter kit root: ```sh filename="Terminal" lineNumbers npx workflow@latest ``` The setup installs the `workflow` package and adds the required Next.js plugin. Review the resulting `next.config.ts` carefully because the starter kit already composes Content Collections, Fumadocs, Sentry and the bundle analyzer there. Preserve those wrappers when adding `withWorkflow` from `workflow/next`, then wrap the existing final configuration once. Running the setup a second time should update the integration, not add another wrapper. Use a current `workflow` release. Older beta releases contained a webhook token vulnerability. Run `npm audit` after installation and follow the Workflow SDK security guidance before exposing hooks or webhooks. ## Create a durable workflow Keep orchestration in the workflow function and database or external-service I/O inside step functions. Pass a stored job ID rather than a browser-supplied user or organization object: ```typescript filename="workflows/process-stored-job.ts" lineNumbers import { processStoredJob } from '@/lib/tasks/process-stored-job'; export async function processStoredJobWorkflow(jobId: string): Promise { 'use workflow'; await processJobStep(jobId); } async function processJobStep(jobId: string): Promise { 'use step'; await processStoredJob(jobId); } ``` `processStoredJob` is product code you create. It should load the pending job and its organization from the database, claim it atomically and record success or failure. Make the operation idempotent because a step may be retried. Workflow functions are deterministic orchestration code. Put database queries, Node.js APIs, ordinary `fetch` calls and third-party SDK calls in a `use step` function. Use Workflow's own durable primitives when the orchestration needs to sleep or wait for an external event. ## Start the workflow after authorization Start workflows through `workflow/api`. Do not call the workflow function directly: ```typescript filename="app/api/tasks/process/route.ts" lineNumbers import { NextResponse } from 'next/server'; import { processStoredJobWorkflow } from '@/workflows/process-stored-job'; import { start } from 'workflow/api'; import { z } from 'zod/v4'; import { getSession } from '@/lib/auth/server'; const requestSchema = z.object({ jobId: z.string().uuid() }); export async function POST(request: Request): Promise { const session = await getSession(); if (!session) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } const { jobId } = requestSchema.parse(await request.json()); // Verify that session.user can start this stored job before continuing. const run = await start(processStoredJobWorkflow, [jobId]); return NextResponse.json({ runId: run.runId }, { status: 202 }); } ``` The authorization comment is a required product-specific step, not optional sample cleanup. Verify ownership or organization membership before starting the workflow. Re-check access in delayed steps when permissions may have changed. ## Test the complete lifecycle The Workflow CLI can verify the generated endpoints and inspect runs: ```sh filename="Terminal" lineNumbers npx workflow health npx workflow web npx workflow inspect runs ``` Test more than the successful path: 1. An authenticated member can start a job they are allowed to operate. 2. Another user cannot start the same organization's job. 3. A transient step failure retries without repeating an external side effect. 4. A permanent failure becomes visible and does not remain pending forever. 5. Redeploying while a workflow is paused does not lose the run. 6. Starting the same stored job twice does not process it twice. On Vercel, inspect runs under **Observability → Workflows**. Keep application job state in the database as well so customers and support can see a stable product status without depending on provider-specific run details. ## Production checklist - Pin a reviewed Workflow SDK version and update it deliberately. - Keep step inputs small and avoid secrets or complete customer records. - Log stable job and organization identifiers, not sensitive payloads. - Define which errors retry and which failures are permanent. - Add alerts for failed runs and jobs that remain pending too long. - Document how support can safely replay or cancel a job. - Review [Workflow pricing and limits](https://vercel.com/docs/workflows/pricing-and-limits) before launch. Continue with the [Workflow SDK documentation](https://useworkflow.dev/docs/getting-started) for sleep, hooks, streaming and other durable primitives. --- ## Billing **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/billing **Description**: Manage subscriptions, one-time payments and credit-based billing for AI features. The Pro Next.js Drizzle starter kit provides a comprehensive billing system integrated with **Stripe**. It supports traditional subscriptions and a flexible credit system for AI-driven features. ## Configuration Billing plans are configured in `config/billing.config.ts`. Credit packages are configured separately in the same file. ```typescript filename="config/billing.config.ts" lineNumbers export const billingConfig = { enabled: true, defaultCurrency: 'usd', plans: { free: { id: 'free', name: 'Free', isFree: true, features: ['Basic features'] }, pro: { id: 'pro', name: 'Pro', description: 'For professional developers', prices: [ { id: 'pro_monthly', stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY ?? '', type: 'recurring', interval: 'month', amount: 2900, // $29.00 in cents currency: 'usd' } ], features: ['Advanced AI', 'Priority Support'] } } } satisfies BillingConfig; // Credit packages are exported separately export const creditPackages = [ { id: 'basic', name: 'Basic Credits', credits: 1000, priceAmount: 1000, // $10.00 in cents stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC ?? '' } ] as const; ``` ## Subscriptions Subscriptions are managed per organization. The kit handles checkout sessions, customer portals and webhooks automatically. ### Checking Subscription Status You can check if an organization has an active subscription using tRPC queries: ```typescript filename="trpc/routers/organization/organization-router.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; import { getActiveSubscriptionByOrganizationId } from '@/lib/billing'; export const organizationRouter = createTRPCRouter({ getSettings: protectedOrganizationProcedure.query(async ({ ctx }) => { const subscription = await getActiveSubscriptionByOrganizationId( ctx.organization.id ); const isSubscribed = !!subscription && (subscription.status === 'active' || subscription.status === 'trialing'); return { isSubscribed }; }) }); ``` ## Credit System The Pro kit includes a robust credit system for usage-based billing, typically used for AI features. ### Consuming Credits Use the `consumeCredits` helper to deduct credits from an organization's balance. ```typescript filename="lib/actions/ai.ts" lineNumbers import { consumeCredits } from '@/lib/billing/credits'; await consumeCredits({ organizationId: ctx.organization.id, amount: 50, description: 'AI Image Generation', referenceType: 'ai_image', referenceId: imageId }); ``` ### Checking Balance ```typescript filename="trpc/routers/organization/organization-credit-router.ts" lineNumbers import { getCreditBalance } from '@/lib/billing/credits'; // In a procedure: const { balance } = await getCreditBalance(organizationId); ``` ## Webhooks Stripe webhooks are handled in `app/api/webhooks/stripe/route.ts`. They keep the local database in sync with Stripe events (e.g., successful payments, subscription cancellations). ### Local Webhook Testing To test webhooks locally, use the Stripe CLI: ```bash filename="Terminal" lineNumbers npm run stripe:listen ``` ## UI Components The kit includes pre-built UI components for: - **Pricing Tables**: Display plans and packages. - **Billing Settings**: Manage subscriptions and view payment history. - **Credit Dashboard**: View current balance and recent transactions. --- ## Check Purchases & Subscriptions **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/billing/check-purchases **Description**: Learn how to check for purchases and subscriptions to provide access to premium features. One of the most common use cases for billing is to provide access to premium features based on a user's or organization's subscription status. ## Plan IDs Plan IDs are defined by the keys in your `billing.config.ts` file. For example, if you have: ```typescript filename="config/billing.config.ts" lineNumbers export const billingConfig = { plans: { free: { isFree: true }, pro: { /* ... */ }, lifetime: { /* ... */ } } }; ``` The plan IDs would be `"free"`, `"pro"` and `"lifetime"`. ## Client-Side Checks Use tRPC queries to check for purchases and subscriptions on the client: ```tsx filename="components/premium-feature.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function PremiumFeature() { const { data: billingStatus } = trpc.organization.subscription.getStatus.useQuery(); if (!billingStatus?.activePlan) { return
Please subscribe to access this feature
; } // Check if user has a specific plan const hasProPlan = billingStatus.activePlan.planId === 'pro'; const hasLifetimeAccess = billingStatus.activePlan.planId === 'lifetime'; const hasActiveSubscription = billingStatus.subscription?.status === 'active' || billingStatus.subscription?.status === 'trialing'; if (!hasActiveSubscription && !hasLifetimeAccess) { return
Please subscribe to access this feature
; } return
Premium feature content
; } ``` ### Billing Status Properties The `getStatus` query returns: - **`activePlan`**: The currently active plan (if any), containing `planId`, `planName`, `status`, etc. - **`subscription`**: The active subscription object (if any), containing `status`, `currentPeriodEnd`, etc. - **`enabled`**: Whether billing is enabled ## Server-Side Checks ### In tRPC Procedures ```typescript filename="trpc/routers/premium-feature.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; import { getActivePlanForOrganization, hasActivePaidPlan, hasSpecificPlan } from '@/lib/billing'; export const premiumFeatureRouter = createTRPCRouter({ access: protectedOrganizationProcedure.query(async ({ ctx }) => { const activePlan = await getActivePlanForOrganization(ctx.organization.id); if ( !activePlan || (activePlan.planId === 'free' && !activePlan.isLifetime) ) { throw new TRPCError({ code: 'FORBIDDEN', message: 'This feature requires an active subscription' }); } return { data: 'premium content' }; }) }); ``` ### In Server Components ```typescript filename="app/(saas)/dashboard/premium/page.tsx" lineNumbers import { getSession } from "@/lib/auth"; import { getActivePlanForOrganization } from "@/lib/billing"; import { redirect } from "next/navigation"; export default async function PremiumPage() { const session = await getSession(); if (!session?.session.activeOrganizationId) { redirect("/auth/sign-in"); } const activePlan = await getActivePlanForOrganization(session.session.activeOrganizationId); if (!activePlan || (activePlan.planId === 'free' && !activePlan.isLifetime)) { redirect("/dashboard/choose-plan"); } return
Premium page content
; } ``` ## Organization-Based Checks Billing is organization-based by default. All checks use the organization ID from the context: ### Client-Side ```tsx filename="components/organization-feature.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function OrganizationFeature() { const { data: billingStatus } = trpc.organization.subscription.getStatus.useQuery(); if ( !billingStatus?.subscription || (billingStatus.subscription.status !== 'active' && billingStatus.subscription.status !== 'trialing') ) { return
This organization needs an active subscription
; } return
Organization premium feature
; } ``` ### Server-Side ```typescript filename="trpc/routers/organization-feature.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; import { hasActivePaidPlan } from '@/lib/billing'; export const organizationFeatureRouter = createTRPCRouter({ access: protectedOrganizationProcedure.query(async ({ ctx }) => { const hasActivePlan = await hasActivePaidPlan(ctx.organization.id); if (!hasActivePlan) { throw new TRPCError({ code: 'FORBIDDEN', message: 'This organization requires an active subscription' }); } return { data: 'organization premium content' }; }) }); ``` ## Plan Limits You can also check plan limits: ```typescript filename="lib/billing/check-limits.ts" lineNumbers import { getOrganizationPlanLimits } from '@/lib/billing/guards'; export async function checkMemberLimit(organizationId: string) { const limits = await getOrganizationPlanLimits(organizationId); // -1 means unlimited if (limits.maxMembers === -1) { return true; // Unlimited members } // Check current member count against limit const currentMembers = await getMemberCount(organizationId); return currentMembers < limits.maxMembers; } ``` ## Helper Functions Use the built-in guard functions for common checks: ```typescript filename="lib/billing/feature-guards.ts" lineNumbers import { TRPCError } from '@trpc/server'; import { hasActivePaidPlan, hasSpecificPlan, requirePaidPlan, requireSpecificPlan } from '@/lib/billing'; // Require any paid plan export async function requireActivePlan(organizationId: string) { await requirePaidPlan(organizationId); } // Require a specific plan export async function requirePlan(organizationId: string, planIds: string[]) { await requireSpecificPlan(organizationId, planIds); } // Check if has active plan (doesn't throw) export async function checkHasActivePlan( organizationId: string ): Promise { return hasActivePaidPlan(organizationId); } // Check if has specific plan (doesn't throw) export async function checkHasPlan( organizationId: string, planId: string ): Promise { return hasSpecificPlan(organizationId, planId); } ``` --- ## Configuration **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/billing/configuration **Description**: Learn about the billing configuration. The billing configuration ensures consistent behavior across the application and any billing provider. ## Basic Setup Configure your Stripe keys in `.env`: ```ini filename=".env" lineNumbers STRIPE_SECRET_KEY=sk_test_... NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... STRIPE_WEBHOOK_SECRET=whsec_... NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY=price_... NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=price_... NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PRO=price_... ``` ## Products and Plans Define your products and plans in your billing configuration file. A simple monthly pro plan would look like this: ```typescript filename="config/billing.config.ts" lineNumbers import { env } from '@/lib/env'; export const billingConfig = { enabled: true, defaultCurrency: 'usd', plans: { free: { id: 'free', name: 'Free', description: 'Get started with basic features', isFree: true, features: ['Basic analytics'], limits: { maxMembers: 3, maxStorage: 1 } }, pro: { id: 'pro', name: 'Pro', description: 'Best for most teams.', features: ['Feature 1', 'Feature 2'], limits: { maxMembers: 10, maxStorage: 100 }, prices: [ { id: 'pro_monthly', stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY ?? '', type: 'recurring', interval: 'month', intervalCount: 1, amount: 2900, // $29.00 in cents currency: 'usd' } ] } } } satisfies BillingConfig; ``` ## Environment Variables Make sure to set the following environment variables: - `STRIPE_SECRET_KEY` - Your Stripe secret key - `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` - Your Stripe publishable key - `STRIPE_WEBHOOK_SECRET` - Your Stripe webhook secret (for production) - `NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY` - Monthly Pro price ID - `NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY` - Yearly Pro price ID - `NEXT_PUBLIC_STRIPE_PRICE_LIFETIME` - One-time lifetime price ID - `NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER` - Starter credit package price ID - `NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC` - Basic credit package price ID - `NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PRO` - Pro credit package price ID ## Email Configuration (Resend) The starter kit uses [Resend](https://resend.com/) for sending emails. Configure Resend in your `.env`: ```ini filename=".env" lineNumbers RESEND_API_KEY=re_... EMAIL_FROM=noreply@yourdomain.com ``` ### Setting up Resend 1. Create an account at [Resend](https://resend.com/) 2. Get your API key from the dashboard 3. Add your domain and verify it with DNS records 4. Set `EMAIL_FROM` to use your verified domain For more details, see the [Email documentation](/docs/starter-kits/pro-nextjs-drizzle/email). --- ## Credits **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/billing/credits **Description**: Learn how to implement and manage a credit-based billing system. The Pro Next.js Drizzle starter kit includes a robust credit system for usage-based billing, typically used for AI features and pay-as-you-go services. ## Overview Credits allow you to charge users based on their actual usage rather than fixed subscription tiers. This is ideal for: - AI-powered features (image generation, text analysis, etc.) - API calls and compute resources - Pay-as-you-go services ## Configuration Credit packages are configured separately in `config/billing.config.ts`: ```typescript filename="config/billing.config.ts" lineNumbers import { env } from '@/lib/env'; // Credit packages are exported separately from billingConfig export const creditPackages = [ { id: 'credits_starter', name: 'Starter', credits: 10_000, priceAmount: 999, // $9.99 in cents stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER ?? '' }, { id: 'credits_basic', name: 'Basic', credits: 50_000, bonusCredits: 5_000, // 10% bonus priceAmount: 3999, // $39.99 in cents stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC ?? '' } ] as const; ``` ## Consuming Credits Use the `consumeCredits` helper to deduct credits from an organization's balance: ```typescript filename="lib/billing/credits.ts" lineNumbers import { consumeCredits } from '@/lib/billing/credits'; await consumeCredits({ organizationId: ctx.organization.id, amount: 50, description: 'AI Image Generation', referenceType: 'ai_image', referenceId: imageId }); ``` The `consumeCredits` function will: - Check if the organization has sufficient credits - Deduct the specified amount - Create a transaction record - Throw an error if insufficient credits ## Checking Credit Balance Get the current credit balance for an organization: ```typescript filename="trpc/routers/organization/organization-credit-router.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; import { getCreditBalance } from '@/lib/billing/credits'; export const organizationCreditRouter = createTRPCRouter({ getBalance: protectedOrganizationProcedure.query(async ({ ctx }) => { const { balance } = await getCreditBalance(ctx.organization.id); return { balance }; }) }); ``` ## Adding Credits Credits are automatically added when a user purchases a credit package. This is handled automatically via Stripe webhooks in `lib/billing/sync.ts`. The webhook handler processes `checkout.session.completed` events and adds credits based on the purchased package. ## Credit Transactions All credit operations are tracked in the database, allowing you to: - View transaction history - Audit credit usage - Generate reports ```typescript filename="trpc/routers/organization/organization-credit-router.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; import { listCreditTransactions } from '@/lib/billing/credits'; export const organizationCreditRouter = createTRPCRouter({ getTransactions: protectedOrganizationProcedure.query(async ({ ctx }) => { return await listCreditTransactions(ctx.organization.id, { limit: 50, offset: 0 }); }) }); ``` ## UI Components The kit includes pre-built UI components for: - **Credit Balance Display**: Show current balance - **Credit Purchase**: Purchase credit packages - **Transaction History**: View credit transactions - **Low Balance Warnings**: Alert users when credits are running low --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/billing/overview **Description**: Learn about the billing package. The starter kit comes with a billing package that enables you to charge your users, display relevant information and let them manage their plan or billing info. ## Monetization The billing package supports multiple monetization options: - **Subscriptions:** Automatically generates recurring invoices at fixed intervals, typically monthly or annually. Ideal for products that offer ongoing access. - **One-time Payments:** Charges the customer a single upfront payment for perpetual access. Useful for lifetime deals, downloadable products or purchasable addons. - **Credits:** A flexible credit system for usage-based billing, typically used for AI features and pay-as-you-go services. ## Configuration The billing configuration ensures consistent behavior across the application and any billing provider. --- ## Paywall **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/billing/paywall **Description**: Learn how to set up a paywall to restrict access to paid plans only. By default, the starter kit includes a free plan, allowing users to access your application after signing up without payment. If you want to require a paid plan or trial, you can set up a paywall. ## Setting Up a Paywall To enable a paywall, remove or disable the free plan in your billing configuration: ```typescript filename="config/billing.config.ts" lineNumbers export const billingConfig = { plans: { // Remove the free plan // free: { // isFree: true, // }, pro: { // ... pro plan configuration } } }; ``` When the free plan is removed, users will be redirected to the plan selection page (`/dashboard/choose-plan`) after signup and onboarding. ## Checking for Active Plans To restrict access to features based on plan status, check if the user has an active plan: ### Server-Side (tRPC) ```typescript filename="trpc/routers/premium-feature.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; import { requirePaidPlan } from '@/lib/billing'; export const premiumFeatureRouter = createTRPCRouter({ access: protectedOrganizationProcedure.query(async ({ ctx }) => { // This will throw if organization doesn't have a paid plan await requirePaidPlan(ctx.organization.id); // Allow access to premium feature return { data: 'premium content' }; }) }); ``` ### Client-Side ```tsx filename="components/premium-feature.tsx" lineNumbers 'use client'; import Link from 'next/link'; import { trpc } from '@/trpc/client'; export function PremiumFeature() { const { data: billingStatus } = trpc.organization.subscription.getStatus.useQuery(); if ( !billingStatus?.activePlan || billingStatus.activePlan.planId === 'free' || (billingStatus.subscription?.status !== 'active' && billingStatus.subscription?.status !== 'trialing') ) { return (

This feature requires an active subscription.

Upgrade now
); } return
Premium feature content
; } ``` ## Protecting Routes You can protect entire routes based on plan status: ```typescript filename="app/(saas)/dashboard/premium/page.tsx" lineNumbers import { getSession } from "@/lib/auth"; import { getActivePlanForOrganization } from "@/lib/billing"; import { redirect } from "next/navigation"; export default async function PremiumPage() { const session = await getSession(); if (!session?.session.activeOrganizationId) { redirect("/auth/sign-in"); } const activePlan = await getActivePlanForOrganization(session.session.activeOrganizationId); if (!activePlan || activePlan.planId === 'free') { redirect("/dashboard/choose-plan"); } return
Premium page content
; } ``` ## Plan-Specific Access You can also check for specific plans: ```typescript filename="lib/billing/check-access.ts" lineNumbers import { hasSpecificPlan } from '@/lib/billing'; export async function checkPlanAccess( organizationId: string, requiredPlanId: string ): Promise { return hasSpecificPlan(organizationId, requiredPlanId); } ``` ```tsx filename="components/pro-feature.tsx" lineNumbers 'use client'; import Link from 'next/link'; import { trpc } from '@/trpc/client'; export function ProFeature() { const { data: billingStatus } = trpc.organization.subscription.getStatus.useQuery(); if (billingStatus?.activePlan?.planId !== 'pro') { return (

This feature is only available on the Pro plan.

Upgrade to Pro
); } return
Pro feature content
; } ``` ## Organization-Based Paywalls Billing is organization-based by default. Use the organization context: ```typescript filename="trpc/routers/organization-feature.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; import { requirePaidPlan } from '@/lib/billing'; export const organizationFeatureRouter = createTRPCRouter({ access: protectedOrganizationProcedure.query(async ({ ctx }) => { // This will throw if organization doesn't have a paid plan await requirePaidPlan(ctx.organization.id); return { data: 'organization premium content' }; }) }); ``` --- ## Plans & Products **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/billing/plans **Description**: Learn how to manage plans and products in your application. Plans and products are defined in `config/billing.config.ts`. This configuration determines what plans are available, their pricing, and how they appear in your pricing table. Storage limits are metadata The shipped kit does not meter stored objects or enforce{' '} maxStorage. Storage amounts in feature lists and plan limits are display and configuration metadata until you add usage tracking and server-side guards. ## Plan Types The starter kit supports several plan types: ### Free Plan The free plan is the default plan for users who haven't purchased any paid plans. It provides limited access to your product. ```typescript filename="config/billing.config.ts" lineNumbers export const billingConfig = { plans: { free: { id: 'free', name: 'Free', description: 'Get started with basic features', isFree: true, features: [ 'Up to 3 team members', 'Basic analytics', 'Community support' ], limits: { maxMembers: 3, maxStorage: 1 // GB } // No prices needed for free plans } } }; ``` ### Subscription Plans Subscription plans charge users on a recurring basis (monthly, yearly, etc.): ```typescript filename="config/billing.config.ts" lineNumbers import { env } from '@/lib/env'; export const billingConfig = { plans: { pro: { id: 'pro', name: 'Pro', description: 'For growing teams', recommended: true, // Highlights this plan in the pricing table features: [ 'Unlimited team members', 'Advanced analytics', 'Priority support', '100 GB storage' ], limits: { maxMembers: -1, // unlimited maxStorage: 100 // GB }, prices: [ { id: 'pro_monthly', stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY ?? '', type: 'recurring', interval: 'month', intervalCount: 1, amount: 2900, // $29.00 in cents currency: 'usd', seatBased: true, // Per-seat pricing trialDays: 14 // Optional: 14-day free trial }, { id: 'pro_yearly', stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY ?? '', type: 'recurring', interval: 'year', intervalCount: 1, amount: 27800, // $278.00 in cents currency: 'usd', seatBased: true, trialDays: 14 } ] } } }; ``` ### One-Time Purchase Plans One-time purchase plans charge users a single upfront payment: ```typescript filename="config/billing.config.ts" lineNumbers import { env } from '@/lib/env'; export const billingConfig = { plans: { lifetime: { id: 'lifetime', name: 'Lifetime', description: 'Pay once, use forever', features: [ 'All Pro features', 'Lifetime updates', 'Priority support for 1 year' ], limits: { maxMembers: -1, maxStorage: 100 }, prices: [ { id: 'lifetime_once', stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_LIFETIME ?? '', type: 'one_time', amount: 49900, // $499.00 in cents currency: 'usd' } ] } } }; ``` ### Enterprise Plan Enterprise plans show up in the pricing table with a link to a contact form: ```typescript filename="config/billing.config.ts" lineNumbers export const billingConfig = { plans: { enterprise: { id: 'enterprise', name: 'Enterprise', description: 'For large organizations with custom needs', isEnterprise: true, features: [ 'Everything in Pro', 'Dedicated account manager', 'Custom SLA', 'Unlimited storage', 'SSO / SAML' ], limits: { maxMembers: -1, maxStorage: -1 } // No prices needed - users contact you directly } } }; ``` ## Price Properties Each price object supports the following properties: - **`id`**: Unique identifier for the price (e.g., `"pro_monthly"`) - **`type`**: `"recurring"` or `"one_time"` - **`stripePriceId`**: The Stripe Price ID (from environment variables, starts with `price_`) - **`interval`**: For recurring plans: `"month"`, `"year"`, `"week"` or `"day"` - **`intervalCount`**: Number of intervals to bill (defaults to 1) - **`amount`**: The price amount (in cents for Stripe) - **`currency`**: Currency code (e.g., `"usd"`, `"eur"`) - **`trialDays`**: Optional trial period in days - **`seatBased`**: If `true`, price is per seat (multiplies by number of organization members) ## Plan Configuration Options - **`recommended`**: Highlights the plan in the pricing table - **`hidden`**: Hides the plan from the pricing table (useful for grandfathering old plans) ## Creating Plans in Stripe 1. Go to your [Stripe Dashboard](https://dashboard.stripe.com/login) 2. Navigate to **Products** > **Add product** 3. Create your product with pricing 4. Copy the **Price ID** (starts with `price_`) 5. Add it to your environment variables: ```env filename=".env" lineNumbers NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY=price_xxxxx NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=price_xxxxx NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=price_xxxxx ``` ## Plan Limits You can define plan limits in `config/billing.config.ts`: ```typescript filename="config/billing.config.ts" lineNumbers export const billingConfig = { plans: { pro: { // ... prices limits: { maxMembers: 10, // Maximum organization members maxStorage: 50 // GB of plan metadata } } } }; ``` The shipped invitation guards enforce `maxMembers`. `maxStorage` is plan metadata until you add stored-byte usage tracking and a server-side guard. To add another limit such as `maxProjects`, extend the billing schema and enforce it on every relevant server mutation. --- ## Subscriptions **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/billing/subscriptions **Description**: Learn how to set up and manage subscriptions. ## Creating a Subscription To create a subscription, use the Stripe API: ```typescript filename="create-subscription.ts" lineNumbers import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); const subscription = await stripe.subscriptions.create({ customer: customerId, items: [{ price: priceId }] }); ``` ## Checking Subscription Status Check if an organization has an active subscription: ```typescript filename="check-subscription.ts" lineNumbers import { getActivePlanForOrganization, getActiveSubscriptionByOrganizationId } from '@/lib/billing'; // Get the active subscription const subscription = await getActiveSubscriptionByOrganizationId(organizationId); if (subscription?.status === 'active' || subscription?.status === 'trialing') { // Organization has active subscription } // Or get the active plan (includes subscription and lifetime orders) const activePlan = await getActivePlanForOrganization(organizationId); if (activePlan && activePlan.planId !== 'free') { // Organization has an active paid plan } ``` ## Canceling a Subscription Allow users to cancel their subscriptions: ```typescript filename="cancel-subscription.ts" lineNumbers import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); await stripe.subscriptions.cancel(subscriptionId); ``` --- ## Webhooks **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/billing/webhooks **Description**: Learn how to handle Stripe webhooks. Webhooks are used to receive events from Stripe. They are important to get the latest data so your application is in sync with Stripe. ## Setting up Webhooks 1. Go to your [Stripe Dashboard](https://dashboard.stripe.com/login?redirect=%2Fwebhooks) 2. Click "Add endpoint" 3. Enter your webhook URL: `https://yourdomain.com/api/webhooks/stripe` 4. Select the required events (see list below) 5. Copy the webhook signing secret ## Required Webhook Events The following Stripe webhook events are handled by the webhook handler: ### Subscription Events - `customer.subscription.created` - When a new subscription is created - `customer.subscription.updated` - When a subscription is modified (plan changes, status updates) - `customer.subscription.deleted` - When a subscription is canceled or expires - `customer.subscription.trial_will_end` - When a trial is ending soon (3 days before) - `customer.subscription.paused` - When a subscription is paused - `customer.subscription.resumed` - When a paused subscription is resumed ### Checkout Events - `checkout.session.completed` - When a checkout session completes (subscriptions, one-time payments, credit purchases) ### Invoice Events - `invoice.paid` - When an invoice payment succeeds - `invoice.payment_failed` - When an invoice payment fails ### Charge Events - `charge.refunded` - When a charge is refunded (handles both full and partial refunds) ### Refund Events - `refund.created` - When a refund is initiated - `refund.updated` - When a refund's status updates - `refund.failed` - When a refund fails ### Dispute Events - `charge.dispute.created` - When a customer disputes a charge - `charge.dispute.updated` - When a dispute status updates - `charge.dispute.closed` - When a dispute is resolved - `charge.dispute.funds_withdrawn` - Funds withdrawn from balance - `charge.dispute.funds_reinstated` - Funds reinstated to balance ### Customer Events - `customer.deleted` - When a customer is deleted from Stripe ### Payment Intent Events - `payment_intent.succeeded` - When a payment intent succeeds (for audit logging) ## Webhook Handler The starter kit includes a comprehensive webhook handler at `app/api/webhooks/stripe/route.ts` that handles all billing events. The handler includes: - **Signature verification** - Validates webhook authenticity using Stripe's signature - **Idempotency** - Prevents duplicate processing of the same event - **Error handling** - Distinguishes between transient and permanent errors - **Event logging** - Records all events in the database for audit trails ### Supported Events The handler processes the following events: - `checkout.session.completed` - Handles subscriptions, one-time payments and credit purchases - `customer.subscription.created` - Creates subscription records - `customer.subscription.updated` - Updates subscription status and plan changes - `customer.subscription.deleted` - Marks subscriptions as canceled - `customer.subscription.trial_will_end` - Sends trial ending notifications - `customer.subscription.paused` - Handles subscription pauses - `customer.subscription.resumed` - Handles subscription resumption - `invoice.paid` - Logs successful invoice payments - `invoice.payment_failed` - Sends payment failure notifications - `charge.refunded` - Handles refunds (full and partial) - `refund.created` - Tracks refund lifecycle - `refund.updated` - Updates refund status - `refund.failed` - Logs refund failure - `charge.dispute.created` - Alerts admins of new chargebacks - `charge.dispute.updated` - Updates dispute status - `charge.dispute.closed` - Logs dispute resolution - `customer.deleted` - Clears Stripe customer ID from organizations - `payment_intent.succeeded` - Logs payment intents for audit ### Extending the Handler To add custom logic for a specific event, you can modify the handler functions in `app/api/webhooks/stripe/route.ts`. For example, to add custom logic when a subscription is created: ```typescript filename="app/api/webhooks/stripe/route.ts" lineNumbers async function handleSubscriptionCreated( eventId: string, subscription: Stripe.Subscription ): Promise { // ... existing code ... // Add your custom logic here await sendWelcomeEmail(organizationId); await createInitialResources(organizationId); } ``` ## Testing Webhooks Install and authenticate the Stripe CLI, start the application and run the included listener from a second terminal: ```sh filename="Terminal" lineNumbers npm run stripe:listen ``` The CLI prints a temporary `whsec_...` signing secret. Put that value in the local `.env` as `STRIPE_WEBHOOK_SECRET`, then restart the development server so the handler reads it. The Stripe CLI listener secret and the production endpoint secret are different. Use the value printed by `stripe listen` locally. Store the endpoint's Dashboard secret in the hosting provider for production. You can ask the Stripe CLI to send a fixture event through the listener: ```sh filename="Terminal" lineNumbers npm run stripe:trigger -- payment_intent.succeeded ``` A generated fixture proves that forwarding and signature verification work. It may not contain the organization, price and checkout metadata created by the application. Test state synchronization by completing a checkout through the local UI with Stripe test-mode credentials, then confirm the related order, subscription or credits in the application. ## Verify Production Delivery After deploying: 1. Confirm the endpoint URL is the final HTTPS origin plus `/api/webhooks/stripe`. 2. Confirm the endpoint is subscribed to every event used by your enabled billing modes. 3. Complete a test-mode checkout and inspect its delivery in Stripe's webhook event log. 4. Check that the application recorded the event and updated the intended organization. 5. Resend the same event from Stripe and confirm it is treated as already processed rather than applying credits or access twice. The handler returns a failure status for transient processing errors so Stripe can retry. Permanent data errors are recorded and acknowledged to avoid an endless retry loop. Monitor failed billing-event records and Stripe delivery attempts together when diagnosing synchronization problems. --- ## Blog **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/cms/blog **Description**: Learn how to write blog posts using Content Collections. The starter kit uses [Content Collections](https://www.content-collections.dev/) for managing blog content. All blog posts are written using `.mdx` files, which combine markdown with React components. Why choose Content Collections? Content Collections is a great alternative to headless CMS platforms such as Contentful or Prismic. It's powered by MDX, free, open-source, and saves content directly in your repository. The blog is configured in `content-collections.ts` and uses [Fumadocs](https://www.fumadocs.dev/) for rendering documentation-style content. ## Add a new blog post To create a new blog post, follow these steps: 1. **Create a new file** Navigate to the `content/posts` directory and create a new `.mdx` file. The file name will act as the URL slug for the post. For example: - File name: `hello-world.mdx` - URL: `https://your-app.com/blog/hello-world` 2. **Add metadata** At the top of the `.mdx` file, include a frontmatter block. This block contains key metadata about your post, written in a YAML-like format enclosed by three dashes (`---`). Here's an example: ```mdx filename="content/posts/hello-world.mdx" lineNumbers --- title: How to create a blog post date: 2025-01-20T12:00:00.000Z authorName: John Doe authorImage: /authors/john.jpg authorLink: https://example.com excerpt: A short description of your blog post. tags: [Innovation, Tutorial] published: true content: | Your blog post content goes here... --- ``` ### Frontmatter Fields The blog post schema supports the following fields: - `title` (required) - The title of the blog post - `date` (required) - ISO 8601 date string for publication date - `authorName` (required) - Name of the author - `authorImage` (optional) - URL to author's image - `authorLink` (optional) - Link to author's profile - `excerpt` (optional) - A short description/excerpt of the post - `tags` (required) - Array of tag strings - `published` (required) - Boolean to control visibility - `image` (optional) - Featured image URL - `content` (required) - The full content of the post ## Using MDX Components You can use React components directly in your MDX files. The starter kit provides several custom components: ```mdx filename="content/posts/example.mdx" lineNumbers --- title: Example Post date: 2025-01-20T12:00:00.000Z authorName: John Doe tags: [Example] published: true content: | # My Blog Post This is a callout component! Regular markdown content here. --- ``` ## Code Blocks Code blocks are automatically highlighted and support line numbers: ```typescript filename="example.ts" lineNumbers export function example() { return 'Hello, World!'; } ``` ## Images You can include images in your blog posts: ```mdx filename="content/posts/example.mdx" lineNumbers ![Alt text](/path/to/image.png) ``` Or use the Image component for more control: ```mdx filename="content/posts/example.mdx" lineNumbers import { Image } from '@/components/mdx-components'; Alt text ``` ## Building Content Content Collections are automatically built during the Next.js build process. During development, they are rebuilt when files change. ## Querying Blog Posts You can query blog posts in your application: ```typescript filename="app/blog/page.tsx" lineNumbers import { allDocuments } from 'content-collections/generated'; export default function BlogPage() { const posts = allDocuments('posts') .filter((post) => post.published) .sort((a, b) => { const dateA = new Date(a.date); const dateB = new Date(b.date); return dateB.getTime() - dateA.getTime(); }); return (
{posts.map((post) => (

{post.title}

{post.excerpt}

))}
); } ``` ## Configuration The blog collection is configured in `content-collections.ts`: ```typescript filename="content-collections.ts" lineNumbers const posts = defineCollection({ name: 'posts', directory: 'content/posts', include: '**/*.{mdx,md}', schema: z.object({ title: z.string(), date: z.string(), authorName: z.string(), excerpt: z.string().optional(), tags: z.array(z.string()), published: z.boolean(), content: z.string() }) }); export default defineConfig({ collections: [posts] }); ``` ## Best Practices 1. **Use descriptive filenames** - The filename becomes the URL slug 2. **Add descriptions** - Help with SEO and preview cards 3. **Use categories** - Organize related posts 4. **Set publication dates** - Control when posts appear 5. **Test locally** - Always preview posts before publishing --- ## Documentation **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/cms/documentation **Description**: Write and organize product documentation with Fumadocs MDX. The starter kit includes a Fumadocs documentation site at `/docs`. Its pages are local MDX files in `content/docs`, so the documentation stays versioned with the application code. Both starter kit repositories use the same documentation structure. Do not create an ORM-specific folder inside `content/docs`. ## How documentation is connected | File | Responsibility | | ------------------------------- | ------------------------------------------------------- | | `content/docs/*.mdx` | Documentation content and page metadata | | `content/docs/meta.json` | Sidebar groups, labels and page order | | `source.config.ts` | Declares `content/docs` as the Fumadocs MDX source | | `lib/marketing/docs/source.ts` | Loads the content and assigns the `/docs` base URL | | `app/docs/layout.tsx` | Configures the documentation layout and navigation tree | | `app/docs/[[...slug]]/page.tsx` | Renders each page and generates its metadata | The kit already connects these files. You normally only need to edit `content/docs` when writing product documentation. ## Add a page ### Create the MDX file Add a file directly under `content/docs`. Its path becomes the URL after `/docs`. ```mdx filename="content/docs/getting-started.mdx" --- title: Getting started description: Configure the application for local development. icon: Rocket --- ## Prerequisites Add your guide here. ``` This example is available at `/docs/getting-started`. The optional `icon` value must match an icon exported by Lucide React. ### Add the page to the sidebar Add the filename without `.mdx` to the `pages` array in `content/docs/meta.json`: ```json filename="content/docs/meta.json" { "title": "Documentation", "root": true, "pages": ["index", "getting-started"] } ``` Keep this array in the order you want readers to follow. Fumadocs also supports separators and external links in this file. ### Preview the page Start the application and open the new route: ```bash npm run dev ``` Visit `http://localhost:3000/docs/getting-started` and check the page on both desktop and mobile. ## Organize a section For a larger topic, put its pages in a folder and add a `meta.json` inside that folder. The folder name becomes the URL segment. ```text content/docs/ ├── meta.json └── billing/ ├── meta.json ├── overview.mdx └── webhooks.mdx ``` ```json filename="content/docs/billing/meta.json" { "title": "Billing", "pages": ["overview", "webhooks"] } ``` The pages are then available at `/docs/billing/overview` and `/docs/billing/webhooks`. Add `billing` to the root `content/docs/meta.json` where that section should appear. ## Use the included MDX components The page renderer registers Fumadocs components including `Callout`, `Cards`, `Tabs`, `Steps`, `Files` and `ImageZoom`. You can use them directly in an MDX page without importing them. ```mdx filename="content/docs/getting-started.mdx" Copy `.env.example` to `.env` and provide the required values. Use your local service credentials. Use credentials from the production project. ``` Standard fenced code blocks support syntax highlighting. Add a `filename` attribute when the file location helps the reader. ## Change the documentation UI - Edit `app/docs/layout.tsx` to change the documentation shell or sidebar behavior. - Edit `lib/marketing/docs/layout.config.tsx` to change shared layout options such as navigation links. - Edit `app/docs/[[...slug]]/page.tsx` to register another MDX component or change page rendering. - Edit `lib/marketing/docs/source.ts` only when changing how the content source is loaded. Keep content changes in `content/docs` and layout changes in the application files above. This separation makes upgrades easier and keeps navigation generated from the same source as the pages. ## Validate before publishing Run the same checks used for application changes: ```bash npm run typecheck npm run lint npm run build ``` Also open every new documentation route locally. A successful build confirms that Fumadocs can compile the MDX, while the browser check catches navigation, layout and readability problems. For advanced navigation and MDX options, see the [Fumadocs documentation](https://fumadocs.dev/docs/mdx). --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/cms/overview **Description**: Learn how to manage content using Content Collections and Fumadocs. The starter kit uses [Content Collections](https://www.content-collections.dev/) for managing content and [Fumadocs](https://www.fumadocs.dev/) for rendering documentation. This provides a powerful, type-safe content management system that's easy to use and maintain. Why Content Collections? Content Collections is a great alternative to headless CMS platforms such as Contentful or Prismic. It's powered by MDX, free, open-source, and saves content directly in your repository. This means your content is version-controlled and easy to manage. ## Features - **Type-safe content** - Full TypeScript support with automatic type generation - **MDX support** - Write content using Markdown with React components - **Version control** - Content is stored in your repository, making it easy to track changes - **Fast builds** - Content is compiled at build time for optimal performance - **Developer-friendly** - Edit content using your favorite code editor - **No database required** - Content is stored as files, not in a database ## Content Collections Content Collections provides: - **Schema validation** - Define schemas for your content using Zod - **Automatic type generation** - TypeScript types are generated from your schemas - **Query API** - Easy-to-use API for querying content - **Transform functions** - Process and transform content during build ## Fumadocs Fumadocs provides: - **Beautiful UI** - Pre-built documentation UI components - **Search** - Full-text search across your documentation - **Dark mode** - Automatic theme switching - **Responsive design** - Mobile-friendly layouts - **Table of contents** - Automatically generated from headings ## Configuration Content Collections is configured in `content-collections.ts`. The starter kit includes multiple collections: ```typescript filename="content-collections.ts" lineNumbers import { defineCollection, defineConfig } from '@content-collections/core'; import { compileMDX } from '@content-collections/mdx'; import { z } from 'zod'; // Blog posts collection const posts = defineCollection({ name: 'posts', directory: 'content/posts', include: '**/*.{mdx,md}', schema: z.object({ title: z.string(), date: z.string(), authorName: z.string(), excerpt: z.string().optional(), tags: z.array(z.string()), published: z.boolean(), content: z.string() }), transform: async (document, context) => { const body = await compileMDX(context, document); return { ...document, body, path: document._meta.path.replace(/\.mdx?$/, '') }; } }); export default defineConfig({ collections: [posts] }); ``` ## Building Content Content Collections are automatically built during the Next.js build process. During development, they are rebuilt when files change. ## Querying Content You can query content in your application: ```typescript filename="app/blog/page.tsx" lineNumbers import { allDocuments } from 'content-collections/generated'; export default function BlogPage() { const posts = allDocuments('posts') .filter((post) => post.published) .sort((a, b) => { const dateA = new Date(a.date); const dateB = new Date(b.date); return dateB.getTime() - dateA.getTime(); }); return (
{posts.map((post) => (

{post.title}

{post.excerpt}

))}
); } ``` ## Best Practices 1. **Organize content** - Use clear directory structures 2. **Use schemas** - Define schemas for type safety 3. **Version control** - Commit content changes to git 4. **Test locally** - Always preview content before publishing 5. **Use MDX components** - Leverage React components in your content --- ## Common Commands **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/codebase/commands **Description**: A practical reference for daily development, tests and local services. Use these commands from the repository root. They are defined in `package.json`, so prefer them over running the underlying tools directly. ## Everyday Development | Command | Purpose | | ------------------- | ------------------------------------------------- | | `npm install` | Install project dependencies | | `npm run dev` | Start the Next.js development server on port 3000 | | `npm run build` | Create a production build | | `npm run start` | Serve an existing production build | | `npm run typecheck` | Check TypeScript without emitting files | ## Environment-aware commands Next.js loads the root `.env` file for `dev`, `build` and `start`. The test scripts use the repository's `with-dev-env` wrapper because Vitest and Playwright also need those values outside the Next.js process: ```sh filename="Terminal" lineNumbers npm run with-dev-env -- ``` You normally do not need to call this wrapper yourself. Use it when running a one-off tool that imports application modules depending on `lib/env.ts`. ## Code Quality | Command | Purpose | | ---------------------- | ------------------------------------ | | `npm run format` | Check formatting with Oxfmt | | `npm run format:write` | Apply safe formatting changes | | `npm run lint` | Check lint rules with Oxlint | | `npm run lint:write` | Apply safe lint fixes | | `npm run check` | Run Oxlint and Oxfmt checks together | Run the non-writing commands in CI. Review the diff after any command ending in `:write` before committing its changes. ## Tests | Command | Purpose | | ------------------------- | ------------------------------------------------------- | | `npm run test -- --run` | Run the unit test suite once and exit | | `npm run test:watch` | Run unit tests in watch mode | | `npm run test:coverage` | Run unit tests and collect coverage | | `npm run test:db` | Include tests that require the configured test database | | `npm run test:e2e:setup` | Install the Playwright browser | | `npm run test:e2e` | Run Playwright end-to-end tests headlessly | | `npm run test:e2e:headed` | Run Playwright while displaying the browser | | `npm run test:e2e:ui` | Open Playwright's interactive test runner | | `npm run test:e2e:debug` | Run Playwright with its inspector | | `npm run e2e:ci` | Install Playwright browsers and run E2E tests for CI | `test:db` and the Playwright commands load variables from the root `.env` file. Use isolated test credentials and never point them at production services. Running `npm run test` without `-- --run` can enter Vitest's interactive watch workflow in a local terminal. Use the explicit one-shot command in scripts and before commits so the process exits with a reliable status. ## Local Services | Command | Purpose | | ----------------------------------- | ---------------------------------------------- | | `npm run docker:up` | Start the included PostgreSQL container | | `npm run docker:down` | Stop the included containers | | `npm run docker:logs` | Follow container logs | | `npm run db:studio` | Open the Drizzle database browser | | `npm run stripe:listen` | Forward Stripe CLI events to the local webhook | | `npm run stripe:trigger -- ` | Ask Stripe CLI to emit a test event | | `npm run email:dev` | Preview React Email templates on port 3001 | Database migration commands differ between the starter kit variants. Follow the [Drizzle database guide](/docs/starter-kits/pro-nextjs-drizzle/database) before changing or applying a schema. ## Repository MCP server | Command | Purpose | | ------------------- | -------------------------------------------------- | | `npm run mcp:build` | Compile the local read-only MCP server | | `npm run mcp:start` | Compile and start the MCP server over standard I/O | An MCP client normally launches `mcp:start` for you. The process waits for MCP messages over standard input/output; it does not open a browser page or HTTP port. See the [MCP server guide](/docs/starter-kits/pro-nextjs-drizzle/codebase/mcp-server) for the checked-in configuration and client-specific setup. ## Dependency Maintenance | Command | Purpose | | --------------------- | ---------------------------------------------- | | `npm run deps:check` | List dependency updates without changing files | | `npm run deps:update` | Update version ranges in `package.json` | After updating dependencies, run `npm install`, review the lockfile and complete the typecheck, lint, test and production build checks before committing. Be careful with the clean command npm run clean removes generated output, dependencies and ignored files from the listed build directories. Commit or back up any ignored work you intend to keep before running it. --- ## Dependencies **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/codebase/dependencies **Description**: Learn how to manage dependencies in the starter kit. We use [npm](https://www.npmjs.com/) as our package manager, which is the standard for Next.js projects. About npm npm is the default package manager for Node.js and comes pre-installed with Node.js. It's widely supported and works seamlessly with Next.js and the broader JavaScript ecosystem. ## Install all packages To install all packages, run: ```sh filename="Terminal" lineNumbers npm install ``` This is likely your first command when you download the starter kit. ## Add a package To install a package as a dependency: ```sh filename="Terminal" lineNumbers npm install ``` To install a package as a dev dependency: ```sh filename="Terminal" lineNumbers npm install -D ``` ## Remove a package To remove a package: ```sh filename="Terminal" lineNumbers npm uninstall ``` ## Update packages To update all packages to their latest versions: ```sh filename="Terminal" lineNumbers npm run deps:update ``` ## Check for outdated packages To see which packages have updates available: ```sh filename="Terminal" lineNumbers npm run deps:check ``` --- ## Environment Variables **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/codebase/environment-variables **Description**: Learn how environment variables are managed in the project. The starter kit uses [`@t3-oss/env-nextjs`](https://env.t3.gg/) to manage environment variables with type safety and validation. All environment variables are defined in `lib/env.ts` with Zod schemas. ## Environment Variable Files Create a `.env` file in the root directory (you can copy from `.env.example`): ```sh filename="Terminal" lineNumbers cp .env.example .env ``` Security Note Never commit `.env` to version control. It's already included in `.gitignore`. Use the same variable names in every environment, but store the values in the place that owns that environment: | Environment | Where to set values | What to commit | | ---------------------- | ------------------------------------------------ | ------------------------------------------ | | Local development | Root `.env` file | Only `.env.example` with safe placeholders | | Vercel or another host | The project's environment variable settings | Nothing containing production values | | CI | The CI provider's encrypted secrets or variables | Workflow references to the variable names | After changing a local value, restart the development server. After changing a hosted value, redeploy the affected environment so Next.js can include any build-time values in the new deployment. ## Server and Browser Variables The `server` and `client` schemas in `lib/env.ts` are a security boundary: - Server variables such as `DATABASE_URL`, `BETTER_AUTH_SECRET`, `STRIPE_SECRET_KEY` and `RESEND_API_KEY` must never use the `NEXT_PUBLIC_` prefix. - Client variables must start with `NEXT_PUBLIC_`. Their values are included in browser-accessible JavaScript and must not contain credentials or secrets. - Adding a variable to `.env` does not add it to the validated application configuration. Declare it in the matching schema and in `runtimeEnv` as shown below. Assume every public value is visible Publishable Stripe keys, Price IDs, site URLs and Turnstile site keys can be public. Stripe secret keys, webhook secrets, database credentials, Better Auth secrets, Resend keys and Turnstile secret keys must remain server-only. ## Required Variables The following environment variables are required for the application to run: ### Database ```env filename=".env" DATABASE_URL=postgresql://user:password@localhost:5432/dbname ``` Note The POSTGRES_* variables (POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, POSTGRES_HOST, POSTGRES_PORT) are optional and have defaults. Only DATABASE_URL is required. ### Authentication ```env filename=".env" BETTER_AUTH_SECRET=paste-a-new-random-secret-here ``` Generate your own value The authentication secret is named BETTER_AUTH_SECRET, not{' '} AUTH_SECRET. Replace the development value copied from{' '} .env.example before sharing or deploying the application. Every environment should use its own secret. ## Optional Variables Optional means the application can start without the integration. Once you enable a feature, configure its complete variable set rather than adding one key at a time. | Feature | Configure together | If omitted | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | | AI chat | `OPENAI_API_KEY` | AI requests cannot reach OpenAI | | Google sign-in | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | Google is not offered as a sign-in method | | Email delivery | `EMAIL_FROM`, `RESEND_API_KEY` | Email-sending flows fail when invoked | | Stripe billing | `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` and the Price IDs used by your configured plans or credits | Billing actions are unavailable | | S3 storage | `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_ENDPOINT`, `NEXT_PUBLIC_IMAGES_BUCKET_NAME` | Uploads are unavailable | | Turnstile | `TURNSTILE_SECRET_KEY`, `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | Captcha protection is disabled | | Sentry source maps | `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN` | Builds do not upload source maps | Keep paired values in sync Configure both the server and browser value for Stripe and Turnstile. A browser-only key can render an integration that the server cannot verify, while a server-only key leaves the corresponding client flow unavailable. ### AI (OpenAI) ```env filename=".env" OPENAI_API_KEY=sk-... ``` The shipped chat route uses the direct OpenAI provider. Its SDK reads `OPENAI_API_KEY` from the server environment, so this variable is intentionally not prefixed with `NEXT_PUBLIC_`. Remove the key if you disable the AI feature. ### Authentication (OAuth) ```env filename=".env" GOOGLE_CLIENT_ID=your-google-client-id GOOGLE_CLIENT_SECRET=your-google-client-secret ``` ### Billing (Stripe) ```env filename=".env" STRIPE_SECRET_KEY=sk_test_... STRIPE_WEBHOOK_SECRET=whsec_... NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY=price_... NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=price_... NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PRO=price_... ``` ### Email (Resend) ```env filename=".env" EMAIL_FROM=noreply@example.com RESEND_API_KEY=re_... ``` ### Storage (S3) ```env filename=".env" S3_ACCESS_KEY_ID=your-access-key S3_SECRET_ACCESS_KEY=your-secret-key S3_ENDPOINT=https://your-s3-compatible-endpoint.example S3_REGION=your-provider-region NEXT_PUBLIC_IMAGES_BUCKET_NAME=your-bucket-name ``` Use the endpoint and signing region supplied by your storage provider. The storage client falls back to `auto` only when `S3_REGION` is omitted. ### Monitoring (Sentry) ```env filename=".env" SENTRY_ORG=your-org SENTRY_PROJECT=your-project SENTRY_AUTH_TOKEN=your-auth-token NEXT_PUBLIC_SENTRY_DSN=https://...@sentry.io/... ``` ### Captcha (Cloudflare Turnstile) ```env filename=".env" TURNSTILE_SECRET_KEY=your-secret-key NEXT_PUBLIC_TURNSTILE_SITE_KEY=your-site-key ``` ### Site Configuration ```env filename=".env" NEXT_PUBLIC_SITE_URL=https://your-domain.com NEXT_PUBLIC_LOG_LEVEL=info ``` ## Type Safety The project uses TypeScript and Zod to ensure type safety for environment variables. All variables are defined in `lib/env.ts` with validation schemas. Type Safety Environment variables are validated at build time and runtime. If a required variable is missing or has an invalid type, the application will fail to start with a clear error message. ## Adding New Variables 1. Add the variable to `lib/env.ts` in the appropriate schema (server or client) 2. Add the variable to `.env.example` (without sensitive values) 3. Add the variable to your `.env` file with the actual value 4. Add the variable to `runtimeEnv` in `lib/env.ts` 5. Restart your development server ### Example: Adding a Server Variable ```typescript filename="lib/env.ts" lineNumbers server: { // ... existing variables MY_NEW_VAR: z.string().min(1), }, ``` ```typescript filename="lib/env.ts" lineNumbers runtimeEnv: { // ... existing variables MY_NEW_VAR: process.env.MY_NEW_VAR, }, ``` ### Example: Adding a Client Variable Client variables must be prefixed with `NEXT_PUBLIC_`: ```typescript filename="lib/env.ts" lineNumbers client: { // ... existing variables NEXT_PUBLIC_MY_VAR: z.string().optional(), }, ``` ```typescript filename="lib/env.ts" lineNumbers runtimeEnv: { // ... existing variables NEXT_PUBLIC_MY_VAR: process.env.NEXT_PUBLIC_MY_VAR, }, ``` ## Production For production deployments, set environment variables in your hosting platform's dashboard (Vercel, Railway, etc.). Never commit production secrets to your repository. ### Skipping Validation For Docker builds or CI/CD pipelines, you can skip environment variable validation: ```sh filename="Terminal" lineNumbers SKIP_ENV_VALIDATION=true bun run build ``` This is useful when environment variables are provided at runtime rather than build time. It only skips schema validation. It does not supply missing values, so the related feature can still fail when used. --- ## Formatting & Linting **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/codebase/formatting-linting **Description**: Learn how to format with Oxfmt and lint with Oxlint. The starter kit uses [Oxfmt](https://oxc.rs/docs/guide/usage/formatter.html) for formatting and [Oxlint](https://oxc.rs/docs/guide/usage/linter.html) for linting. The tools have separate configuration and commands so a formatting change never hides a lint failure. Type-aware linting is enabled Oxlint loads its TypeScript, React, import, accessibility and Next.js plugins with type-aware analysis. Run the repository scripts from the project root so the linter can resolve the local TypeScript configuration. ## Format and fix on save The recommended VS Code workspace settings use the Oxc extension for both formatting and safe lint fixes: ```json filename=".vscode/settings.json" lineNumbers { "editor.formatOnSave": true, "editor.defaultFormatter": "oxc.oxc-vscode", "editor.codeActionsOnSave": { "source.fixAll.oxc": "always" }, "oxc.fmt.configPath": ".oxfmtrc.json", "oxc.fixKind": "safe_fix_or_suggestion", "oxc.typeAware": true } ``` Change `editor.formatOnSave` to `false` and remove the `source.fixAll.oxc` action if you prefer to run the commands manually. ## Manual commands Use non-writing commands in CI and before reviewing a change: | Command | Purpose | | ---------------- | ----------------------------------------- | | `npm run format` | Check formatting with Oxfmt | | `npm run lint` | Check lint rules with Oxlint | | `npm run check` | Run linting and then the formatting check | Use the writing variants when you intentionally want to change files: | Command | Purpose | | ---------------------- | -------------------------------------------- | | `npm run format:write` | Format supported files with Oxfmt | | `npm run lint:write` | Apply safe Oxlint fixes | | `npm run check:write` | Apply lint fixes and then format the project | Always review the resulting diff. Automated fixes can be valid while still changing code in a way you did not intend. ## Configuration Oxlint reads `.oxlintrc.json`. The shipped configuration enables TypeScript, React, import, JSX accessibility, Next.js and Oxc rules. Generated output, coverage, migration files and test reports are ignored. Oxfmt reads `.oxfmtrc.json`. It defines the print width, quote and semicolon style, import sorting and Tailwind CSS class sorting. Migration files and generated or vendored output are excluded from formatting. Keep tool exclusions in these configuration files rather than adding ad hoc flags to package scripts. That keeps editor, local and CI behavior aligned. ## Editor integration Install the [Oxc VS Code extension](https://marketplace.visualstudio.com/items?itemName=oxc.oxc-vscode). The repository already recommends it through `.vscode/extensions.json` and sets it as the default formatter in `.vscode/settings.json`. If VS Code still uses an older formatter: 1. Disable the older workspace formatter extension for this repository. 2. Run **Format Document With...** and choose **Oxc**. 3. Select **Configure Default Formatter** and choose **Oxc**. 4. Reload the editor after installing or upgrading the extension. ## Pre-commit behavior The Husky pre-commit hook runs `lint-staged`. JavaScript and TypeScript files receive safe Oxlint fixes followed by Oxfmt. JSON, CSS, Markdown and MDX files are formatted with Oxfmt. Database migration files are deliberately excluded. The pre-push hook runs `npm run typecheck`. These hooks are a fast guardrail, not a replacement for the complete test and build checks in CI. --- ## Local Development **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/codebase/local-development **Description**: Learn how to set up your local development environment. This guide will help you set up your local development environment for the Pro Next.js Drizzle starter kit, including the necessary services like PostgreSQL. ## Prerequisites To run the application locally, you need to have the following: - [Node.js 22.21.1](https://nodejs.org/en), matching the version in `package.json` - [npm](https://www.npmjs.com/) (comes with Node.js) - [PostgreSQL](https://www.postgresql.org/download/) (v14 or later) ## Recommended Startup Order Start only the services needed for the flow you are testing: | Order | Service | Command | Local address | Required | | ----- | ------------------------- | ----------------------- | ---------------------------------- | --------------------------------- | | 1 | PostgreSQL 17 | `npm run docker:up` | `localhost:5432` | Yes | | 2 | Next.js | `npm run dev` | `http://localhost:3000` | Yes | | 3 | React Email preview | `npm run email:dev` | `http://localhost:3001` | Only when editing email templates | | 4 | Stripe webhook forwarding | `npm run stripe:listen` | Forwards to `/api/webhooks/stripe` | Only when testing billing events | The Stripe command requires the [Stripe CLI](https://docs.stripe.com/stripe-cli) and an authenticated Stripe account. Copy the temporary `whsec_...` value it prints into `STRIPE_WEBHOOK_SECRET`, then restart Next.js. ## Setting Up Local Services ### Option 1: Local PostgreSQL Installation Install PostgreSQL on your machine and create a database: ```sh filename="Terminal" lineNumbers createdb your_database_name ``` ### Option 2: Docker Compose (Recommended) The repository includes a `docker-compose.yml` file with PostgreSQL 17. It creates a database named `database` with the password `password`. ## Starting the Services 1. Start the services using Docker Compose: ```sh filename="Terminal" lineNumbers npm run docker:up ``` 2. Verify that the services are running: ```sh filename="Terminal" lineNumbers docker compose ps ``` ## Environment Configuration Start from the environment template shipped with the repository: ```sh filename="Terminal" lineNumbers cp .env.example .env ``` The template already contains the local Docker database URL and `NEXT_PUBLIC_SITE_URL`. Replace its example `BETTER_AUTH_SECRET` with a unique value before starting the application: Optional integrations may remain empty until you test them. Password signup does require `RESEND_API_KEY` and `EMAIL_FROM` because new accounts must verify their email. Use the [environment variables guide](/docs/starter-kits/pro-nextjs-drizzle/codebase/environment-variables) to select complete variable groups for email, Stripe, Google sign-in, AI, storage, Turnstile and Sentry. ## Accessing the Services - **PostgreSQL**: - Host: localhost - Port: 5432 - Username: postgres - Password: password - Database: database ## Running Database Migrations After setting up your database, you have two options: ### Option 1: Push schema directly (for initial setup) ```sh filename="Terminal" lineNumbers npm run db:push ``` ### Option 2: Generate and apply migrations ```sh filename="Terminal" lineNumbers npm run db:generate npm run db:migrate ``` ## Start Development Server Start the development server: ```sh filename="Terminal" lineNumbers npm run dev ``` Your application should now be running at `http://localhost:3000` with the local PostgreSQL database. ## Troubleshooting ### Database Connection Issues If you're having trouble connecting to PostgreSQL: 1. Verify the database is running: ```sh filename="Terminal" lineNumbers docker compose ps postgres ``` 2. Check the logs: ```sh filename="Terminal" lineNumbers docker compose logs postgres ``` 3. Verify your `DATABASE_URL` in `.env` matches your database configuration ### Port Already in Use If port 3000 is already in use, you can change it by setting the `PORT` environment variable: ```sh filename="Terminal" lineNumbers PORT=3002 npm run dev ``` Port `3001` is reserved by the included React Email preview command. If you change the application port, also update `NEXT_PUBLIC_SITE_URL`, OAuth callback URLs and the target used by Stripe webhook forwarding. ## Stopping the Services To stop all services: ```sh filename="Terminal" lineNumbers npm run docker:down ``` To stop and remove all data (including volumes): ```sh filename="Terminal" lineNumbers docker compose down -v ``` ## Additional Resources - [Docker Compose Documentation](https://docs.docker.com/compose/) - [PostgreSQL Documentation](https://www.postgresql.org/docs/) - [Next.js Documentation](https://nextjs.org/docs) --- ## MCP Server **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/codebase/mcp-server **Description**: Give coding assistants safe, repository-aware context with the local read-only MCP server. The starter kit includes a local [Model Context Protocol (MCP)](https://modelcontextprotocol.io/docs/getting-started/intro) server for compatible coding assistants. It gives an assistant structured, read-only context about the repository instead of relying on guessed paths, commands or database conventions. The server runs on your computer over standard input/output. Its MCP tools do not connect to PostgreSQL, execute package scripts, change source files, read environment files or make network requests. The startup command only compiles the server into the ignored `dist/` directory before connecting. ## Set up the server Install the project dependencies from the repository root: ```sh filename="Terminal" lineNumbers npm install ``` The checked-in `.mcp.json` is the project configuration used by Claude Code. It compiles the server before each start so a client cannot run stale generated output: ```json filename=".mcp.json" lineNumbers { "mcpServers": { "achromatic": { "type": "stdio", "command": "npm", "args": ["run", "--silent", "mcp:start"] } } } ``` You can compile the server without starting it as a separate validation step: ```sh filename="Terminal" lineNumbers npm run mcp:build ``` `npm run mcp:start` performs this compilation automatically and then starts the server. Because it uses stdio as its protocol transport, run it through an MCP client rather than expecting a browser page or HTTP port. ## Connect your coding assistant MCP clients use different project configuration filenames. Open the starter kit as the client workspace, install dependencies and use the matching setup below. ### Claude Code Claude Code discovers the checked-in `.mcp.json`. Review and approve the project server when prompted, then start a new session if it is not listed immediately. ### Cursor Create `.cursor/mcp.json`: ```json filename=".cursor/mcp.json" lineNumbers { "mcpServers": { "achromatic": { "command": "npm", "args": ["run", "--silent", "mcp:start"] } } } ``` ### Visual Studio Code Run **MCP: Add Server** from the command palette and save the stdio server to the workspace, or create `.vscode/mcp.json`: ```json filename=".vscode/mcp.json" lineNumbers { "servers": { "achromatic": { "type": "stdio", "command": "npm", "args": ["run", "--silent", "mcp:start"] } } } ``` ### Codex Create `.codex/config.toml`, trust the project and start Codex from the repository root: ```toml filename=".codex/config.toml" lineNumbers [mcp_servers.achromatic] command = "npm" args = ["run", "--silent", "mcp:start"] ``` For another client, add a local stdio server with command `npm`, arguments `run`, `--silent`, `mcp:start` and the starter kit repository root as its working directory. Restart the client or begin a new session after changing its configuration. Project-scoped MCP configuration can launch local commands. Review changes to `.mcp.json` and any client-specific MCP file before approving the server after a pull or branch switch, just as you would review changes to package scripts. ## What the server exposes The server provides 19 read-only tools grouped around common development tasks. Each tool returns both a text representation for broad client compatibility and a machine-readable structured result for clients that consume MCP structured content. A published output schema defines and validates the shared `structuredContent.result` envelope. Component and implementation lists return at most 250 entries, while searches return at most 50 matches. Use the available area and query filters to narrow a broad result before reading individual files. Limited tools expose `structuredContent.resultLimit`; when `reached` is `true`, narrow the request before assuming the result is complete. | Area | Available context | | -------------- | -------------------------------------------------------------------------------------------------------------- | | Project | Architecture, key package versions, security guardrails, package scripts and the supported validation sequence | | Components | Searchable UI and feature component paths, exported names and source | | Implementation | Searchable routes, configuration, hooks, core libraries, Zod schemas, tRPC source and shared types | | Documentation | A documentation index, full document reads and line-level search results | | Database | The current Drizzle schema, field metadata and constraints, checked-in migrations and task-specific workflows | The complete tool contract is: - Project: `get_project_overview`, `list_project_scripts`, `get_healthcheck` - Components: `list_components`, `search_components`, `read_component` - Implementation: `list_implementation_files`, `search_implementation`, `read_implementation_file` - Documentation: `list_documentation`, `search_documentation`, `read_documentation` - Database: `get_database_overview`, `read_database_schema`, `list_migrations`, `read_migration`, `list_migration_metadata`, `read_migration_metadata`, `get_database_workflow` It also publishes: - a project overview resource - the current database schema resource - a documentation index resource - a feature-planning prompt - a change-review prompt During the MCP handshake, the server also tells compatible clients to begin with project discovery, search before reading individual files, inspect existing components before creating UI and request the ORM-specific workflow before suggesting database commands. The prompts do not grant extra access. They guide the assistant to use the same read-only tools and to inspect implementation sources, tenant isolation, authorization, migrations, existing components, tests and documentation. ## Recommended workflow Use discovery tools before asking an assistant to implement a change: 1. Call `get_project_overview` to load the application boundaries and tenant guardrails. 2. Use `search_documentation` for the relevant product system. 3. Use `list_implementation_files` and `search_implementation` to find the relevant routes, configuration, core libraries, Zod schemas and tRPC procedures. 4. Call `list_components` before creating new interface code. 5. Use `get_database_overview` and `get_database_workflow` before changing the Drizzle schema. 6. Ask for `get_healthcheck` before handing the change back for review. For example: ```text Plan an organization audit log for this starter kit. Use the MCP project overview, documentation, implementation files, existing components and database workflow. Keep every query scoped by organizationId and include the tests and migration review steps. ``` This sequence keeps the assistant grounded in the current checkout. Tool output is still context, not permission to skip the authorization and validation rules in `AGENTS.md`. ## Safety boundary Repository reads are limited to generated lists of known documentation, components, implementation sources, schema and migration files. Implementation source reads are limited to `app/`, `config/`, `hooks/`, `lib/`, `schemas/`, `trpc/`, `types/` and selected root entry points such as `proxy.ts`. The server resolves real paths before checking repository containment, rejects symbolic links and oversized files, and excludes environment files. Database tools inspect checked-in source files only and never use `DATABASE_URL`. The local server intentionally has no source-write, shell, database or network tools. Its only filesystem write is the ignored `dist/` output created by the startup compiler. That makes it suitable for repository discovery and planning, not deployment or production administration. Returned source and documentation are context, not new user authority. A coding assistant should not execute an embedded instruction or command merely because it appears in a file. The server reinforces this boundary during the MCP handshake alongside the tenant, authorization and migration guardrails. ## Optional provider servers Provider-hosted MCP servers are separate from the local Achromatic server. Add only the services you need and review their permissions because their tools may read or change external systems. Current official endpoints include: - [Stripe MCP](https://docs.stripe.com/mcp) - [Vercel MCP](https://vercel.com/docs/ai-tooling/vercel-mcp) - [Linear MCP](https://linear.app/docs/mcp) Keep secrets out of tracked configuration. Prefer the provider's OAuth flow or reference an existing environment variable when a client supports environment interpolation. ## Verify the integration Run the focused MCP suite after changing the server or its configuration: ```sh filename="Terminal" lineNumbers npm run test:unit -- --run tests/mcp ``` The suite covers the repository file boundary, Drizzle parsing, tool, resource and prompt contracts, and the real stdio process started by the documented `npm run mcp:start` command. ## Troubleshooting ### The client cannot start the local server Run `npm install` from the repository root, then restart the client. The `mcp:start` script compiles generated files under `dist/` before every start. ### The server cannot locate the project Confirm that the client's working directory is the repository root. The server looks for `package.json` and the Drizzle schema before registering its tools. ### A tool refuses to read a path Use a path returned by the corresponding list tool. The server does not accept arbitrary repository paths. --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/codebase/overview **Description**: Learn more about the codebase and how it is structured. The Pro Next.js Drizzle starter kit is built as a single-repo Next.js application. This structure ensures efficient development and scalability, making it easy to manage all components of your application in one place. --- ## Updating the Kit **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/codebase/updating **Description**: Safely merge Achromatic updates into a customized Drizzle project. Achromatic ships updates through the private `pro-nextjs-drizzle` repository. Keep your product repository as `origin` and add Achromatic as a separate upstream source of updates. ## Add the Achromatic remote Run this once from your project root: ```sh filename="Terminal" lineNumbers git remote add achromatic https://github.com/achromaticlabs/pro-nextjs-drizzle.git git fetch achromatic ``` Confirm that `origin` still points to your product repository: ```sh filename="Terminal" lineNumbers git remote -v ``` Do not change the tracking branch for your product's main branch to the Achromatic repository. Your commits should continue to push to your own remote. ## Review an update before merging Start with a clean working tree and fetch the latest release: ```sh filename="Terminal" lineNumbers git status --short git fetch achromatic git log --oneline --decorate HEAD..achromatic/main git diff --stat HEAD...achromatic/main ``` Read the [Achromatic changelog](/changelog) and inspect changes that touch authentication, database migrations, billing, environment variables or deployment before merging them. ## Merge on a dedicated branch Create a branch from your current product state, then merge the upstream code: ```sh filename="Terminal" lineNumbers git switch -c update/achromatic git merge achromatic/main ``` Resolve conflicts in favor of your product requirements while preserving security fixes and new committed migrations. Do not regenerate or delete migration history just to make a merge clean. After resolving conflicts: ```sh filename="Terminal" lineNumbers npm install npm run typecheck npm run lint npm run test npm run build ``` Run the authenticated E2E suite against a disposable database when the update changes authentication, organizations, billing, credits, settings or the admin area: ```sh filename="Terminal" lineNumbers npm run test:e2e:setup npm run test:e2e ``` ## Apply database changes safely Review new migration files before applying them. Back up any database containing data you need, then run the kit's deployment migration command in each target environment: ```sh filename="Terminal" lineNumbers npm run db:migrate ``` The Drizzle kit applies the committed migrations supplied with each update. Generate a new migration only for schema changes made by your product, not for migration files already supplied by Achromatic. ## Finish the update Test the update branch in a preview environment. When it is ready, merge it into your product's main branch using your normal review workflow. If an update is too large to merge at once, review the upstream commits and cherry-pick a focused security or dependency fix. Record skipped commits so the same conflict is not investigated again during the next update. --- ## VS Code Setup **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/codebase/vscode **Description**: Configure VS Code for the repository's formatter, linter, Tailwind CSS and TypeScript. You can use any editor. The repository includes VS Code recommendations and workspace settings so contributors use the same formatter, lint fixes and local TypeScript version. When you first open the repository, accept the recommended extensions from `.vscode/extensions.json`. You can install them manually at any time. ## Oxc The [Oxc extension](https://marketplace.visualstudio.com/items?itemName=oxc.oxc-vscode) integrates Oxlint and Oxfmt. The workspace sets Oxc as the default formatter, formats on save and applies safe lint fixes on save. ## Tailwind CSS IntelliSense [Tailwind CSS IntelliSense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) adds completion, validation and hover previews for Tailwind classes. The workspace points it to `app/globals.css`, which is the Tailwind CSS entry point. ## TypeScript VS Code includes TypeScript language support. The workspace uses `node_modules/typescript/lib` so editor diagnostics match the TypeScript version installed by the repository. If VS Code prompts you to choose a TypeScript version, select **Use Workspace Version**. ## Included workspace behavior The settings in `.vscode/settings.json`: - format supported files with Oxfmt on save and paste - apply safe Oxlint fixes on save - enable type-aware Oxlint analysis - use the local TypeScript SDK - point Tailwind CSS IntelliSense at `app/globals.css` - exclude generated Next.js output from search - avoid auto-imports from unsupported Next.js entry points and `radix-ui` Treat the workspace files as shared project configuration. Discuss changes before committing personal editor preferences that affect every contributor. --- ## Configuration **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/configuration **Description**: Learn how to configure your application using the configuration files. The Pro Next.js Drizzle starter kit uses a modular configuration system that allows you to customize your application to your needs. Configuration is split into separate files in the `config/` directory, making it easy to manage different aspects of your application. ## Configuration Structure Configuration files are located in the `config/` directory: ```text filename="Project Structure" lineNumbers config/ ├── app.config.ts # App-wide settings (name, themes, site sections) ├── auth.config.ts # Authentication settings (redirects, CORS, signup) ├── billing.config.ts # Billing and plans configuration └── storage.config.ts # Storage bucket configuration ``` ## Using Configuration Configuration objects are exported from each file and can be imported where needed: ```typescript filename="lib/utils.ts" lineNumbers import { appConfig } from '@/config/app.config'; export function getAppName() { return appConfig.appName; } ``` ## Configuration Principles ### Type Safety All configuration objects use TypeScript's `satisfies` keyword to ensure type safety while preserving literal types. This gives you autocomplete and type checking. ### Environment Variables Configuration files can read from environment variables using the `env` object from `@/lib/env`. This keeps sensitive values out of your code. ### Modular Design Each configuration file focuses on a specific domain (app, auth, billing, storage), making it easy to find and modify settings. ### Default Values Configuration files provide sensible defaults, but you can override them to match your needs. ## Common Use Cases ### Disable Marketing Site If you want to deploy only the SaaS application without the marketing site: ```typescript filename="config/app.config.ts" lineNumbers export const appConfig = { // ... other config site: { marketing: { enabled: false // Disables marketing routes }, saas: { enabled: true } } }; ``` ### Disable SaaS Application If you want to deploy only the marketing site: ```typescript filename="config/app.config.ts" lineNumbers export const appConfig = { // ... other config site: { marketing: { enabled: true }, saas: { enabled: false // Disables SaaS routes } } }; ``` ### Gate the Starter Signup Page Set `enableSignup` to `false` to hide signup links in the starter auth cards and require a valid, pending invitation when someone opens the starter signup page: ```typescript filename="config/auth.config.ts" lineNumbers export const authConfig = { // ... other config enableSignup: false // Gate the starter signup page by invitation }; ``` This setting is a UI and page-route gate. It does not block direct requests to Better Auth's signup endpoint. Add server-side invitation validation in your auth layer before describing the application as strictly invitation-only. ### Gate the Starter Organization Creation Path To block non-admin organization creation through the starter's `trpc.organization.create` procedure: ```typescript filename="config/app.config.ts" lineNumbers export const appConfig = { // ... other config organizations: { allowUserCreation: false // Guard the starter tRPC creation procedure } }; ``` This setting does not configure Better Auth's organization endpoint. Passing `allowUserToCreateOrganization: false` to the Better Auth `organization` plugin disables creation through that endpoint for everyone. Use a function that returns `true` for allowed users if you want to preserve an admin exception. ## Next Steps Explore the individual configuration files to learn more about each area: - [App Configuration](/docs/starter-kits/pro-nextjs-drizzle/configuration/app) - App-wide settings - [Authentication Configuration](/docs/starter-kits/pro-nextjs-drizzle/configuration/auth) - Auth settings - [Billing Configuration](/docs/starter-kits/pro-nextjs-drizzle/configuration/billing) - Plans and pricing - [Storage Configuration](/docs/starter-kits/pro-nextjs-drizzle/configuration/storage) - Storage buckets --- ## App Configuration **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/configuration/app **Description**: Configure app name, site sections, themes, and organization settings. The app configuration file (`config/app.config.ts`) contains application-wide settings including the app name, site sections, theme configuration, and organization settings. ## Configuration File ```typescript filename="config/app.config.ts" lineNumbers import { getBaseUrl } from '@/lib/utils'; export const appConfig = { appName: 'Acme', description: `Acme's description`, baseUrl: getBaseUrl(), // Contact information (displayed on contact page) contact: { enabled: true, email: 'hello@yourdomain.com', phone: '(123) 456-7890', address: '123 Main St, San Francisco, CA' }, // Site sections - enable/disable major parts of the site site: { // Marketing website (landing page, blog, docs, etc.) // When disabled, all marketing routes redirect to /dashboard marketing: { enabled: true }, // SaaS application (dashboard, auth, etc.) // When disabled, all /dashboard and /auth routes redirect to marketing homepage saas: { enabled: true } }, // Theme configuration theme: { // Default theme for new users: "light", "dark", or "system" default: 'system' as const, // Available themes users can choose from available: ['light', 'dark'] as const }, // Organization settings organizations: { // Guard non-admin access to the starter tRPC creation procedure // This does not configure Better Auth's organization endpoint allowUserCreation: true }, // Pagination defaults pagination: { // Default page size for lists defaultLimit: 20, // Maximum allowed page size maxLimit: 100 } } satisfies AppConfig; ``` ## Configuration Options ### App Information - **`appName`**: The name of your application, displayed throughout the UI - **`description`**: A brief description of your application - **`baseUrl`**: The base URL of your application (automatically detected) ### Contact Information The `contact` object configures contact information displayed on the contact page: - **`enabled`**: Whether the contact form is enabled - **`email`**: Contact email address - **`phone`**: Contact phone number - **`address`**: Physical address ### Site Sections The `site` object controls which parts of your application are enabled: - **`marketing.enabled`**: Enable/disable the marketing website (landing page, blog, docs) - When disabled, all marketing routes redirect to `/dashboard` - **`saas.enabled`**: Enable/disable the SaaS application (dashboard, auth) - When disabled, all `/dashboard` and `/auth` routes redirect to marketing homepage ### Theme Configuration The `theme` object controls theme settings: - **`default`**: Default theme for new users (`"light"`, `"dark"`, or `"system"`) - **`available`**: Array of themes users can choose from ### Organization Settings The `organizations` object controls organization-related features: - **`allowUserCreation`**: Whether the starter's `trpc.organization.create` procedure accepts non-admin users - When `false`, that procedure still accepts platform admins - This does not configure Better Auth's organization endpoint ### Pagination The `pagination` object sets default pagination values: - **`defaultLimit`**: Default number of items per page - **`maxLimit`**: Maximum allowed items per page ## Use Cases ### Deploy Marketing Site Only To deploy only the marketing site without the SaaS application: ```typescript filename="config/app.config.ts" lineNumbers export const appConfig = { // ... other config site: { marketing: { enabled: true }, saas: { enabled: false // Disables SaaS routes } } }; ``` ### Deploy SaaS Application Only To deploy only the SaaS application without the marketing site: ```typescript filename="config/app.config.ts" lineNumbers export const appConfig = { // ... other config site: { marketing: { enabled: false // Disables marketing routes }, saas: { enabled: true } } }; ``` ### Gate the Starter Organization Creation Path To block non-admin creation through `trpc.organization.create`: ```typescript filename="config/app.config.ts" lineNumbers export const appConfig = { // ... other config organizations: { allowUserCreation: false // Guard the starter tRPC creation procedure } }; ``` Passing `allowUserToCreateOrganization: false` to the Better Auth `organization` plugin disables creation through that endpoint for everyone. Use a function that returns `true` for allowed users if you want to preserve the starter tRPC procedure's admin exception. ### Customize Theme Options To customize available themes: ```typescript filename="config/app.config.ts" lineNumbers export const appConfig = { // ... other config theme: { default: 'dark' as const, available: ['light', 'dark', 'system'] as const } }; ``` ## Type Definitions The configuration uses TypeScript types for type safety: ```typescript filename="config/app.config.ts" lineNumbers export type ContactConfig = { enabled: boolean; email: string; phone: string; address: string; }; export type SiteConfig = { marketing: { enabled: boolean; }; saas: { enabled: boolean; }; }; export type ThemeConfig = { default: 'light' | 'dark' | 'system'; available: readonly ('light' | 'dark')[]; }; export type OrganizationsConfig = { allowUserCreation: boolean; }; export type PaginationConfig = { defaultLimit: number; maxLimit: number; }; export type AppConfig = { appName: string; description: string; baseUrl: string; contact: ContactConfig; site: SiteConfig; theme: ThemeConfig; organizations: OrganizationsConfig; pagination: PaginationConfig; }; ``` ## Using the Configuration Import and use the configuration in your code: ```typescript filename="components/app-header.tsx" lineNumbers import { appConfig } from "@/config/app.config"; export function AppHeader() { return

{appConfig.appName}

; } ``` ```typescript filename="lib/pagination.ts" lineNumbers import { appConfig } from '@/config/app.config'; export function getDefaultLimit() { return appConfig.pagination.defaultLimit; } ``` --- ## Authentication Configuration **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/configuration/auth **Description**: Configure authentication settings, redirects, and CORS. The authentication configuration file (`config/auth.config.ts`) contains settings for authentication, session management, redirects, and CORS. ## Configuration File ```typescript filename="config/auth.config.ts" lineNumbers import { env } from '@/lib/env'; import { getBaseUrl } from '@/lib/utils'; const origins = Array.from( new Set( [ getBaseUrl(), env.NEXT_PUBLIC_SITE_URL, env.NEXT_PUBLIC_VERCEL_URL ? `https://${env.NEXT_PUBLIC_VERCEL_URL}` : undefined, env.NEXT_PUBLIC_VERCEL_BRANCH_URL ? `https://${env.NEXT_PUBLIC_VERCEL_BRANCH_URL}` : undefined, env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL ? `https://${env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL}` : undefined, env.NEXT_PUBLIC_NODE_ENV === 'development' ? 'http://localhost:3000' : undefined ].filter(Boolean) as string[] ) ); export const authConfig = { redirectAfterSignIn: '/dashboard', redirectAfterLogout: '/', sessionCookieMaxAge: 60 * 60 * 24 * 30, verificationExpiresIn: 60 * 60 * 24 * 14, minimumPasswordLength: 8, trustedOrigins: origins, // Controls signup links and the starter signup page // This does not block Better Auth's signup endpoint enableSignup: true, enableSocialLogin: true, enablePasskeys: true, cors: { allowedOrigins: [...origins, /^https:\/\/.*\.vercel\.app$/], allowedMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: [ 'Authorization', 'Content-Type', 'Accept', 'Origin', 'X-Requested-With', 'Access-Control-Request-Method', 'Access-Control-Request-Headers', 'X-CSRF-Token', 'Accept-Version', 'Content-Length', 'Content-MD5', 'Date', 'X-Api-Version', 'cf-connecting-ip', 'cf-ipcountry', 'cf-ray', 'cf-visitor', 'x-vercel-id', 'x-vercel-deployment-url', 'x-vercel-proxied-for', 'X-Forwarded-For', 'X-Forwarded-Host', 'X-Forwarded-Proto', 'X-Real-IP', 'Connection', 'Host', 'User-Agent', 'Referer' ], maxAge: 86_400 } } satisfies AuthConfig; ``` ## Configuration Options ### Redirects - **`redirectAfterSignIn`**: Where users are redirected after successful sign in (default: `"/dashboard"`) - **`redirectAfterLogout`**: Where users are redirected after logout (default: `"/"`) ### Session Management - **`sessionCookieMaxAge`**: Maximum age of the session cookie in seconds (default: 30 days) - **`verificationExpiresIn`**: How long email verification links are valid in seconds (default: 14 days) ### Password Requirements - **`minimumPasswordLength`**: Minimum password length required (default: `8`) ### Trusted Origins - **`trustedOrigins`**: Array of trusted origins for authentication requests - Automatically includes base URL, Vercel URLs, and localhost in development - Used for CSRF protection and secure authentication ### Signup and Login - **`enableSignup`**: Whether the starter auth cards show signup links and the signup page opens without a valid, pending invitation (default: `true`) - **`enableSocialLogin`**: Whether the starter UI shows social login buttons (default: `true`) - **`enablePasskeys`**: Whether the passkey plugin endpoints, sign-in button and account-management card are available (default: `true`) ### CORS Configuration The `cors` object configures Cross-Origin Resource Sharing: - **`allowedOrigins`**: Array of allowed origins (includes trusted origins and Vercel preview URLs) - **`allowedMethods`**: HTTP methods allowed in CORS requests - **`allowedHeaders`**: HTTP headers allowed in CORS requests - **`maxAge`**: Maximum age for preflight requests in seconds (default: 24 hours) ## Use Cases ### Gate the Starter Signup Page To hide signup links in the starter auth cards and gate the starter signup page by invitation: ```typescript filename="config/auth.config.ts" lineNumbers export const authConfig = { // ... other config enableSignup: false }; ``` The signup page validates that the supplied invitation exists, is pending and has not expired. `enableSignup` is not a server-side policy for Better Auth's signup endpoint. A production invitation-only product must add server-side invitation validation and review every enabled signup path, including OAuth. ### Hide Social Login Buttons To hide the starter's OAuth buttons and connected accounts card: ```typescript filename="config/auth.config.ts" lineNumbers export const authConfig = { // ... other config enableSocialLogin: false }; ``` This flag does not unregister the configured OAuth provider or disable Better Auth's OAuth routes. Remove the provider from `lib/auth/index.ts` and its credentials from the environment if you want to disable the provider itself. ### Disable Passkeys To remove both passkey UI and server endpoints: ```typescript filename="config/auth.config.ts" lineNumbers export const authConfig = { // ... other config enablePasskeys: false }; ``` Unlike the social-login display flag, `enablePasskeys` conditionally registers the Better Auth passkey plugin. Existing passkey rows can remain in the database if you temporarily disable the feature. ### Custom Redirects To customize redirect paths: ```typescript filename="config/auth.config.ts" lineNumbers export const authConfig = { // ... other config redirectAfterSignIn: '/dashboard', redirectAfterLogout: '/auth/sign-in' }; ``` ### Adjust Session Duration To change session cookie duration: ```typescript filename="config/auth.config.ts" lineNumbers export const authConfig = { // ... other config sessionCookieMaxAge: 60 * 60 * 24 * 7 // 7 days instead of 30 }; ``` ### Stricter Password Requirements To require longer passwords: ```typescript filename="config/auth.config.ts" lineNumbers export const authConfig = { // ... other config minimumPasswordLength: 12 // Require 12 characters minimum }; ``` ## Type Definitions The configuration uses TypeScript types for type safety: ```typescript filename="config/auth.config.ts" lineNumbers export type CorsConfig = { allowedOrigins: (string | RegExp)[]; allowedMethods: string[]; allowedHeaders: string[]; maxAge: number; }; export type AuthConfig = { redirectAfterSignIn: string; redirectAfterLogout: string; sessionCookieMaxAge: number; verificationExpiresIn: number; minimumPasswordLength: number; trustedOrigins: string[]; enableSignup: boolean; enableSocialLogin: boolean; cors: CorsConfig; }; ``` ## Using the Configuration Import and use the configuration in your code: ```typescript filename="lib/auth/redirects.ts" lineNumbers import { authConfig } from '@/config/auth.config'; export function getSignInRedirect() { return authConfig.redirectAfterSignIn; } ``` ```typescript filename="lib/auth/validation.ts" lineNumbers import { authConfig } from '@/config/auth.config'; export function validatePassword(password: string) { if (password.length < authConfig.minimumPasswordLength) { throw new Error( `Password must be at least ${authConfig.minimumPasswordLength} characters` ); } } ``` --- ## Billing Configuration **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/configuration/billing **Description**: Configure plans, pricing, and credit packages. The billing configuration file (`config/billing.config.ts`) contains settings for billing features, subscription plans, pricing, and credit packages for AI features. ## Configuration File The billing configuration is extensive and includes: - **Billing settings**: Enable/disable billing, default currency - **Plans**: Subscription plans with features, limits, and pricing - **Credit packages**: One-time credit purchases for AI features - **Credit costs**: Per-model pricing for AI usage ```typescript filename="config/billing.config.ts" lineNumbers import { env } from '@/lib/env'; export const billingConfig = { // Enable/disable billing feature enabled: true, // Default currency defaultCurrency: 'usd', // Plans configuration plans: { // Free tier - no Stripe price needed free: { id: 'free', name: 'Free', description: 'Get started with basic features', isFree: true, features: [ 'Up to 3 team members', 'Basic analytics', 'Community support', '1 GB storage' ], limits: { maxMembers: 3, maxStorage: 1 // GB } }, // Pro plan - main paid tier pro: { id: 'pro', name: 'Pro', description: 'For growing teams', recommended: true, features: [ 'Unlimited team members', 'Advanced analytics', 'Priority support', '100 GB storage', 'Custom integrations', 'API access' ], limits: { maxMembers: -1, // unlimited maxStorage: 100 // GB }, prices: [ { id: 'pro_monthly', stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY ?? '', type: 'recurring', interval: 'month', intervalCount: 1, amount: 2900, // $29.00 in cents currency: 'usd', seatBased: true, // Per-seat pricing trialDays: 14 }, { id: 'pro_yearly', stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY ?? '', type: 'recurring', interval: 'year', intervalCount: 1, amount: 27800, // $278.00 in cents currency: 'usd', seatBased: true, trialDays: 14 } ] }, // Lifetime deal - one-time order lifetime: { id: 'lifetime', name: 'Lifetime', description: 'Pay once, use forever', features: [ 'All Pro features', 'Lifetime updates', 'Priority support for 1 year', '100 GB storage' ], limits: { maxMembers: -1, maxStorage: 100 }, prices: [ { id: 'lifetime_once', stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_LIFETIME ?? '', type: 'one_time', amount: 49900, // $499.00 in cents currency: 'usd' } ] } } } satisfies BillingConfig; ``` Storage limits are not enforced The current storage code does not read maxStorage or meter stored bytes. Treat storage amounts as plan metadata until you add usage tracking and server-side enforcement. ## Configuration Options ### Billing Settings - **`enabled`**: Enable/disable billing feature (default: `true`) - **`defaultCurrency`**: Default currency for pricing (default: `"usd"`) ### Plans Each plan in the `plans` object has: - **`id`**: Unique identifier for the plan - **`name`**: Display name - **`description`**: Plan description - **`features`**: Array of feature strings - **`limits`**: Plan limits (members, storage) - **`prices`**: Array of price configurations #### Plan Types - **Free plans**: Set `isFree: true`, no prices needed - **Paid plans**: Include `prices` array with Stripe price IDs - **Enterprise plans**: Set `isEnterprise: true`, typically no prices (contact sales) #### Price Configuration Each price has: - **`id`**: Unique price identifier - **`stripePriceId`**: Stripe Price ID from your Stripe dashboard - **`type`**: `"recurring"` or `"one_time"` - **`amount`**: Price in cents - **`currency`**: Currency code - **`interval`**: For recurring: `"month"`, `"year"`, `"week"`, or `"day"` - **`intervalCount`**: Number of intervals - **`seatBased`**: Whether pricing is per-seat (optional) - **`trialDays`**: Trial period in days (optional) ### Credit Packages Credit packages are configured separately for one-time purchases: ```typescript filename="config/billing.config.ts" lineNumbers export const creditPackages = [ { id: 'credits_starter', name: 'Starter', description: 'Great for trying out AI features', credits: 10_000, bonusCredits: 0, priceAmount: 999, // $9.99 in cents currency: 'usd', stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER ?? '', popular: false }, { id: 'credits_basic', name: 'Basic', description: 'For regular AI usage', credits: 50_000, bonusCredits: 5_000, // 10% bonus priceAmount: 3999, // $39.99 currency: 'usd', stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC ?? '', popular: true } ]; ``` ### Credit Costs Credit costs define pricing per AI model: ```typescript filename="config/billing.config.ts" lineNumbers export const creditCosts = { 'gpt-4o-mini': { input: 1, // credits per 1K input tokens output: 6 // credits per 1K output tokens }, 'gpt-4o': { input: 25, output: 100 } // ... more models } as const; ``` ## Use Cases ### Add a New Plan To add a new subscription plan: ```typescript filename="config/billing.config.ts" lineNumbers export const billingConfig = { // ... other config plans: { // ... existing plans business: { id: 'business', name: 'Business', description: 'For larger teams', features: [ 'Everything in Pro', 'Advanced security', 'Dedicated support', '500 GB storage' ], limits: { maxMembers: -1, maxStorage: 500 }, prices: [ { id: 'business_monthly', stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_BUSINESS_MONTHLY ?? '', type: 'recurring', interval: 'month', intervalCount: 1, amount: 9900, // $99.00 currency: 'usd', seatBased: true } ] } } }; ``` `NEXT_PUBLIC_STRIPE_PRICE_BUSINESS_MONTHLY` is a customization placeholder. It is not defined by the shipped kit. Before using it, add the variable to the client schema and `runtimeEnv` mapping in `lib/env.ts`, then add it to `.env.example` and your deployment environment. ### Disable Billing To disable billing entirely: ```typescript filename="config/billing.config.ts" lineNumbers export const billingConfig = { enabled: false // ... other config }; ``` ### Add Enterprise Plan To add an enterprise plan (contact sales): ```typescript filename="config/billing.config.ts" lineNumbers export const billingConfig = { // ... other config plans: { // ... existing plans enterprise: { id: 'enterprise', name: 'Enterprise', description: 'For large organizations', isEnterprise: true, features: [ 'Everything in Pro', 'Dedicated account manager', 'Custom SLA', 'Unlimited storage', 'SSO / SAML' ], limits: { maxMembers: -1, maxStorage: -1 } } } }; ``` ## Type Definitions The configuration uses TypeScript types for type safety: ```typescript filename="config/billing.config.ts" lineNumbers export type PriceConfig = { id: string; stripePriceId: string; amount: number; currency: string; } & ( | { type: 'recurring'; interval: 'month' | 'year' | 'week' | 'day'; intervalCount: number; seatBased?: boolean; trialDays?: number; } | { type: 'one_time'; } ); export type PlanLimits = { maxMembers: number; // -1 for unlimited maxStorage: number; // in GB, -1 for unlimited }; export type Plan = FreePlan | PaidPlan | EnterprisePlan; export type BillingConfig = { enabled: boolean; defaultCurrency: string; plans: Record; }; ``` ## Using the Configuration Import and use the configuration in your code: ```typescript filename="lib/billing/plans.ts" lineNumbers import { billingConfig } from '@/config/billing.config'; export function getPlanById(planId: string) { return billingConfig.plans[planId]; } export function getAllPlans() { return Object.values(billingConfig.plans); } ``` ```typescript filename="lib/billing/credits.ts" lineNumbers import { creditCosts, creditPackages } from '@/config/billing.config'; export function getCreditPackageById(id: string) { return creditPackages.find((pkg) => pkg.id === id); } export function calculateCreditsForModel( modelId: string, inputTokens: number, outputTokens: number ) { const costs = creditCosts[modelId as keyof typeof creditCosts]; const inputCost = Math.ceil((inputTokens / 1000) * costs.input); const outputCost = Math.ceil((outputTokens / 1000) * costs.output); return inputCost + outputCost; } ``` ## Next Steps For more information on billing, see: - [Billing Overview](/docs/starter-kits/pro-nextjs-drizzle/billing/overview) - [Plans](/docs/starter-kits/pro-nextjs-drizzle/billing/plans) - [Subscriptions](/docs/starter-kits/pro-nextjs-drizzle/billing/subscriptions) - [Credits](/docs/starter-kits/pro-nextjs-drizzle/billing/credits) --- ## Storage Configuration **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/configuration/storage **Description**: Configure the image bucket used by the shipped storage integration. The shipped configuration contains one bucket name for user avatars and organization logos. ## Current configuration ```typescript filename="config/storage.config.ts" lineNumbers import { env } from '@/lib/env'; export const storageConfig = { bucketNames: { images: env.NEXT_PUBLIC_IMAGES_BUCKET_NAME ?? '' } } satisfies StorageConfig; export type StorageConfig = { bucketNames: { images: string; }; }; ``` Set the value with: ```env filename=".env" lineNumbers NEXT_PUBLIC_IMAGES_BUCKET_NAME="my-images-bucket" ``` The name is included in client-generated `/storage/{bucket}/{key}` URLs, so it is intentionally public configuration. Storage credentials must remain in the server-only `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY` variables. ## Adding another bucket Additional document, video or tenant-specific buckets are a customization. Adding a property to `storageConfig` alone is not enough. You must also: 1. Add and map the environment variable in `lib/env.ts`. 2. Extend the `StorageConfig` type. 3. Decide which authenticated procedures may sign uploads for the bucket. 4. Add server-side object key and ownership rules. 5. Add an authorized download route for private data. 6. Configure provider credentials, CORS and lifecycle policies. Do not add a private bucket to the shipped public `/storage/[...path]` handler unless its objects are intended to be accessible to anyone who knows their key. ## Related guides - [Storage Overview](/docs/starter-kits/pro-nextjs-drizzle/storage/overview) - [Setup](/docs/starter-kits/pro-nextjs-drizzle/storage/setup) - [Upload Files](/docs/starter-kits/pro-nextjs-drizzle/storage/upload) - [Access Files](/docs/starter-kits/pro-nextjs-drizzle/storage/access) --- ## Favicons & Icons **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/customization/favicons **Description**: Learn how to generate and integrate favicons and app icons for your project. ## Generating a favicon 1. Visit [Favicon Generator](https://www.favicon-generator.org/). 2. Upload an image (recommended size: **at least 512×512px** for optimal resizing). 3. Click on **Create Favicon** ## Downloading Click **Download** to save the generated files. ## Copying and overwriting 1. Select all downloaded files, **excluding** `browserconfig.xml` and `manifest.json`. 2. Copy and paste the files into the `public` directory. ## Updating metadata Update the favicon references in `app/layout.tsx`: ```tsx filename="app/layout.tsx" lineNumbers export const metadata = { icons: { icon: '/favicon.ico', apple: '/apple-touch-icon.png' } // ... }; ``` Note that sometimes it takes time for the browser to reflect favicon changes. Try clearing your browser cache or doing a hard refresh. --- ## Fonts **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/customization/fonts **Description**: Learn how to change fonts using Google Fonts or custom fonts. You can change the font in `app/layout.tsx`. The starter kit currently uses: ```tsx filename="app/layout.tsx" lineNumbers import { Literata } from 'next/font/google'; import { GeistSans } from 'geist/font/sans'; const literata = Literata({ subsets: ['latin'], variable: '--font-literata' }); export default function RootLayout({ children }) { return ( {/* ... */} ); } ``` To change to a different Google Font, for example `Inter`: ```tsx filename="app/layout.tsx" lineNumbers import { Inter } from 'next/font/google'; import { GeistSans } from 'geist/font/sans'; const inter = Inter({ subsets: ['latin'], variable: '--font-inter' }); export default function RootLayout({ children }) { return ( {/* ... */} ); } ``` Or use a custom font: ```tsx filename="app/layout.tsx" lineNumbers import localFont from 'next/font/local'; const customFont = localFont({ src: './fonts/custom-font.woff2', display: 'swap' }); ``` ## Font Variables Font variables are automatically available via the `variable` prop. You can use them in your CSS: ```css filename="app/globals.css" lineNumbers :root { --font-sans: var(--font-geist-sans); --font-literata: var(--font-literata); } ``` Then use them in your components: ```tsx filename="components/example.tsx" lineNumbers
This uses Geist Sans
This uses Literata
``` --- ## Naming & Branding **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/customization/naming **Description**: Learn how to change the app name, description, and metadata throughout your application. ## App Name and Description The app name and description are used throughout the application. Update them in the configuration file: ```typescript filename="config/app.config.ts" lineNumbers export const appConfig = { appName: 'Your App Name', description: 'A fantastic SaaS to make your life easier.', baseUrl: 'https://yourdomain.com' // ... }; ``` This configuration is used in: - Navigation and headers - Email templates - SEO metadata - Social sharing ## Metadata The metadata in `app/layout.tsx` automatically uses values from `appConfig`: ```typescript filename="app/layout.tsx" lineNumbers import { appConfig } from '@/config/app.config'; export const metadata: Metadata = { metadataBase: new URL(appConfig.baseUrl), title: { absolute: appConfig.appName, default: appConfig.appName, template: `%s | ${appConfig.appName}` }, description: appConfig.description, openGraph: { type: 'website', locale: 'en_US', siteName: appConfig.appName, title: appConfig.appName, description: appConfig.description }, twitter: { card: 'summary_large_image', title: appConfig.appName, description: appConfig.description } }; ``` ## Package.json Update the name and description in `package.json`: ```json filename="package.json" lineNumbers { "name": "your-app-name", "version": "1.0.0", "description": "Your app description", "author": "Your Name", "license": "MIT" // ... } ``` ## Environment Variables The `baseUrl` in `appConfig` uses `getBaseUrl()` which reads from `NEXT_PUBLIC_SITE_URL` if set, otherwise falls back to the request URL. You can set it in your `.env`: ```env filename=".env" lineNumbers NEXT_PUBLIC_SITE_URL=https://yourdomain.com ``` ## Email Branding Update email templates to reflect your branding. Email templates are located in `lib/email/templates/`: ```typescript filename="lib/email/templates/welcome-email.tsx" lineNumbers export function WelcomeEmail({ name }: { name: string }) { return ( Welcome to Your App Name! Welcome to Your App Name! Hi {name}, Welcome to Your App Name! We're excited to have you. ); } ``` --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/customization/overview **Description**: Learn how to customize your application to match your brand and requirements. Customization is essential for making the starter kit your own. This section covers everything you need to personalize your application, from branding and theming to fonts and icons. --- ## Theming & Styling **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/customization/theming **Description**: Learn how to customize colors, themes, and styling with Tailwind CSS and shadcn/ui. The starter kit uses [Tailwind CSS](https://tailwindcss.com) for styling and [shadcn/ui](https://ui.shadcn.com) components backed by [Base UI](https://base-ui.com/). This gives you complete control over the visual appearance of your application while keeping accessible interaction behavior in unstyled primitives. ## Color System The color system is based on CSS variables defined in `app/globals.css`: ```css filename="app/globals.css" lineNumbers :root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; --secondary: 210 40% 96.1%; --secondary-foreground: 222.2 47.4% 11.2%; --muted: 210 40% 96.1%; --muted-foreground: 215.4 16.3% 46.9%; --accent: 210 40% 96.1%; --accent-foreground: 222.2 47.4% 11.2%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 222.2 84% 4.9%; --radius: 0.5rem; } .dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; /* ... dark mode colors */ } ``` ## Tailwind Configuration The starter kit uses Tailwind CSS v4, which uses CSS-based configuration instead of a config file. All configuration is done in `app/globals.css`: ```css filename="app/globals.css" lineNumbers @import 'tailwindcss'; @import 'tw-animate-css'; /* Specify content paths */ @source "./**/*.{ts,tsx}"; @source "../components/**/*.{ts,tsx}"; @source "../lib/**/*.{ts,tsx}"; @source "../hooks/**/*.{ts,tsx}"; /* Custom dark mode variant */ @custom-variant dark (&:is(.dark *)); /* Define theme values */ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); } ``` To add new content paths, add more `@source` directives. To customize theme values, add them to the `@theme inline` block. ## shadcn/ui Components The starter kit uses the Base UI variant of [shadcn/ui](https://ui.shadcn.com). The source lives in `components/ui/`, so you can change the styles and composition directly. Base UI supplies the accessible behavior for dialogs, menus, selects, tooltips and other interactive primitives. The repository's `components.json` sets `base-nova` as its shadcn style. The CLI therefore installs compatible Base UI components instead of Radix variants. ### Installing Components You can install additional components using the shadcn CLI: ```bash filename="Terminal" lineNumbers npx shadcn@latest add button npx shadcn@latest add card npx shadcn@latest add dialog ``` ### Customizing Components Components are located in `components/ui/` and can be customized directly: ```tsx filename="components/ui/button.tsx" lineNumbers import type * as React from 'react'; import { Button as ButtonPrimitive } from '@base-ui/react/button'; export type ButtonProps = ButtonPrimitive.Props & { loading?: boolean; }; function Button({ children, loading = false, ...props }: ButtonProps) { return ( {loading ? 'Loading…' : children} ); } export { Button }; ``` Base UI uses the `render` prop for composition. When an installed component documents `render`, prefer it over Radix's former `asChild` pattern: ```tsx filename="components/example-dialog.tsx" lineNumbers }>Open dialog ``` ## Dark Mode Dark mode is automatically handled by the theme system. Users can toggle between light and dark themes: ```tsx filename="components/theme-toggle.tsx" lineNumbers 'use client'; import { useTheme } from 'next-themes'; import { Button } from '@/components/ui/button'; export function ThemeToggle() { const { theme, setTheme } = useTheme(); return ( ); } ``` ## Global Styles Customize global styles in `app/globals.css`: ```css filename="app/globals.css" lineNumbers @import 'tailwindcss'; @layer base { * { @apply border-border; } body { @apply bg-background text-foreground; } } ``` ## Custom Themes You can create custom themes by modifying the CSS variables: ```css filename="app/globals.css" lineNumbers [data-theme='custom'] { --primary: 142 76% 36%; --primary-foreground: 355 100% 97%; /* ... other custom colors */ } ``` Then apply the theme: ```tsx filename="app/layout.tsx" lineNumbers {/* ... */} ``` --- ## Database **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/database **Description**: Learn how to manage your database, schema, and migrations with Drizzle ORM. The Pro Next.js Drizzle starter kit uses **Drizzle ORM** with **PostgreSQL**. Drizzle provides a lightweight, fully type-safe way to interact with your database. ## Client Setup The database client is initialized in `lib/db/client.ts` and exported from `lib/db/index.ts`. It uses the `postgres` driver for high performance. ```typescript filename="lib/db/client.ts" lineNumbers import { drizzle } from 'drizzle-orm/node-postgres'; import { env } from '@/lib/env'; import * as schema from './schema'; export const db = drizzle(env.DATABASE_URL, { schema }); ``` The client is then exported from `lib/db/index.ts`: ```typescript filename="lib/db/index.ts" lineNumbers export * from './client'; export * from './schema'; ``` ## Schema Definition Your database schema is defined in `lib/db/schema/`. We recommend splitting your schema into multiple files for better organization: - `tables.ts`: Table definitions. - `enums.ts`: Enum definitions. - `relations.ts`: Relation definitions. ### Example Table Definition ```typescript filename="lib/db/schema/tables.ts" lineNumbers import { boolean, index, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; import { organizationTable } from './organization'; export const leadTable = pgTable( 'lead', { id: uuid('id').primaryKey().defaultRandom(), organizationId: uuid('organization_id') .notNull() .references(() => organizationTable.id, { onDelete: 'cascade' }), firstName: text('first_name').notNull(), lastName: text('last_name').notNull(), email: text('email').notNull(), createdAt: timestamp('created_at', { withTimezone: true }) .notNull() .defaultNow() }, (table) => [index('lead_organization_id_idx').on(table.organizationId)] ); ``` ## Migrations ### Commands | Command | Description | | --------------------- | --------------------------------------------- | | `npm run db:generate` | Generate migration from schema changes | | `npm run db:migrate` | Apply pending migrations | | `npm run db:studio` | Open Drizzle Studio GUI | | `npm run db:push` | Push schema directly (dev only, no migration) | ### Migration Workflow 1. **Edit schema** in `lib/db/schema/tables.ts`. 2. **Generate migration**: `npm run db:generate`. 3. **Review migration** in `lib/db/migrations/`. 4. **Apply migration**: `npm run db:migrate`. ## Multi-Tenancy **Critical**: Always filter by `organizationId` for tenant data to ensure data isolation. ```typescript filename="trpc/routers/lead-router.ts" lineNumbers const leads = await db.query.leadTable.findMany({ where: eq(leadTable.organizationId, ctx.organization.id) }); ``` ## Transactions Use transactions for related operations that must be atomic. ```typescript filename="lib/actions/widget.ts" lineNumbers const result = await db.transaction(async (tx) => { await tx .delete(subscriptionItemTable) .where(eq(subscriptionItemTable.subscriptionId, subId)); const items = await tx .insert(subscriptionItemTable) .values(newItems) .returning(); return items; }); ``` --- ## Client **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/database/client **Description**: Learn how to use basic database operations with the Drizzle database client. The database client is powered by Drizzle, a type-safe and lightweight ORM. Drizzle provides a functional approach to database interactions, allowing you to query, create, update, and delete records with full TypeScript support. This guide outlines core operations with the database client, such as querying, creating, updating, and deleting records. For in-depth details and advanced usage, visit the [Drizzle ORM documentation](https://orm.drizzle.team/docs/overview). ## Select records To retrieve records from the database, you can use the `db.select()` method provided by Drizzle. Here's an example of how to query all records from a table: ```typescript filename="query-records.ts" lineNumbers import { desc } from 'drizzle-orm'; import { db } from '@/lib/db'; import { userTable } from '@/lib/db/schema'; const allUsers = await db .select() .from(userTable) .orderBy(desc(userTable.createdAt)); ``` You can also filter records using the `where` clause: ```typescript filename="query-filtered-records.ts" lineNumbers import { eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { userTable } from '@/lib/db/schema'; const user = await db .select() .from(userTable) .where(eq(userTable.email, 'user@example.com')); ``` To limit the number of results, use the `limit` method: ```typescript filename="query-limited-records.ts" lineNumbers import { desc } from 'drizzle-orm'; import { db } from '@/lib/db'; import { userTable } from '@/lib/db/schema'; const recentUsers = await db .select() .from(userTable) .orderBy(desc(userTable.createdAt)) .limit(10); ``` ## Create record To insert a new record into the database, you can use the `db.insert()` method and specify the table and values to be saved: ```typescript filename="insert-record.ts" lineNumbers import { db } from '@/lib/db'; import { userTable } from '@/lib/db/schema'; const user = await db .insert(userTable) .values({ name: 'John Doe', email: 'john.doe@gmail.com' }) .returning(); ``` By default, Drizzle returns the full row. You can modify the return behavior using `returning({})` to select specific fields. ## Update record To update an existing record, use the `db.update()` method, specifying the table, conditions, and data to update: ```typescript filename="update-record.ts" lineNumbers import { eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { userTable } from '@/lib/db/schema'; const updatedUser = await db .update(userTable) .set({ name: 'John Doe Updated', email: 'john.doe.updated@gmail.com' }) .where(eq(userTable.id, 'some-uuid')) .returning(); ``` You can use `returning({})` to limit the returned fields and make the operation more efficient. ## Delete record To delete a record from the database, use the `db.delete()` method and specify the table and conditions: ```typescript filename="delete-record.ts" lineNumbers import { eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { userTable } from '@/lib/db/schema'; const deletedUser = await db .delete(userTable) .where(eq(userTable.id, 'some-uuid')) .returning(); ``` This will remove the record from the database permanently. You can use or omit `returning({})` to limit the fields returned after the deletion if needed. --- ## Migrations **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/database/migrations **Description**: Learn how to manage database migrations with Drizzle. Migrations are a way to version control your database schema changes. They allow you to track, apply, and rollback database changes in a controlled and reproducible manner. ## Migration Workflow The typical migration workflow consists of three steps: ### 1. Generate Migration After updating your schema files, generate a migration: ```sh filename="Terminal" lineNumbers npm run db:generate ``` This command: - Analyzes your schema files in `lib/db/schema/` - Compares them with the current database state - Generates SQL migration files in `lib/db/migrations/` - Creates a migration metadata file Migration Files Migration files are stored in lib/db/migrations/ and should be committed to version control. Each migration has a unique name and contains the SQL statements needed to apply the changes. ### 2. Review Migration Before applying, review the generated migration file to ensure it contains the expected changes: ```sql filename="lib/db/migrations/xxxx_add_phone_field.sql" lineNumbers ALTER TABLE "users" ADD COLUMN "phone" varchar(32); ``` You can edit the migration file if needed, but be careful - only modify the SQL if you understand the implications. ### 3. Apply Migration To apply the migration to your database, run: ```sh filename="Terminal" lineNumbers npm run db:migrate ``` This command: - Executes the migration SQL against your database - Updates the migration history table - Ensures your database schema matches your code ## Migration Commands ### Generate Migration Generate a new migration from schema changes: ```sh filename="Terminal" lineNumbers npm run db:generate ``` This creates migration files but doesn't apply them to the database. ### Apply Migration Apply pending migrations to the database: ```sh filename="Terminal" lineNumbers npm run db:migrate ``` This runs all pending migrations in order. ### Push Changes (Development Only) For rapid prototyping, push schema changes directly without creating a migration: ```sh filename="Terminal" lineNumbers npm run db:push ``` Warning db:push is useful for development but should not be used in production. Always use migrations (db:generate and{' '} db:migrate) for production deployments. ### Regenerate Migrations If you need to regenerate migrations (e.g., after restoring from a backup): ```sh filename="Terminal" lineNumbers npm run db:regenerate ``` This restores the migration files from the main branch and regenerates new migrations based on your current schema. ## Production Migrations For production deployments, follow these steps: 1. **Generate migrations locally** - Run `npm run db:generate` after schema changes 2. **Review migrations** - Check the generated SQL files 3. **Test on staging** - Apply migrations to a staging database first 4. **Commit migrations** - Commit migration files to version control 5. **Deploy** - Run `npm run db:migrate` as part of your deployment process Best Practice Always test migrations on a staging database that mirrors production before deploying to production. ## Migration Best Practices 1. **Always generate migrations** for production deployments 2. **Review migration files** before applying them 3. **Test migrations** on a staging database first 4. **Commit migration files** to version control 5. **Never edit existing migrations** - create new ones instead 6. **Use descriptive migration names** - The migration name should describe what it does 7. **Keep migrations small** - Break large changes into multiple migrations 8. **Don't delete migrations** - Even if you rollback, keep the migration files ## Migration History Drizzle tracks migration history in a special table. You can view which migrations have been applied by checking your database. The migration history table stores: - Migration name - Applied timestamp - Migration SQL ## Troubleshooting ### Migration Fails If a migration fails: 1. **Check the error message** - It usually indicates what went wrong 2. **Review the migration SQL** - Ensure the SQL is correct 3. **Check database state** - Verify the current database schema 4. **Fix the migration** - Edit the migration file if needed 5. **Re-run** - Try applying the migration again ### Migration Already Applied If you see an error that a migration is already applied: 1. **Check migration history** - Verify which migrations have been applied 2. **Skip if safe** - If the migration was already applied, you can skip it 3. **Regenerate if needed** - Use `npm run db:regenerate` if migrations are out of sync ### Schema Out of Sync If your schema files don't match your database: 1. **Review schema files** - Ensure they're up to date 2. **Check migration history** - See which migrations have been applied 3. **Generate new migration** - Run `npm run db:generate` to create a migration that brings the database in sync 4. **Apply migration** - Run `npm run db:migrate` to apply the changes ## Advanced Topics ### Custom Migration SQL You can write custom SQL in migration files for complex changes: ```sql filename="lib/db/migrations/xxxx_custom_migration.sql" lineNumbers -- Custom migration SQL ALTER TABLE "users" ADD COLUMN "full_name" varchar(255); UPDATE "users" SET "full_name" = "name" || ' ' || "last_name"; ``` ### Data Migrations Migrations can also include data transformations: ```sql filename="lib/db/migrations/xxxx_data_migration.sql" lineNumbers -- Data migration example UPDATE "users" SET "status" = 'active' WHERE "status" IS NULL; ``` ### Rollback Migrations While Drizzle doesn't have built-in rollback support, you can: 1. **Create a new migration** - Write a migration that reverses the changes 2. **Manual rollback** - Manually revert the database changes 3. **Restore from backup** - Restore the database to a previous state ## Migration Files Structure Migration files are organized as follows: ```text filename="lib/db/migrations/" lineNumbers lib/db/migrations/ ├── meta/ │ ├── _journal.json # Migration journal │ └── 0000_snapshot.json # Schema snapshot ├── 0001_initial.sql # Initial migration ├── 0002_add_users.sql # Add users table └── 0003_add_phone_field.sql # Add phone field ``` Each migration file contains the SQL needed to apply that specific change. --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/database/overview **Description**: Learn how to interact with the database in the starter kit. The database serves as the backbone for storing data, handling queries and making sure users get what they need fast. ## Drizzle The starter kit uses Drizzle as its data access solution. Why choose Drizzle? Drizzle is a fast and efficient ORM built for relational databases like PostgreSQL and MySQL. It prioritizes type safety, flexibility and performance, making it a strong choice for modern applications. ## Database driver The project uses PostgreSQL as the default database provider, ensuring seamless integration. Drizzle also supports MySQL, SQLite, and other relational databases. If you want to use a different database than PostgreSQL, you will need to configure the database provider in the database client configuration. For a comprehensive list of supported database drivers, visit [Drizzle's Documentation](https://orm.drizzle.team/docs/get-started). ## Drizzle Studio Drizzle's visual database editor allows you to view and edit your database records. You can open it with: ```sh filename="Terminal" lineNumbers npm run db:studio ``` Make sure `.env` has a correct `DATABASE_URL` defined. --- ## Schema **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/database/schema **Description**: Learn how to update your database schema and migrate changes with Drizzle. The database schema is defined in Drizzle schema files located in `lib/db/schema/`. The schema is organized into multiple files for better maintainability, with each file representing a domain or feature. ## Schema Structure The schema is typically organized as follows: ```text filename="lib/db/schema/" lineNumbers lib/db/schema/ ├── index.ts # Main schema export ├── users.ts # User table definition ├── organizations.ts # Organization tables └── ... # Other domain schemas ``` ## Updating the Schema To update your database schema, edit the appropriate schema file. More information about Drizzle schema definitions can be found in the [Drizzle documentation](https://orm.drizzle.team/docs/schemas). ### Example: Adding a Field For example, to add a new `phone` field to the `users` table: ```typescript filename="lib/db/schema/users.ts" lineNumbers import { pgTable, varchar } from 'drizzle-orm/pg-core'; export const users = pgTable('users', { id: varchar('id', { length: 255 }).primaryKey(), email: varchar('email', { length: 255 }).notNull().unique(), name: varchar('name', { length: 255 }), phone: varchar('phone', { length: 32 }) // New field // ... other fields }); ``` The field is defined as an optional string with a maximum length of 32 characters. Now you need to create a migration to apply this change to the database. ## Migration Workflow Drizzle uses migrations to track and apply database schema changes. The migration workflow consists of three steps: ### 1. Generate Migration Create a new migration by running: ```sh filename="Terminal" lineNumbers npm run db:generate ``` This command: - Analyzes your schema files - Compares them with the current database state - Generates SQL migration files in `lib/db/migrations/` - Creates a migration metadata file Migration Files Migration files are stored in lib/db/migrations/ and are version controlled. Each migration has a unique name and contains the SQL statements needed to apply the changes. ### 2. Review Migration Before applying, review the generated migration file to ensure it contains the expected changes: ```sql filename="lib/db/migrations/xxxx_add_phone_field.sql" lineNumbers ALTER TABLE "users" ADD COLUMN "phone" varchar(32); ``` ### 3. Apply Migration To apply the migration to your database, run: ```sh filename="Terminal" lineNumbers npm run db:migrate ``` This command: - Executes the migration SQL against your database - Updates the migration history table - Ensures your database schema matches your code ## Alternative: Push Changes (Development Only) For rapid prototyping during development, you can push schema changes directly without creating a migration: ```sh filename="Terminal" lineNumbers npm run db:push ``` Warning db:push is useful for development but should not be used in production. Always use migrations (db:generate and{' '} db:migrate) for production deployments. ## Migration Best Practices 1. **Always generate migrations** for production deployments 2. **Review migration files** before applying them 3. **Test migrations** on a staging database first 4. **Commit migration files** to version control 5. **Never edit existing migrations** - create new ones instead ## Schema Relationships Drizzle supports defining relationships between tables. For example: ```typescript filename="lib/db/schema/users.ts" lineNumbers import { relations } from 'drizzle-orm'; import { organizations } from './organizations'; export const usersRelations = relations(users, ({ many }) => ({ organizations: many(organizations) })); ``` For more information on relationships, see the [Drizzle relations documentation](https://orm.drizzle.team/docs/relations). ## Regenerating Migrations If you need to regenerate migrations (e.g., after restoring from a backup), you can use: ```sh filename="Terminal" lineNumbers npm run db:regenerate ``` This restores the migration files from the main branch and regenerates new migrations based on your current schema. --- ## Studio **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/database/studio **Description**: Learn how to use Drizzle Studio to view and interact with your database. Drizzle Studio is a visual database editor that allows you to view and edit your database records directly in your browser. ## Start Drizzle Studio To start Drizzle Studio, run the following command from the root of your project: ```sh filename="Terminal" lineNumbers npm run db:studio ``` Drizzle Studio will open at https://local.drizzle.studio Make sure your `DATABASE_URL` is correctly set in your `.env` file before starting Drizzle Studio. ## Using Drizzle Studio Drizzle Studio allows you to: - **View all your database tables and data** - Browse through all tables and see their records - **Edit records directly in the browser** - Update, create, or delete records without writing SQL - **Run queries and see results** - Execute SQL queries and view results - **Inspect your database schema** - See the structure of your tables, columns, and relationships ## Features ### Browse Tables Navigate through all your database tables using the sidebar. Click on any table to view its data. ### Edit Records - **Add new records** - Click the "Add" button to create new records - **Edit existing records** - Click on any cell to edit its value - **Delete records** - Select records and delete them ### Run Queries Use the query editor to run custom SQL queries against your database. This is useful for: - Testing complex queries - Debugging data issues - Performing bulk operations ### Inspect Schema View the schema of your tables, including: - Column names and types - Primary keys and foreign keys - Indexes - Constraints ## Alternative Database Tools While Drizzle Studio is convenient, you can also use other database GUI tools: - [TablePlus](https://tableplus.com/) - Modern database management tool - [DBeaver](https://dbeaver.io/) - Universal database tool - [pgAdmin](https://www.pgadmin.org/) - PostgreSQL administration tool - [Postico](https://eggerapps.at/postico2/) - PostgreSQL client for macOS ## Troubleshooting ### Studio won't start If Drizzle Studio won't start, check: 1. **Database connection** - Ensure `DATABASE_URL` is set correctly in `.env` 2. **Port availability** - Make sure port `4983` is not already in use 3. **Database running** - Verify your database server is running ### Can't see tables If you can't see your tables in Drizzle Studio: 1. **Check schema** - Ensure your schema files are correct 2. **Run migrations** - Make sure all migrations have been applied 3. **Refresh** - Try refreshing the browser --- ## Deployment **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/deployment **Description**: Learn how to deploy your Pro Next.js Drizzle application to production. We recommend deploying your application to **Vercel** for the most direct Next.js workflow, but you can use any provider that supports Node.js and PostgreSQL. ## Prerequisites Before deploying, ensure you have: - A GitHub repository with your project code. - A PostgreSQL database (Neon, Supabase, Railway, etc.). - The provider accounts required by the features you keep, such as Stripe, Resend or an S3-compatible storage service. - A unique production authentication secret. ## Deploying to Vercel 1. Push your code to a GitHub repository. 2. Import the project into [Vercel](https://vercel.com). 3. Add the required environment variables for Production. Add them to Preview only when preview deployments should connect to separate preview services. 4. Apply the committed database migrations once from CI or a one-off release task. 5. Deploy the same commit that you validated locally. ### Environment Variables Start with the complete [environment variable guide](/docs/starter-kits/pro-nextjs-drizzle/codebase/environment-variables). The minimum application and database values are: ```bash filename="Vercel Settings" lineNumbers BETTER_AUTH_SECRET="generate-a-unique-production-secret" DATABASE_URL="postgresql://user:pass@host:5432/db?sslmode=require" NEXT_PUBLIC_SITE_URL="https://your-app.com" ``` Add provider variables only for the integrations you enable. Never copy live secrets into variables prefixed with `NEXT_PUBLIC_`. ## Database Migrations Both kits expose the same production migration command: ```sh filename="Terminal" lineNumbers npm run db:migrate ``` Run it once against the production `DATABASE_URL` before the new application revision receives traffic. A CI release job or a one-off task on your hosting provider is suitable. Do not add migrations to `npm run build` and do not run them from every application replica at startup. Keep migration files in version control and review them with the code that depends on the schema change. Use development migration commands only while authoring a migration locally. ## SSL and Database Connections Most production database providers (like Neon or Supabase) require SSL. Ensure your `DATABASE_URL` includes `?sslmode=require`. ```ini filename=".env" lineNumbers DATABASE_URL="postgresql://user:pass@ep-xxx.region.aws.neon.tech/neondb?sslmode=require" ``` ## Post-Deployment Checklist Treat the first production deployment as a release, not only a successful build. Complete each applicable check before sending customers to the app. ### Application and database - [ ] Run `npm run typecheck`, `npm run lint`, `npm run test:unit -- --run` and `npm run build` against the release commit. The explicit `--run` keeps Vitest non-interactive on a developer machine and in CI. - [ ] Run production migrations once and confirm the expected schema exists. - [ ] Confirm the production database has backups and a tested restore procedure. - [ ] Verify `NEXT_PUBLIC_SITE_URL` exactly matches the canonical production origin. - [ ] Verify the custom domain, HTTPS certificate, redirects, `robots.txt` and sitemap. ### Authentication and email - [ ] Create a new account and complete email verification on the production domain. - [ ] Complete password reset and confirm its link returns to the production app. - [ ] Update Google OAuth origins and callback URLs for the production domain. - [ ] Confirm `EMAIL_FROM` uses a verified domain and replies go to a monitored address. - [ ] Test organization invitations with a second email address. ### Billing - [ ] Replace every Stripe test key and Price ID with its live-mode value. - [ ] Configure the production webhook endpoint at `https://your-domain.com/api/webhooks/stripe`. - [ ] Subscribe the endpoint only to events handled by the shipped webhook route. - [ ] Complete a real or controlled live-mode purchase, then verify the local order or subscription state. - [ ] Open the customer portal and verify cancellation or plan-change behavior for your product. ### Storage, monitoring and operations - [ ] Restrict storage credentials to the required bucket and object operations. - [ ] Upload and display an avatar or organization logo from the production domain. - [ ] Confirm Sentry receives a controlled test error without exposing secrets or personal data. - [ ] Confirm production logs use the intended level and do not contain credentials or tokens. - [ ] Add uptime monitoring for the application and any business-critical webhook path. - [ ] Document who receives billing, authentication and infrastructure alerts. Features you have disabled do not need their provider checks. Do not configure production credentials for integrations the application does not use. --- ## Docker **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/deployment/docker **Description**: Learn how to deploy your application as a Docker container. Deploying your application as a Docker container gives you control over the server environment, better privacy, potential cost savings, and flexibility to customize your setup. It can also improve performance compared to serverless platforms by removing cold starts. ## Setup Next.js for Docker Deployment Configure Next.js to build as a standalone app for containerization. Update your `next.config.ts`: ```typescript filename="next.config.ts" lineNumbers import type { NextConfig } from 'next'; const nextConfig: NextConfig = { // ... other config output: 'standalone' }; export default nextConfig; ``` ## Create Dockerfile Create a `Dockerfile` in the root of your project: ```dockerfile filename="Dockerfile" lineNumbers FROM node:22.21.1-alpine AS base # Install dependencies only when needed FROM base AS deps RUN apk add --no-cache libc6-compat WORKDIR /app # Copy package files COPY package.json package-lock.json* ./ RUN npm ci # Rebuild the source code only when needed FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . # Build the application RUN npm run build # Production image, copy all the files and run next FROM base AS runner WORKDIR /app ENV NODE_ENV production RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs # Copy the standalone build COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static USER nextjs EXPOSE 3000 ENV PORT 3000 ENV HOSTNAME "0.0.0.0" CMD ["node", "server.js"] ``` ## Create .dockerignore Create a `.dockerignore` file in the root: ```text filename=".dockerignore" lineNumbers Dockerfile .dockerignore node_modules npm-debug.log README.md .next .git .env*.local ``` ## Build and Run Locally Test your Docker image locally: ```sh filename="Terminal" lineNumbers docker build -t my-app . docker run -p 3000:3000 --env-file .env my-app ``` ## Deploy to Any Platform You can now deploy this Docker image to any platform that supports Docker: - **Fly.io** - See [Fly.io deployment guide](/docs/starter-kits/pro-nextjs-drizzle/deployment/fly) - **Railway** - See [Railway deployment guide](/docs/starter-kits/pro-nextjs-drizzle/deployment/railway) - **Render** - See [Render deployment guide](/docs/starter-kits/pro-nextjs-drizzle/deployment/render) - **AWS ECS/Fargate** - Use AWS container services - **Google Cloud Run** - Serverless container platform - **DigitalOcean App Platform** - Managed container hosting - **Your own server** - Deploy to any VPS with Docker ## Environment Variables Make sure to set all required environment variables when running the container: ```sh filename="Terminal" lineNumbers docker run -p 3000:3000 \ -e DATABASE_URL="postgresql://..." \ -e BETTER_AUTH_SECRET="..." \ -e NEXT_PUBLIC_SITE_URL="https://your-app.com" \ my-app ``` Or use an environment file: ```sh filename="Terminal" lineNumbers docker run -p 3000:3000 --env-file .env.production my-app ``` ## Database Migrations Generate and review migration files during development: ```sh filename="Terminal" lineNumbers npm run db:generate ``` Commit the generated files in `lib/db/migrations/` with the schema change. In production, apply those committed migrations once as a release step: ```sh filename="Terminal" lineNumbers npm run db:migrate ``` Run the command in CI before replacing the application containers or use a one-off migration task provided by your container platform. Wait for it to succeed before directing traffic to the new revision. Keep migrations out of the image buildA Docker build should not connect to or mutate a production database. Avoid running migrations in the Dockerfile. Also avoid starting the migration command independently in every application replica because several containers may start at the same time. ## Troubleshooting ### SSL Errors If you encounter SSL errors like `ERR_SSL_PACKET_LENGTH_TOO_LONG`, ensure your `DATABASE_URL` includes SSL parameters: ```ini filename=".env" lineNumbers DATABASE_URL="postgresql://user:pass@host:5432/db?sslmode=require" ``` ### Port Configuration Make sure the port in your Dockerfile matches your Next.js configuration and the port you expose when running the container. ### Build Failures If the build fails, check: - Node.js version is 22.21.1, matching `package.json` - All dependencies are properly installed - Every environment variable required by the application build is available - `output: 'standalone'` remains enabled in `next.config.ts` For the complete container workflow, including runtime secrets, health checks and reverse proxies, read [Self-Host a Next.js SaaS With Docker](/blog/self-host-nextjs-saas-with-docker). --- ## Fly.io **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/deployment/fly **Description**: Learn how to deploy your application to Fly.io. [Fly.io](https://fly.io) is a platform for running full-stack apps and databases close to your users. It is well suited to Docker-based deployments. Review [Fly.io pricing](https://fly.io/docs/about/pricing/) before provisioning resources. ## Why Fly.io? - **Global edge network** - Deploy close to your users - **Docker-based** - Full control over your container - **Usage-based pricing** - Choose resources for your application's requirements - **Simple scaling** - Scale up or down easily - **Database support** - Can provision PostgreSQL ## Prerequisites Before deploying to Fly.io, you need to: 1. **Set up Docker** - Follow the [Docker deployment guide](/docs/starter-kits/pro-nextjs-drizzle/deployment/docker) to create a Dockerfile 2. **Install Fly CLI** - Install the [Fly CLI](https://fly.io/docs/flyctl/install/) ## Deploying to Fly.io ### 1. Create Fly.io Account Sign up for a free account at [fly.io](https://fly.io). ### 2. Login to Fly CLI ```sh filename="Terminal" lineNumbers fly auth login ``` ### 3. Launch Your App From your project root, run: ```sh filename="Terminal" lineNumbers fly launch ``` The CLI will: - Detect your Dockerfile - Ask for an app name - Ask if you want to set up a PostgreSQL database - Create a `fly.toml` configuration file ### 4. Configure fly.toml The generated `fly.toml` should look like this: ```toml filename="fly.toml" lineNumbers app = "your-app-name" primary_region = "iad" [build] [env] PORT = "3000" [http_service] internal_port = 3000 force_https = true auto_stop_machines = true auto_start_machines = true min_machines_running = 0 processes = ["app"] [[vm]] memory = "256mb" cpu_kind = "shared" cpus = 1 ``` ### 5. Set Environment Variables Set your environment variables: ```sh filename="Terminal" lineNumbers fly secrets set DATABASE_URL="postgresql://..." fly secrets set BETTER_AUTH_SECRET="..." fly secrets set NEXT_PUBLIC_SITE_URL="https://your-app.fly.dev" fly secrets set STRIPE_SECRET_KEY="..." fly secrets set RESEND_API_KEY="..." ``` Or set multiple at once: ```sh filename="Terminal" lineNumbers fly secrets set DATABASE_URL="..." BETTER_AUTH_SECRET="..." NEXT_PUBLIC_SITE_URL="..." ``` ### 6. Deploy Deploy your application: ```sh filename="Terminal" lineNumbers fly deploy ``` Your app will be available at `https://your-app.fly.dev`. ## Database Migrations Run migrations after deployment: ```sh filename="Terminal" lineNumbers fly ssh console -C "npm run db:migrate" ``` Or use `db:push` for development: ```sh filename="Terminal" lineNumbers fly ssh console -C "npm run db:push" ``` Or add to your Dockerfile's entrypoint script. ## Provision Database If you didn't provision a database during `fly launch`: ```sh filename="Terminal" lineNumbers fly postgres create --name your-app-db fly postgres attach your-app-db ``` This will automatically set the `DATABASE_URL` secret. ## Custom Domain To use a custom domain: 1. Add your domain: ```sh filename="Terminal" lineNumbers fly domains add your-domain.com ``` 2. Follow DNS configuration instructions 3. Update `NEXT_PUBLIC_SITE_URL` secret ## Scaling Scale your app: ```sh filename="Terminal" lineNumbers # Scale to 2 instances fly scale count 2 # Scale memory fly scale vm shared-cpu-1x --memory 512 ``` ## Monitoring View logs and metrics: ```sh filename="Terminal" lineNumbers # View logs fly logs # View metrics fly status ``` ## Troubleshooting ### SSL Errors If you encounter SSL errors, ensure your `DATABASE_URL` includes SSL parameters: ```ini filename=".env" lineNumbers DATABASE_URL="postgresql://user:pass@host:5432/db?sslmode=require" ``` ### Build Failures - Check build logs: `fly logs` - Verify Dockerfile is correct - Ensure all dependencies are installed ### Database Connection - Verify `DATABASE_URL` secret is set: `fly secrets list` - Check database is attached: `fly postgres list` - Ensure database is in the same region --- ## Netlify **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/deployment/netlify **Description**: Learn how to deploy your application to Netlify. [Netlify](https://www.netlify.com/) is a popular platform for deploying web applications. While it's optimized for static sites, you can deploy Next.js applications using Netlify's Next.js runtime. ## Why Netlify? - **Easy deployment** - Connect GitHub and deploy automatically - **Free tier available** - Great for getting started - **Automatic HTTPS** - SSL certificates included - **Edge functions** - Run serverless functions at the edge - **Preview deployments** - Automatic previews for PRs ## Deploying to Netlify ### 1. Create Netlify Account Sign up for a free account at [netlify.com](https://www.netlify.com/). ### 2. Create New Site 1. Click **"Add new site"** → **"Import an existing project"** 2. Connect your Git provider (GitHub, GitLab, or Bitbucket) 3. Select your repository ### 3. Configure Build Settings Netlify will auto-detect Next.js, but verify these settings: - **Build command**: `npm run build` - **Publish directory**: `.next` - **Framework preset**: Next.js For Next.js standalone output, you may need to adjust: - **Build command**: `npm run build && npm run export` (if using static export) - Or use Netlify's Next.js runtime (recommended) ### 4. Add Environment Variables Add the deployment variables. `DATABASE_URL` and `BETTER_AUTH_SECRET` are required. Add the Stripe and Resend variables when those features are enabled: 1. Go to **Site settings** → **Environment variables** 2. Add variables from your `.env`: ```env filename="Netlify Environment Variables" lineNumbers DATABASE_URL=postgresql://... BETTER_AUTH_SECRET=... NEXT_PUBLIC_SITE_URL=https://your-app.netlify.app STRIPE_SECRET_KEY=... NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=... STRIPE_WEBHOOK_SECRET=... NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY=price_... NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=price_... NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PRO=price_... RESEND_API_KEY=... EMAIL_FROM=... ``` ### 5. Deploy Click **"Deploy site"** and Netlify will: - Install dependencies - Build your application - Deploy to their CDN Your app will be available at `https://your-app.netlify.app`. ## Netlify Configuration Create a `netlify.toml` in your project root: ```toml filename="netlify.toml" lineNumbers [build] command = "npm run build" publish = ".next" [build.environment] NODE_VERSION = "22.21.1" [[plugins]] package = "@netlify/plugin-nextjs" ``` ## Database Migrations Netlify doesn't support running migrations during build. You have a few options: 1. **Run migrations manually** before deploying 2. **Use a build plugin** to run migrations 3. **Run migrations via API route** (not recommended for production) ## Custom Domain To use a custom domain: 1. Go to **Domain settings** → **Add custom domain** 2. Follow DNS configuration instructions 3. Update `NEXT_PUBLIC_SITE_URL` environment variable ## Functions Region For better performance, select the region closest to your database: 1. Go to **Site configuration** → **Build & deploy** → **Functions** 2. Select the **Functions region** closest to your database 3. Redeploy your site ## Preview Deployments Netlify automatically creates preview deployments for: - Pull requests - Branch pushes - Merge commits Each preview gets its own URL for testing. ## Environment Variables by Context Netlify supports different environment variables for: - **Production** - Production deployments - **Deploy previews** - Preview deployments - **Branch deploys** - Branch-specific deployments ## Troubleshooting ### Build Failures - Check build logs in the Netlify dashboard - Verify Node.js version (set in `netlify.toml`) - Ensure all dependencies are in `package.json` ### Function Timeouts - Netlify Functions have a 10-second timeout on free tier - Upgrade to Pro for longer timeouts - Optimize your API routes ### Database Connection Issues - Verify `DATABASE_URL` is set correctly - Check if database requires SSL (add `?sslmode=require`) - Ensure database allows connections from Netlify's IPs ### Environment Variables - Verify variables are set in the correct context - Redeploy after adding new variables - Check for typos in variable names --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/deployment/overview **Description**: Learn how to deploy your applications. You can deploy the app to any hosting provider that supports Node.js. Since Next.js is developed by Vercel, deploying to Vercel offers the most seamless and optimized developer experience. ## Choose a hosting model The starter kit ships as one Next.js service. It does not include a separately deployed API server or persistent worker process. | Model | Good fit | You operate | | ----------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------ | | Vercel or another serverless Next.js host | Automatic previews, managed scaling and the smallest operations surface | Environment variables, database migrations and provider configuration | | A managed container platform | A portable image, longer-running requests and more runtime control | Image builds, health checks, scaling and release migrations | | A self-hosted container or VPS | Infrastructure control and predictable host resources | TLS, reverse proxy, patching, restarts, monitoring, backups and capacity | Choose based on the operations you are prepared to own, not only the initial deployment cost. If the product needs durable background work, use a managed background provider or operate a separate worker service. Do not depend on a Next.js web instance remaining alive after an HTTP response. Apply committed database migrations once from CI or a one-off release task. Starting multiple web replicas must not race to apply the same migration. ## Production checklist Complete these steps before sending production traffic to a new environment: 1. Create an empty PostgreSQL database and set its production `DATABASE_URL`. 2. Add the required server and browser variables from the [environment variable guide](/docs/starter-kits/pro-nextjs-drizzle/codebase/environment-variables). 3. Replace the development `BETTER_AUTH_SECRET` with a unique production value. 4. Add `https://yourdomain.com/api/auth/callback/google` to the Google OAuth client if Google sign-in is enabled. 5. Configure the Stripe webhook endpoint at `https://yourdomain.com/api/webhooks/stripe` if billing is enabled. 6. Apply the committed database migrations once as a release step: ```sh filename="Terminal" lineNumbers npm run db:migrate ``` 7. Build the same revision that will be deployed: ```sh filename="Terminal" lineNumbers npm run build ``` 8. Verify sign-in, email delivery, organization access and one billing flow in the deployed environment before announcing the release. ## Public launch checklist A successful build only proves that the application compiled. Complete this second pass before directing customers to it: - Connect the final domain, set `NEXT_PUBLIC_SITE_URL` to its HTTPS URL and redeploy so generated links and authentication callbacks use that origin. - Replace the starter name, logo, contact details, legal text and sample marketing content with your own product information. - Verify the sender domain and review the authentication and invitation email templates using real inboxes outside your company domain. - Enable automated database backups and perform a restore rehearsal before the database contains customer data. - Configure provider budgets and alerts for every usage-based service, including OpenAI, email, storage and monitoring. - Test a failed payment, canceled subscription and Stripe webhook retry in test mode, not only a successful checkout. - Confirm that a non-admin user cannot open admin routes or another organization's resources. - Check the privacy policy and terms against the data and providers your deployed product actually uses. Give preview deployments their own database and provider credentials. Never point an untrusted branch or pull request at the production database, Stripe account or billable AI project. ## Control third-party spend Treat every server-side provider key as access to a billable account. Use a separate provider project or account for each environment so a development or demo incident cannot consume the production budget. For the included AI chat: 1. Set an OpenAI project budget and provider-side usage alerts before adding `OPENAI_API_KEY` to production. 2. Keep model selection restricted to the allowlist in `config/billing.config.ts`. 3. Configure application credits deliberately. Organization credits limit what the product permits, but they do not replace the OpenAI project budget. 4. Do not fund an unrestricted key for an anonymous public demo. Disable live generation or add a durable per-user and per-IP limiter first. 5. Monitor provider usage after launch and keep a documented way to revoke the key quickly. The starter kits check organization credit balance before AI generation and deduct actual usage afterward. They do not ship a generic distributed request-frequency limiter. Add one backed by shared durable storage before exposing a billable endpoint to untrusted traffic. Run the migration command once in CI or as a one-off release task. Do not run it independently from every application container when several replicas may start at the same time. To learn more about deployment, explore the following guide: --- ## Railway **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/deployment/railway **Description**: Learn how to deploy your application to Railway. [Railway](https://railway.com/) is a modern platform that makes it easy to deploy full-stack applications. It offers a generous free tier and seamless integration with GitHub. ## Why Railway? - **Simple deployment** - Connect your GitHub repo and deploy in minutes - **Free tier available** - Great for testing and MVPs - **Automatic HTTPS** - SSL certificates handled automatically - **Database included** - Can provision PostgreSQL directly - **Environment variables** - Easy management through the dashboard ## Deploying to Railway ### 1. Create Railway Account Sign up for a free account at [railway.com](https://railway.com/). ### 2. Create New Project 1. Click **"New Project"** in the Railway dashboard 2. Select **"Deploy from GitHub repo"** 3. Connect your GitHub account if prompted 4. Select your repository ### 3. Configure Build Settings Railway will auto-detect Next.js, but you can verify these settings: - **Build Command**: `npm run build` - **Start Command**: `npm start` - **Root Directory**: `/` (root of your project) ### 4. Add Environment Variables Add the deployment variables in the Railway dashboard. `DATABASE_URL` and `BETTER_AUTH_SECRET` are required. Add the Stripe and Resend variables when those features are enabled: 1. Go to your project → **Variables** tab 2. Add variables from your `.env`: ```env filename="Railway Environment Variables" lineNumbers DATABASE_URL=postgresql://... BETTER_AUTH_SECRET=... NEXT_PUBLIC_SITE_URL=https://your-app.railway.app STRIPE_SECRET_KEY=... NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=... STRIPE_WEBHOOK_SECRET=... NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY=price_... NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=price_... NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PRO=price_... RESEND_API_KEY=... EMAIL_FROM=... ``` ### 5. Provision Database (Optional) Railway can provision a PostgreSQL database for you: 1. Click **"New"** → **"Database"** → **"Add PostgreSQL"** 2. Railway will automatically set the `DATABASE_URL` environment variable 3. Configure the pre-deploy migration command before releasing the web service ### 6. Deploy Railway will automatically: - Install dependencies - Build your application - Deploy to their infrastructure Your app will be available at `https://your-app.railway.app`. ## Database Migrations Keep the build command as `npm run build`. In the web service settings, set the **Pre-Deploy Command** to: ```sh filename="Railway Pre-Deploy Command" lineNumbers npm run db:migrate ``` Railway runs this command in a separate container after the build and before the new deployment starts. The command receives the service environment variables, including `DATABASE_URL`. A non-zero exit stops the deployment. For a controlled one-off migration, you can run the same script from a trusted local checkout with Railway's production variables: ```sh filename="Terminal" lineNumbers railway run npm run db:migrate ``` Do not append migrations to `npm run build` and do not run them from every web replica at startup. ## Custom Domain To use a custom domain: 1. Go to **Settings** → **Domains** 2. Click **"Add Domain"** 3. Follow the DNS configuration instructions 4. Update `NEXT_PUBLIC_SITE_URL` to your custom domain ## Environment-Specific Variables Railway supports environment-specific variables: - **Production** - Used for production deployments - **Preview** - Used for preview deployments (from PRs) - **Development** - Used for local development with Railway CLI ## Monitoring Railway provides: - **Logs** - View real-time application logs - **Metrics** - CPU, memory, and network usage - **Deployments** - View deployment history ## Troubleshooting ### Build Failures - Check build logs in the Railway dashboard - Ensure all dependencies are in `package.json` - Verify Node.js 22.21.1 is active, matching the version in `package.json` ### Database Connection Issues - Verify `DATABASE_URL` is set correctly - Check if database requires SSL (add `?sslmode=require`) - Ensure database is accessible from Railway's IPs ### Environment Variables Not Loading - Verify variables are set in the correct environment - Check for typos in variable names - Redeploy after adding new variables --- ## Render **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/deployment/render **Description**: Learn how to deploy your application to Render. [Render](https://render.com) is a managed cloud platform for deploying web services and PostgreSQL databases from a Git repository. ## Why Render? - **Git-based deployment** - Build automatically from your production branch - **Managed HTTPS** - Connect a custom domain with managed TLS - **Database support** - Provision PostgreSQL or connect an external database - **Release controls** - Run migrations with a pre-deploy command on supported plans ## Deploying to Render ### 1. Create Render Account Sign up for a free account at [render.com](https://render.com). ### 2. Create New Web Service 1. Click **"New +"** in the Render dashboard 2. Select **"Web Service"** 3. Choose **"Build and deploy from a Git repository"** 4. Connect your GitHub account if prompted 5. Select your repository ### 3. Configure Service Set the following configuration: - **Name**: Your application name - **Region**: Choose closest to your users - **Branch**: `main` or your production branch - **Root Directory**: `/` (leave empty if root) - **Runtime**: `Node` - **Build Command**: `npm run build` - **Start Command**: `npm start` ### 4. Add Environment Variables Add the deployment variables. `DATABASE_URL` and `BETTER_AUTH_SECRET` are required. Add the Stripe and Resend variables when those features are enabled: 1. Scroll to **"Environment Variables"** section 2. Add variables from your `.env`: ```env filename="Render Environment Variables" lineNumbers DATABASE_URL=postgresql://... BETTER_AUTH_SECRET=... NEXT_PUBLIC_SITE_URL=https://your-app.onrender.com STRIPE_SECRET_KEY=... NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=... STRIPE_WEBHOOK_SECRET=... NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY=price_... NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=price_... NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC=price_... NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PRO=price_... RESEND_API_KEY=... EMAIL_FROM=... ``` ### 5. Select Plan Choose a plan that supports the uptime, compute and deployment features your application needs. A testing plan can be suitable for evaluation, but use an always-on service for production and confirm that your selected plan supports the pre-deploy migration command described below. ### 6. Deploy Click **"Create Web Service"** and Render will: - Install dependencies - Build your application - Deploy to their infrastructure Your app will be available at `https://your-app.onrender.com`. ## Database Migrations Keep the build command as `npm run build`. On a paid Render service, set the **Pre-Deploy Command** to: ```sh filename="Render Pre-Deploy Command" lineNumbers npm run db:migrate ``` Render runs this after a successful build and before the new revision goes live. If your plan does not support pre-deploy commands, run the same command once from a trusted release environment against the production `DATABASE_URL` before deploying the application revision. Do not use `npm run db:push` in production and do not run migrations from every web-service replica at startup. ## Provision Database (Optional) Render can provision a PostgreSQL database: 1. Click **"New +"** → **"PostgreSQL"** 2. Configure database settings 3. Render will automatically set `DATABASE_URL` 4. Link the database to your web service ## Custom Domain To use a custom domain: 1. Go to **Settings** → **Custom Domains** 2. Add your domain 3. Follow DNS configuration instructions 4. Update `NEXT_PUBLIC_SITE_URL` to your custom domain ## Auto-Deploy Render automatically deploys when you push to your connected branch. You can: - Enable/disable auto-deploy in settings - Set up manual deploys - Configure deploy hooks ## Monitoring Render provides: - **Logs** - Real-time application logs - **Metrics** - CPU, memory usage - **Events** - Deployment history ## Troubleshooting ### Service Sleeping (Free Tier) The free tier service sleeps after 15 minutes of inactivity. To prevent this: - Upgrade to a paid plan - Use a service like [UptimeRobot](https://uptimerobot.com) to ping your app ### Build Failures - Check build logs in the Render dashboard - Verify Node.js 22.21.1 is selected, matching the version in `package.json` - Ensure all dependencies are in `package.json` ### Database Connection Issues - Verify `DATABASE_URL` is set correctly - Check if database requires SSL (add `?sslmode=require`) - Ensure database and web service are in the same region ### Environment Variables - Verify variables are set correctly - Redeploy after adding new variables - Check for typos in variable names --- ## Vercel **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/deployment/vercel **Description**: Learn how to deploy on Vercel. Deploy the Pro Next.js Drizzle starter kit as a standard Next.js project on [Vercel](https://vercel.com). The repository is a single application, so it does not require monorepo root-directory or custom framework settings. ## Deploying to Vercel Vercel is the easiest way to deploy Next.js apps. It's the company behind Next.js and has first-class support for Next.js. ### Setup Vercel account To host your project on Vercel you first have to [create an account](https://vercel.com/signup). ### Connect your git repository After signing up you will be prompted to import a git repository. Select the git provider of your project and connect your git account with Vercel. Now you will see a list of all your projects. Select the project you want to deploy and click on the **Import** button. ### Configure project In the **Configure Project** view expand the **Environment Variables** section and add the following variables one by one (you can copy them from the `.env` file in your projects root too): ```env filename="Vercel Environment Variables" lineNumbers NEXT_PUBLIC_SITE_URL= DATABASE_URL= BETTER_AUTH_SECRET= STRIPE_SECRET_KEY= NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= STRIPE_WEBHOOK_SECRET= NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY= NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY= NEXT_PUBLIC_STRIPE_PRICE_LIFETIME= NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER= NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC= NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PRO= RESEND_API_KEY= EMAIL_FROM= ``` `DATABASE_URL` and `BETTER_AUTH_SECRET` are required. `NEXT_PUBLIC_SITE_URL` is optional and should contain your stable production URL when set. The Stripe and Resend variables are only required when you enable those features. Copy every price variable referenced by your deployed `billingConfig`; the names above match the shipped configuration. Then click the **Deploy** button and your project will be deployed. ## Environment Variables Make sure to add all required environment variables in the Vercel Dashboard. You can add them during the initial setup or later in the project settings under the **Environment Variables** tab. ## Apply the database migrations The application build does not apply database migrations. Generate and commit each migration while developing, then apply the committed migrations once against the production database before the new deployment receives traffic: ```sh filename="Terminal" lineNumbers npm run db:migrate ``` Run this from a controlled CI release job or a one-off local session with the production `DATABASE_URL`. Do not add it to the Vercel build command because concurrent builds or replicas must not race to change the schema. Use a pooled connection string intended for serverless workloads when your database provider offers one. Keep the application and database in nearby regions to reduce query latency. If the database firewall requires fixed source addresses, configure a supported secure connection method with the provider rather than assuming every Vercel function has a stable outbound IP. ## Build Settings Vercel will automatically detect Next.js and configure the build settings. The starter kit includes the necessary build configuration: ```json filename="package.json" lineNumbers { "scripts": { "build": "next build", "postinstall": "fumadocs-mdx" } } ``` Vercel runs the `next build` script. Drizzle infers schema types from the TypeScript source. This build does not run `drizzle-kit generate`. When you change the database schema, generate and commit the migration files before deploying. Leave Vercel's framework preset set to **Next.js** and use the repository root. A failed environment validation identifies a missing required variable; do not bypass it with `SKIP_ENV_VALIDATION` for an ordinary Vercel deployment. ## Webhooks If you're using Stripe webhooks, make sure to configure the webhook endpoint in your Stripe dashboard to point to your Vercel deployment URL: ```text filename="Webhook URL" lineNumbers https://your-app.vercel.app/api/webhooks/stripe ``` After adding the endpoint, subscribe only to the events handled by `app/api/webhooks/stripe/route.ts`. Use Stripe test mode to verify a checkout and webhook delivery before switching to live keys. ## Verify the deployment Before sending users to the application: 1. Open the production URL and confirm it uses HTTPS and the final domain. 2. Create an account, verify its email and sign in again. 3. Test an organization invitation with a second account. 4. Exercise each enabled integration, including one Stripe test checkout and one upload. 5. Confirm the Vercel function logs do not contain credentials or tokens. 6. Enable Web Analytics and Speed Insights separately in the Vercel dashboard if you want to use the components already included in the root layout. --- ## Email **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/email **Description**: Learn how to configure and send emails with Resend and React Email. The Pro Next.js Drizzle starter kit uses [Resend](https://resend.com/) for sending emails and [React Email](https://react.email/) for creating beautiful email templates. ## Configuration ### Environment Variables Add the following environment variables to your `.env` file: ```ini filename=".env" lineNumbers RESEND_API_KEY=re_... EMAIL_FROM=noreply@yourdomain.com ``` ### Getting Your Resend API Key 1. Create an account at [Resend](https://resend.com/) 2. Navigate to the API Keys section in your dashboard 3. Create a new API key 4. Copy the API key and add it to your `.env` file ### Domain Setup To send emails from your own domain: 1. Add your domain in the Resend dashboard 2. Verify your domain by adding the required DNS records 3. Update `EMAIL_FROM` to use your verified domain (e.g., `noreply@yourdomain.com`) ## Sending Emails The starter kit includes an `EmailService` class that handles all email operations: ```typescript filename="lib/actions/send-verification.ts" lineNumbers import { emailService } from '@/lib/email'; import { getBaseUrl } from '@/lib/utils'; // Use the email service to send verification emails await emailService.sendVerifyEmailAddressEmail({ recipient: 'user@example.com', name: 'John Doe', verificationLink: `${getBaseUrl()}/verify-email?token=${token}` }); ``` Or use the generic `sendEmail` method for custom emails: ```typescript filename="lib/actions/send-custom.ts" lineNumbers import { emailService } from '@/lib/email'; await emailService.sendEmail({ recipient: 'user@example.com', subject: 'Welcome!', html: '

Welcome to our platform!

', text: 'Welcome to our platform!' }); ``` ## Email Templates The starter kit uses [React Email](https://react.email/) to create email templates using `.tsx` files. Why choose React Email? React Email allows us to leverage Tailwind and React components, while ensuring consistent email styling across various email clients. It's really easy to write consistent email templates. ### Creating Email Templates Create email templates in the `lib/email/templates/` directory: ```tsx filename="lib/email/templates/welcome-email.tsx" lineNumbers import type * as React from 'react'; import { Body, Button, Container, Head, Heading, Html, Preview, Section, Text } from '@react-email/components'; import { Tailwind } from '@react-email/tailwind'; export type WelcomeEmailProps = { name: string; welcomeLink: string; }; function WelcomeEmail({ name, welcomeLink }: WelcomeEmailProps): React.JSX.Element { return ( Welcome to our platform! Welcome! Hello {name}, Thanks for joining us. We're excited to have you on board!
); } // Preview props for React Email preview WelcomeEmail.PreviewProps = { name: 'John Doe', welcomeLink: 'https://example.com/dashboard' } satisfies WelcomeEmailProps; export default WelcomeEmail; export { WelcomeEmail }; ``` The email service already includes methods for all pre-built templates. See the [Email Templates](/docs/starter-kits/pro-nextjs-drizzle/email/templates) documentation for details on creating custom templates. ## Previewing Emails You can preview your email templates during development. See the [React Email Preview](/docs/starter-kits/pro-nextjs-drizzle/email/react-email-preview) documentation for details. ## Available Templates The starter kit includes pre-built templates for: - Email verification (`sendVerifyEmailAddressEmail`) - Password reset (`sendPasswordResetEmail`) - Organization invitations (`sendOrganizationInvitationEmail`) - Email address change confirmation (`sendConfirmEmailAddressChangeEmail`) - Revoked invitations (`sendRevokedInvitationEmail`) - Payment failed notifications (`sendPaymentFailedEmail`) - Subscription canceled (`sendSubscriptionCanceledEmail`) - Trial ending soon (`sendTrialEndingSoonEmail`) - Contact form submissions (`sendContactFormEmail`) All templates are located in `lib/email/templates/` and can be customized to match your brand. --- ## Configuration **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/email/configuration **Description**: Learn how to configure Resend and set up email sending. The starter kit uses [Resend](https://resend.com/) for sending emails. Resend is a modern email API designed for developers, offering excellent deliverability and a simple integration. ## Setup ### 1. Create a Resend Account 1. Go to [Resend](https://resend.com/) and create an account 2. Navigate to the **API Keys** section in your dashboard 3. Click **Create API Key** 4. Give it a name (e.g., "Production" or "Development") 5. Copy the API key (starts with `re_`) ### 2. Configure Environment Variables Add the following environment variables to your `.env` file: ```env filename=".env" lineNumbers RESEND_API_KEY=re_... EMAIL_FROM=noreply@yourdomain.com ``` Security Note Never commit your API keys to version control. Always use environment variables and ensure .env is in your .gitignore. ### 3. Domain Setup To send emails from your own domain: 1. Go to **Domains** in your Resend dashboard 2. Click **Add Domain** 3. Enter your domain (e.g., `yourdomain.com`) 4. Add the required DNS records to verify your domain: - **SPF Record** - Authorizes Resend to send emails - **DKIM Record** - Signs emails for authentication - **DMARC Record** (optional) - Email authentication policy 5. Wait for domain verification (usually a few minutes) 6. Update `EMAIL_FROM` to use your verified domain: ```env filename=".env" lineNumbers EMAIL_FROM=noreply@yourdomain.com ``` Using Resend's Domain For testing, you can use Resend's default domain:{' '} onboarding@resend.dev. However, for production, always use your own verified domain for better deliverability. ## Email Functions The email functions in `lib/email` automatically use your environment variables. ### Basic Usage ```typescript filename="lib/email/example.ts" lineNumbers import { sendEmail } from '@/lib/email'; await sendEmail({ recipient: 'user@example.com', subject: 'Welcome!', html: '

Welcome to our platform!

', text: 'Welcome to our platform!' }); ``` ### Using Pre-built Templates The email module exports functions for all pre-built templates: ```typescript filename="lib/actions/signup.ts" lineNumbers import { sendVerifyEmailAddressEmail } from '@/lib/email'; await sendVerifyEmailAddressEmail({ recipient: user.email, name: user.name, verificationLink: `${getBaseUrl()}/verify-email?token=${token}` }); ``` ## Retry Logic The email service includes automatic retry logic with exponential backoff: - **Max Attempts**: 3 total attempts (the initial attempt and up to 2 retries) - **Base Delay**: 1 second - **Max Delay**: 10 seconds - **Exponential Backoff**: Delay doubles with each retry Permanent errors (invalid email, auth failure) are not retried. ## Error Handling The email functions handle errors gracefully: ```typescript filename="lib/email/example.ts" lineNumbers import { sendEmail } from '@/lib/email'; try { await sendEmail({ recipient: 'user@example.com', subject: 'Test', html: '

Test

', text: 'Test' }); } catch (error) { // Error is logged automatically // Permanent errors are not retried // Transient errors are retried automatically } ``` ## Production Configuration For production deployments: 1. **Use a verified domain** - Always use your own domain, not Resend's default 2. **Set up SPF/DKIM** - Ensure DNS records are properly configured 3. **Monitor deliverability** - Check Resend dashboard for bounce rates 4. **Set up webhooks** (optional) - Track email events (delivered, bounced, etc.) ### Environment Variables in Production Add your environment variables in your hosting platform: - **Vercel**: Project Settings → Environment Variables - **Railway**: Variables tab - **Other platforms**: Follow their environment variable documentation ## Testing Test templates and delivery separately. A correct preview does not prove that Resend can authenticate your sender or deliver a message. ### Preview templates locally Run `npm run email:dev`, then open `http://localhost:3001`. This renders the components in `lib/email/templates/` with their `PreviewProps` without sending email or calling Resend. See [React Email Preview](/docs/starter-kits/pro-nextjs-drizzle/email/react-email-preview). ### Verify provider delivery 1. Set `RESEND_API_KEY` to a development API key. 2. Set `EMAIL_FROM` to an address on a verified domain. For an initial Resend test, `onboarding@resend.dev` can only send to the email address associated with your Resend account. 3. Start the application and trigger a real product flow such as email verification, password reset or an organization invitation. 4. Confirm the request succeeds, the message appears in the Resend dashboard and the recipient receives it. 5. Open the generated link and confirm that it uses the correct application URL for the environment you are testing. Use a real product flow The repository does not include a standalone email test script. Triggering a shipped flow verifies the template, provider configuration and generated URL together. ### Before production - Use separate Resend API keys for development and production. - Verify the production sending domain and set `EMAIL_FROM` to that domain. - Add both email variables to the production deployment environment. - Exercise verification, password reset and invitation delivery after deploy. - Review failed requests, bounces and provider limits in the Resend dashboard. ## Best Practices 1. **Always use your own domain** - Better deliverability and branding 2. **Set up DNS records correctly** - SPF, DKIM and DMARC 3. **Monitor bounce rates** - Remove invalid email addresses 4. **Use templates** - Consistent branding and easier maintenance 5. **Handle errors gracefully** - Log errors and notify admins 6. **Test before production** - Preview templates, then exercise real product flows against the deployed environment --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/email/overview **Description**: Understand the shipped Resend and React Email integration. The starter kit uses [Resend](https://resend.com/) for delivery and [React Email](https://react.email/) for typed email templates. Both editions ship the same email API under `lib/email/`. ## How email flows through the application 1. A product flow calls an exported function from `lib/email/emails.ts`. 2. That function renders a React Email component to HTML and plain text. 3. `lib/email/resend.ts` sends both versions through Resend. 4. Transient failures are retried and final failures are logged. This separation keeps provider credentials out of templates and gives each message a typed input contract. ## Connected product emails The following messages are connected to shipped application flows: | Flow | Trigger | | ----------------------- | ------------------------------------------------------ | | Verify email address | Password signup through Better Auth | | Password reset | Better Auth password-reset request | | Confirm email change | Better Auth email-change request | | Organization invitation | Creating an invitation through the organization plugin | | Contact form | A successful contact-form submission | | Payment failed | A handled Stripe invoice failure webhook | | Subscription canceled | A handled Stripe subscription deletion webhook | | Trial ending | A handled Stripe trial-ending webhook | | Dispute received | A handled Stripe dispute webhook | `lib/email/templates/revoked-invitation-email.tsx` and its sending function are included for customization, but the shipped invitation-revocation action does not call it automatically. Wire it into that action if your product should notify the recipient. ## Send a custom email Use the generic transport for a one-off message: ```typescript filename="lib/actions/send-custom.ts" lineNumbers import { sendEmail } from '@/lib/email'; await sendEmail({ recipient: 'user@example.com', subject: 'Welcome!', html: '

Welcome to our platform!

', text: 'Welcome to our platform!' }); ``` For a reusable product email, create a typed React Email template and an exported rendering function in `lib/email/emails.ts`. This guarantees that HTML and plain-text versions are generated consistently. The React Email preview proves that a component renders. Exercise the real product flow with a development Resend key to verify sender authentication, generated links and delivery. --- ## React Email Preview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/email/react-email-preview **Description**: Learn how to preview email templates using React Email's preview server. ## Start the preview server To preview email templates, run: ```sh filename="Terminal" lineNumbers npm run email:dev ``` The preview server runs at http://localhost:3001 and automatically detects all email templates in lib/email/templates/ . ## How it works React Email's preview server automatically: - Scans the `lib/email/templates/` directory - Detects all email template files - Uses the `PreviewProps` exported from each template for preview data - Provides a web interface to preview all templates ## Email Development React Email allows you to develop email templates using React components, making it easy to create responsive and beautiful emails that work across email clients. --- ## Email Templates **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/email/templates **Description**: Learn how to create and use React Email templates. The starter kit uses [React Email](https://react.email/) to create email templates using `.tsx` files. React Email allows you to leverage Tailwind CSS and React components while ensuring consistent email styling across various email clients. ## Why React Email? Why choose React Email? React Email allows us to leverage Tailwind and React components, while ensuring consistent email styling across various email clients. It's really easy to write consistent email templates. ## Creating Email Templates Create email templates in the `lib/email/templates/` directory: ```tsx filename="lib/email/templates/welcome-email.tsx" lineNumbers import type * as React from 'react'; import { Body, Button, Container, Head, Heading, Html, Preview, Section, Text } from '@react-email/components'; import { Tailwind } from '@react-email/tailwind'; export type WelcomeEmailProps = { name: string; welcomeLink: string; }; function WelcomeEmail({ name, welcomeLink }: WelcomeEmailProps): React.JSX.Element { return ( Welcome to our platform! Welcome! Hello {name}, Thanks for joining us. We're excited to have you on board!
); } // Preview props for React Email preview WelcomeEmail.PreviewProps = { name: 'John Doe', welcomeLink: 'https://example.com/dashboard' } satisfies WelcomeEmailProps; export default WelcomeEmail; export { WelcomeEmail }; ``` ## Add a Sending Function The repository does not use an email service class. Add a focused exported function to `lib/email/emails.ts`, following the same pattern as the shipped authentication and billing emails: ```typescript filename="lib/email/emails.ts" lineNumbers import { render } from '@react-email/render'; import { sendEmail } from './resend'; import type { WelcomeEmailProps } from './templates/welcome-email'; export async function sendWelcomeEmail( input: WelcomeEmailProps & { recipient: string } ): Promise { const { WelcomeEmail } = await import('./templates/welcome-email'); const component = WelcomeEmail(input); const html = await render(component); const text = await render(component, { plainText: true }); await sendEmail({ recipient: input.recipient, subject: 'Welcome to our platform!', html, text }); } ``` `lib/email/index.ts` already re-exports `lib/email/emails.ts`, so the new function becomes available from `@/lib/email` without adding another export. ## Using Email Templates Send emails using the email service: ```typescript filename="lib/actions/send-welcome.ts" lineNumbers import { sendWelcomeEmail } from '@/lib/email'; export async function handleUserCreated(user: { email: string; name: string }) { await sendWelcomeEmail({ recipient: user.email, name: user.name, welcomeLink: 'https://yourdomain.com/dashboard' }); } ``` In application code, build links with the same trusted base-URL helper used by the shipped authentication flows. Do not construct email links from an unvalidated request `Host` header. ## Available Components React Email provides many components for building emails: - **Container** - Main wrapper - **Section** - Content sections - **Heading** - Headings (h1-h6) - **Text** - Paragraph text - **Button** - Call-to-action buttons - **Link** - Hyperlinks - **Image** - Images - **Hr** - Horizontal rules - **Code** - Inline code - **CodeBlock** - Code blocks See the [React Email documentation](https://react.email/docs/components/html) for a complete list. ## Styling ### Using Tailwind React Email supports Tailwind CSS: ```tsx filename="lib/email/templates/example.tsx" lineNumbers Hello World ``` ### Inline Styles You can also use inline styles: ```tsx filename="lib/email/templates/example.tsx" lineNumbers Hello World ``` ## Preview Props Each template should export `PreviewProps` for the React Email preview: ```typescript filename="lib/email/templates/welcome-email.tsx" lineNumbers WelcomeEmail.PreviewProps = { name: 'John Doe', welcomeLink: 'https://example.com/dashboard' } satisfies WelcomeEmailProps; ``` ## Previewing Emails You can preview your email templates during development. See the [React Email Preview](/docs/starter-kits/pro-nextjs-drizzle/email/react-email-preview) documentation for details. ### Running the Preview Server ```bash filename="Terminal" lineNumbers npm run email:dev ``` This starts a local server at `http://localhost:3001` where you can preview all your email templates. ## Available Templates The starter kit includes the following email templates: - **Verify Email Address** - Email verification link - **Password Reset** - Password reset instructions - **Organization Invitation** - Invite users to organizations - **Payment Failed** - Notify about failed payments - **Subscription Canceled** - Notify about canceled subscriptions - **Trial Ending Soon** - Remind about trial expiration - **Contact Form** - Contact form submissions - **Revoked Invitation** - Notify about revoked invitations - **Email Address Change** - Confirm email address change - **Dispute Received** - Alert administrators when Stripe reports a dispute All templates are located in `lib/email/templates/` and can be customized to match your brand. ## Customizing Templates ### Update Branding Update the logo and colors in your templates: ```tsx filename="lib/email/templates/welcome-email.tsx" lineNumbers Your Company {/* ... rest of template */} ``` ### Add Custom Styles Create a shared styles file: ```typescript filename="lib/email/styles.ts" lineNumbers export const emailStyles = { primaryColor: '#000000', secondaryColor: '#666666', borderRadius: '4px', fontFamily: 'Arial, sans-serif' }; ``` Use in templates: ```tsx filename="lib/email/templates/example.tsx" lineNumbers import { emailStyles } from '../styles'; ; ``` ## Best Practices 1. **Use Preview Props** - Always define preview props for development 2. **Test across clients** - Test emails in Gmail, Outlook, Apple Mail 3. **Keep it simple** - Avoid complex layouts that break in email clients 4. **Use Tailwind** - Leverage Tailwind for consistent styling 5. **Include plain text** - Always provide a plain text version 6. **Mobile responsive** - Ensure emails look good on mobile devices 7. **Accessible** - Use semantic HTML and alt text for images --- ## FAQ **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/faq **Description**: Frequently asked questions about the starter kit. ## What is a starter kit? A starter kit is a pre-built, fullstack foundation designed to help you create scalable, production-ready web applications quickly and efficiently. It includes all the essential tools, components and best practices for building modern SaaS platforms, so you can focus on developing your unique features instead of spending time on generic setup tasks. ## What is the difference between a starter kit and boilerplate? In everyday conversations the terms are often used interchangeably. Feel free to use whichever one you prefer! ## How do I update the starter kit? Keep your product repository as `origin`, add the private Achromatic repository as a separate remote and merge updates on a dedicated branch. Review migrations, environment changes and security-sensitive code before applying an update. See the [updating guide](/docs/starter-kits/pro-nextjs-drizzle/codebase/updating) for the complete workflow. --- ## Folder Structure **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/folder-structure **Description**: An overview of the project's organization and file structure. The Pro Next.js Drizzle starter kit follows a clean and logical structure designed for scalability and ease of maintenance in a single-repo setup. ## Directory Descriptions ### `app/` Next.js App Router directory containing all routes and pages. Uses route groups `(marketing)` and `(saas)` to organize public and protected pages. ### `components/` React components organized by feature. The `ui/` subdirectory contains reusable UI primitives from shadcn/ui. ### `config/` Application configuration files. Each feature has its own configuration file for easy management. ### `content/` MDX content for blog posts, documentation and legal pages. Uses Content Collections for type-safe content management. ### `hooks/` Custom React hooks for shared functionality like session management, storage and theming. ### `lib/` Core business logic and service libraries. Each subdirectory represents a major feature: - **`auth/`**: Better Auth setup and utilities - **`billing/`**: Stripe integration and billing logic - **`db/`**: Drizzle ORM schema and client - **`email/`**: Email service and React Email templates - **`storage/`**: File storage service (S3-compatible) ### `schemas/` Zod validation schemas for form validation and API input validation. Organized by feature domain. ### `trpc/` tRPC API layer providing end-to-end type safety. Routers are organized by feature, and the context provides session and organization scoping. ### `types/` Shared TypeScript type definitions used across the application. ## Highlights - **`(marketing)` & `(saas)` Route Groups**: Clearly separates public-facing pages from the protected dashboard application. - **`lib/` Directory**: Centralizes all core services like database, authentication and billing, making them easy to test and maintain. - **`schemas/` Directory**: Centralizes all Zod validation schemas for consistent validation across the application. - **`trpc/` Directory**: Contains your entire API layer, ensuring end-to-end type safety with React Query integration. - **`components/ui/`**: Houses reusable UI primitives, following the shadcn/ui pattern. - **`hooks/` Directory**: Custom React hooks for shared functionality, reducing code duplication. --- ## Introduction **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle **Description**: Set up, understand and customize the Drizzle edition of the Achromatic Next.js SaaS starter kit. ## Start here Achromatic is a production-grade Next.js SaaS starter kit built as a single, approachable application. This documentation explains what the repository ships, how its systems fit together and where to customize them safely. ## What ships The repository includes working implementations for: - **Next.js 16.3, React 19 and TypeScript 7** with the App Router, Server Components and streaming. - **Drizzle ORM and PostgreSQL** with schemas, migrations and local Docker setup. - **Better Auth** with email and password authentication, Google OAuth, email verification, password recovery, TOTP two-factor authentication and administration. - **Organizations** with invitations, roles, permissions and organization-scoped data. - **Stripe billing** with subscriptions, one-time purchases, credits, per-seat billing, a customer portal and webhook synchronization. - **Email** with Resend, React Email templates and local template previews. - **AI** with a working Vercel AI SDK chatbot, persistence and credit consumption. - **Image storage** with S3-compatible presigned uploads for avatars and organization logos. - **Developer tooling** with tests, strict TypeScript, [Oxlint and Oxfmt](/docs/starter-kits/pro-nextjs-drizzle/codebase/formatting-linting) and a [local, read-only MCP server](/docs/starter-kits/pro-nextjs-drizzle/codebase/mcp-server) for repository-aware coding assistance. - **Operations** with Pino logging, Sentry, Vercel Analytics and Speed Insights. - **Product surfaces** including marketing pages, a blog, legal pages, account settings, organization settings and the admin dashboard. Included code versus integration guidesA page can explain how to add a compatible service without claiming that the service is installed. For example, the background-task section contains integration guides for Trigger.dev, QStash, Inngest and Vercel Workflow. Those packages are not part of the repository until you choose and install one. ## Architecture Achromatic deliberately uses a single repository and a single Next.js application. Marketing, authentication, the SaaS dashboard and API routes share one dependency graph and deployment. This keeps local development, upgrades and cross-cutting changes direct. It is an intentional alternative to a monorepo, not a reduced version of one. The application is organized around feature boundaries: - `app/` contains routes, layouts and route handlers. - `components/` contains reusable interface and feature components. - `config/` contains typed product configuration. - `lib/` contains server integrations and application services. - `trpc/` contains the type-safe API layer. - The database schema and migration directories contain the persistence model. Use the [folder structure](/docs/starter-kits/pro-nextjs-drizzle/folder-structure) and [codebase overview](/docs/starter-kits/pro-nextjs-drizzle/codebase/overview) before moving large features. ## Choose the right database kit Both repositories ship the same product features and user experience. Choose Drizzle when your team prefers Drizzle ORM. If you are still deciding, use the [starter kit chooser](/docs/starter-kits) before selecting the repository that will become the foundation for your product. ## Recommended path 1. Complete [Setup](/docs/starter-kits/pro-nextjs-drizzle/setup) without product customizations. 2. Confirm the application, database, signup and email verification flows work locally. 3. Read [Configuration](/docs/starter-kits/pro-nextjs-drizzle/configuration) and replace the product identity. 4. Configure only the integrations your first release needs. 5. Run the test and quality checks before changing architecture. 6. Use the [deployment guide](/docs/starter-kits/pro-nextjs-drizzle/deployment) as a production checklist. Starting from a verified baseline makes later failures much easier to isolate. ## Scope of this documentation Use these guides as the source of truth for the code Achromatic ships: repository paths, package scripts, configuration files, environment variable names and how the included features are connected. Database-specific pages are kept separate where schema or migration workflows differ between editions. Use the upstream documentation when you need the complete API of an underlying library: - [Next.js documentation](https://nextjs.org/docs) for framework behavior and App Router APIs. - [Better Auth documentation](https://www.better-auth.com/docs) for plugin APIs and authentication concepts. - [Stripe documentation](https://docs.stripe.com/) for account configuration, Checkout and webhook behavior. - The official Drizzle ORM documentation for queries, schema syntax and advanced database features. Check the versions pinned in `package.json` before following a newly published upstream example. If an upstream guide conflicts with this documentation, first confirm that it targets the same installed version and the same runtime. --- ## Blog **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/marketing/blog **Description**: Learn how to write blog posts using Content Collections. The starter kit uses [Content Collections](https://www.content-collections.dev/) for managing blog content. All blog posts are written using `.mdx` files, which combine markdown with React components. Why choose Content Collections? Content Collections is a powerful, type-safe content management solution. It's powered by MDX, free, open-source and saves content directly in your repository. The blog is configured in `content-collections.ts` and uses [Fumadocs](https://www.fumadocs.dev/) for rendering documentation-style content. ## Add a new blog post To create a new blog post, follow these steps: 1. **Create a new file** Navigate to the `content/posts` directory and create a new `.mdx` file. The file name will act as the URL slug for the post. For example: - File name: `hello-world.mdx` - URL: `https://your-app.com/blog/hello-world` 2. **Add metadata** At the top of the `.mdx` file, include a frontmatter block. This block contains key metadata about your post, written in a YAML-like format enclosed by three dashes (`---`). Here's an example: ```mdx filename="content/posts/hello-world.mdx" lineNumbers --- title: How to create a blog post date: 2025-01-20T12:00:00.000Z authorName: John Doe authorImage: /authors/john.jpg authorLink: https://example.com excerpt: A short description of your blog post. tags: [Innovation, Tutorial] published: true content: | Your blog post content goes here... --- ``` ### Frontmatter Fields The blog post schema supports the following fields: - `title` (required) - The title of the blog post - `date` (required) - ISO 8601 date string for publication date - `authorName` (required) - Name of the author - `authorImage` (optional) - URL to author's image - `authorLink` (optional) - Link to author's profile - `excerpt` (optional) - A short description/excerpt of the post - `tags` (required) - Array of tag strings - `published` (required) - Boolean to control visibility - `image` (optional) - Featured image URL - `content` (required) - The full content of the post ## Using MDX Components You can use React components directly in your MDX files. The starter kit provides several custom components: ```mdx filename="content/posts/example.mdx" lineNumbers --- title: Example Post date: 2025-01-20T12:00:00.000Z authorName: John Doe tags: [Example] published: true content: | # My Blog Post This is a callout component! Regular markdown content here. --- ``` ## Code Blocks Code blocks are automatically highlighted and support line numbers: ```typescript filename="example.ts" lineNumbers export function example() { return 'Hello, World!'; } ``` ## Images You can include images in your blog posts: ```mdx filename="content/posts/example.mdx" lineNumbers ![Alt text](/path/to/image.png) ``` Or use the Image component for more control: ```mdx filename="content/posts/example.mdx" lineNumbers import { Image } from '@/components/mdx-components'; Alt text ``` ## Building Content Content Collections are automatically built during the Next.js build process. During development, they are rebuilt when files change. ## Querying Blog Posts You can query blog posts in your application: ```typescript filename="app/blog/page.tsx" lineNumbers import { allDocuments } from 'content-collections/generated'; export default function BlogPage() { const posts = allDocuments('posts') .filter((post) => post.published) .sort((a, b) => { const dateA = new Date(a.date); const dateB = new Date(b.date); return dateB.getTime() - dateA.getTime(); }); return (
{posts.map((post) => (

{post.title}

{post.excerpt}

))}
); } ``` ## Configuration The blog collection is configured in `content-collections.ts`: ```typescript filename="content-collections.ts" lineNumbers const posts = defineCollection({ name: 'posts', directory: 'content/posts', include: '**/*.{mdx,md}', schema: z.object({ title: z.string(), date: z.string(), authorName: z.string(), excerpt: z.string().optional(), tags: z.array(z.string()), published: z.boolean(), content: z.string() }) }); export default defineConfig({ collections: [posts] }); ``` ## Best Practices 1. **Use descriptive filenames** - The filename becomes the URL slug 2. **Add descriptions** - Help with SEO and preview cards 3. **Use categories** - Organize related posts 4. **Set publication dates** - Control when posts appear 5. **Test locally** - Always preview posts before publishing --- ## Contact Page **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/marketing/contact **Description**: Learn how to customize the contact page and configure contact information. The contact page (`/contact`) provides a way for visitors to get in touch with your team. It includes a contact form and displays your contact information. ## Page Structure The contact page is located at `app/contact/page.tsx` and includes: - **Contact Hero Section** - Headline and description - **Contact Form** - Form for visitors to send messages - **FAQ Section** - Frequently asked questions ## Configuration Contact information is configured in `config/app.config.ts`: ```typescript filename="config/app.config.ts" lineNumbers export const appConfig = { // ... other config contact: { enabled: true, email: 'hello@yourdomain.com', phone: '(123) 456-7890', address: '123 Main St, San Francisco, CA' } }; ``` ### Contact Form The contact form is handled by the `ContactHeroSection` component. To customize the form: 1. **Update form fields** - Edit the form component in `components/sections/contact-hero-section.tsx` 2. **Configure email** - Set up email sending in your email service (see [Email documentation](/docs/starter-kits/pro-nextjs-drizzle/email/overview)) 3. **Add validation** - Add client and server-side validation as needed ## Customization ### Update Contact Content Edit the `ContactHeroSection` component: ```typescript filename="components/sections/contact-hero-section.tsx" lineNumbers export function ContactHeroSection(): React.JSX.Element { return (

Get in Touch

We'd love to hear from you

{/* Contact form */}
); } ``` ### Display Contact Information Contact information from `app.config.ts` is automatically displayed. You can customize how it's shown by editing the contact section component. ## Email Integration To send emails when the contact form is submitted: 1. **Create a tRPC endpoint** - Handle form submission server-side 2. **Use email service** - Send emails using your configured email provider 3. **Add validation** - Validate form data before sending See the [Email documentation](/docs/starter-kits/pro-nextjs-drizzle/email/overview) for more details. ## SEO The contact page includes structured data (JSON-LD) for: - ContactPage schema - WebPage schema - Breadcrumb schema ## Best Practices 1. **Clear contact options** - Provide multiple ways to get in touch 2. **Quick response** - Set expectations for response time 3. **Form validation** - Validate all form fields 4. **Spam protection** - Consider adding reCAPTCHA or similar 5. **Confirmation message** - Show a success message after submission --- ## Documentation **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/marketing/documentation **Description**: Write and organize product documentation with Fumadocs MDX. The starter kit includes a Fumadocs documentation site at `/docs`. Its pages are local MDX files in `content/docs`, so the documentation stays versioned with the application code. Both starter kit repositories use the same documentation structure. Do not create an ORM-specific folder inside `content/docs`. ## How documentation is connected | File | Responsibility | | ------------------------------- | ------------------------------------------------------- | | `content/docs/*.mdx` | Documentation content and page metadata | | `content/docs/meta.json` | Sidebar groups, labels and page order | | `source.config.ts` | Declares `content/docs` as the Fumadocs MDX source | | `lib/marketing/docs/source.ts` | Loads the content and assigns the `/docs` base URL | | `app/docs/layout.tsx` | Configures the documentation layout and navigation tree | | `app/docs/[[...slug]]/page.tsx` | Renders each page and generates its metadata | The kit already connects these files. You normally only need to edit `content/docs` when writing product documentation. ## Add a page ### Create the MDX file Add a file directly under `content/docs`. Its path becomes the URL after `/docs`. ```mdx filename="content/docs/getting-started.mdx" --- title: Getting started description: Configure the application for local development. icon: Rocket --- ## Prerequisites Add your guide here. ``` This example is available at `/docs/getting-started`. The optional `icon` value must match an icon exported by Lucide React. ### Add the page to the sidebar Add the filename without `.mdx` to the `pages` array in `content/docs/meta.json`: ```json filename="content/docs/meta.json" { "title": "Documentation", "root": true, "pages": ["index", "getting-started"] } ``` Keep this array in the order you want readers to follow. Fumadocs also supports separators and external links in this file. ### Preview the page Start the application and open the new route: ```bash npm run dev ``` Visit `http://localhost:3000/docs/getting-started` and check the page on both desktop and mobile. ## Organize a section For a larger topic, put its pages in a folder and add a `meta.json` inside that folder. The folder name becomes the URL segment. ```text content/docs/ ├── meta.json └── billing/ ├── meta.json ├── overview.mdx └── webhooks.mdx ``` ```json filename="content/docs/billing/meta.json" { "title": "Billing", "pages": ["overview", "webhooks"] } ``` The pages are then available at `/docs/billing/overview` and `/docs/billing/webhooks`. Add `billing` to the root `content/docs/meta.json` where that section should appear. ## Use the included MDX components The page renderer registers Fumadocs components including `Callout`, `Cards`, `Tabs`, `Steps`, `Files` and `ImageZoom`. You can use them directly in an MDX page without importing them. ```mdx filename="content/docs/getting-started.mdx" Copy `.env.example` to `.env` and provide the required values. Use your local service credentials. Use credentials from the production project. ``` Standard fenced code blocks support syntax highlighting. Add a `filename` attribute when the file location helps the reader. ## Change the documentation UI - Edit `app/docs/layout.tsx` to change the documentation shell or sidebar behavior. - Edit `lib/marketing/docs/layout.config.tsx` to change shared layout options such as navigation links. - Edit `app/docs/[[...slug]]/page.tsx` to register another MDX component or change page rendering. - Edit `lib/marketing/docs/source.ts` only when changing how the content source is loaded. Keep content changes in `content/docs` and layout changes in the application files above. This separation makes upgrades easier and keeps navigation generated from the same source as the pages. ## Validate before publishing Run the same checks used for application changes: ```bash npm run typecheck npm run lint npm run build ``` Also open every new documentation route locally. A successful build confirms that Fumadocs can compile the MDX, while the browser check catches navigation, layout and readability problems. For advanced navigation and MDX options, see the [Fumadocs documentation](https://fumadocs.dev/docs/mdx). --- ## Landing Page **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/marketing/landing **Description**: Learn how to customize the landing page and its sections. The landing page (`/`) is the main homepage of your marketing site. It showcases your product with multiple sections designed to convert visitors into customers. ## Page Structure The landing page is located at `app/page.tsx` and consists of the following sections: - **Hero Section** - Main headline, value proposition and call-to-action buttons - **Pre-built Section** - Highlights key features and benefits - **Features Section** - Detailed feature list - **Auth Section** - Authentication capabilities showcase - **Multi-tenancy Section** - Organization management features - **Billing Section** - Payment and subscription features - **Code Section** - Developer experience highlights - **Trusted Section** - Social proof and testimonials - **Pricing Section** - Pricing preview with call-to-action - **FAQ Section** - Frequently asked questions ## Customization ### Update Hero Content Edit the `HeroSection` component to change the headline, description and CTA buttons: ```typescript filename="components/sections/hero-section.tsx" lineNumbers export function HeroSection(): React.JSX.Element { return (

Your Headline

Your value proposition

); } ``` ### Modify Sections Each section is a separate component in `components/sections/`. You can: - **Reorder sections** - Change the order in `app/page.tsx` - **Remove sections** - Comment out or delete unused sections - **Add custom sections** - Create new section components and add them to the page - **Customize styling** - Update Tailwind classes in each section component ### Update Metadata Modify the page metadata in `app/page.tsx`: ```typescript filename="app/page.tsx" lineNumbers export const metadata: Metadata = { title: 'Your App Name', description: 'Your app description' // ... other metadata }; ``` ## SEO The landing page includes structured data (JSON-LD) for: - Website schema - Software application schema - FAQ schema - Site navigation schema These are automatically generated and help with search engine optimization. ## Best Practices 1. **Clear value proposition** - Make it immediately clear what your product does 2. **Strong CTAs** - Use action-oriented button text (e.g., "Get Started", "Start Free Trial") 3. **Social proof** - Include testimonials, logos or usage statistics 4. **Mobile responsive** - Ensure all sections work well on mobile devices 5. **Fast loading** - Optimize images and use Next.js Image component --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/marketing/overview **Description**: Learn about all marketing pages and how to manage content using Content Collections and Fumadocs. The starter kit includes a comprehensive set of marketing pages to cover every touchpoint of your SaaS. These pages are organized in the `(marketing)` route group and include landing pages, blog, documentation, pricing, contact and legal pages. ## Marketing Pages The starter kit includes the following marketing pages: - **Landing Page** (`/`) - Main homepage showcasing your product with hero section, features, pricing preview and call-to-action - **Blog** (`/blog`) - Integrated blogging system using Content Collections, statically generated at build time - **Documentation** (`/docs`) - Documentation pages using Content Collections and Fumadocs - **Pricing** (`/pricing`) - Responsive pricing tables with plan comparisons and conversion-optimized design - **Contact** (`/contact`) - Contact form with email integration - **Legal Pages** - Terms of Service, Privacy Policy and Cookie Policy (create these pages as needed) All marketing pages are located in the `app/(marketing)/` directory and can be enabled/disabled via the `app.config.ts` file. ## Content Management For blog posts and documentation, the starter kit uses [Content Collections](https://www.content-collections.dev/) for managing content and [Fumadocs](https://www.fumadocs.dev/) for rendering documentation. This provides a powerful, type-safe content management system that's easy to use and maintain. Why Content Collections? Content Collections is a powerful, type-safe content management solution. It's powered by MDX, free, open-source and saves content directly in your repository. This means your content is version-controlled and easy to manage. ### Features - **Type-safe content** - Full TypeScript support with automatic type generation - **MDX support** - Write content using Markdown with React components - **Version control** - Content is stored in your repository, making it easy to track changes - **Fast builds** - Content is compiled at build time for optimal performance - **Developer-friendly** - Edit content using your favorite code editor - **No database required** - Content is stored as files, not in a database ## Content Collections Content Collections provides: - **Schema validation** - Define schemas for your content using Zod - **Automatic type generation** - TypeScript types are generated from your schemas - **Query API** - Easy-to-use API for querying content - **Transform functions** - Process and transform content during build ## Fumadocs Fumadocs provides: - **Beautiful UI** - Pre-built documentation UI components - **Search** - Full-text search across your documentation - **Dark mode** - Automatic theme switching - **Responsive design** - Mobile-friendly layouts - **Table of contents** - Automatically generated from headings ## Configuration Content Collections is configured in `content-collections.ts`. The starter kit includes multiple collections: ```typescript filename="content-collections.ts" lineNumbers import { defineCollection, defineConfig } from '@content-collections/core'; import { compileMDX } from '@content-collections/mdx'; import { z } from 'zod'; // Blog posts collection const posts = defineCollection({ name: 'posts', directory: 'content/posts', include: '**/*.{mdx,md}', schema: z.object({ title: z.string(), date: z.string(), authorName: z.string(), excerpt: z.string().optional(), tags: z.array(z.string()), published: z.boolean(), content: z.string() }), transform: async (document, context) => { const body = await compileMDX(context, document); return { ...document, body, path: document._meta.path.replace(/\.mdx?$/, '') }; } }); export default defineConfig({ collections: [posts] }); ``` ## Building Content Content Collections are automatically built during the Next.js build process. During development, they are rebuilt when files change. ## Querying Content You can query content in your application: ```typescript filename="app/blog/page.tsx" lineNumbers import { allDocuments } from 'content-collections/generated'; export default function BlogPage() { const posts = allDocuments('posts') .filter((post) => post.published) .sort((a, b) => { const dateA = new Date(a.date); const dateB = new Date(b.date); return dateB.getTime() - dateA.getTime(); }); return (
{posts.map((post) => (

{post.title}

{post.excerpt}

))}
); } ``` ## Best Practices 1. **Organize content** - Use clear directory structures 2. **Use schemas** - Define schemas for type safety 3. **Version control** - Commit content changes to git 4. **Test locally** - Always preview content before publishing 5. **Use MDX components** - Leverage React components in your content --- ## Pricing Page **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/marketing/pricing **Description**: Learn how to customize the pricing page and integrate it with your billing configuration. The pricing page (`/pricing`) displays your subscription plans and pricing information. It's integrated with your billing configuration and automatically displays plans from `config/billing.config.ts`. ## Page Structure The pricing page is located at `app/pricing/page.tsx` and includes: - **Pricing Hero Section** - Headline and description for the pricing page - **Pricing Section** - Displays all plans from your billing configuration - **FAQ Section** - Frequently asked questions about pricing ## Integration with Billing Config The pricing page automatically reads plans from `config/billing.config.ts`. Plans are displayed based on: - **Plan visibility** - Plans with `hidden: true` are not shown - **Plan order** - Plans are displayed in the order they appear in the config - **Recommended plans** - Plans with `recommended: true` are highlighted - **Enterprise plans** - Plans with `isEnterprise: true` show a "Contact Sales" button ## Customization ### Update Pricing Content Edit the `PricingHeroSection` component: ```typescript filename="components/sections/pricing-hero-section.tsx" lineNumbers export function PricingHeroSection(): React.JSX.Element { return (

Choose Your Plan

Select the perfect plan for your needs

); } ``` ### Customize Plan Display The `PricingSection` component automatically renders plans from your billing config. To customize how plans are displayed, edit: ```typescript filename="components/sections/pricing-section.tsx" lineNumbers // Customize plan card styling, features display, etc. ``` ### Update FAQ Modify the FAQ section in `app/pricing/page.tsx`: ```typescript filename="app/pricing/page.tsx" lineNumbers const faqSchema = { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: [ { '@type': 'Question', name: 'Your question?', acceptedAnswer: { '@type': 'Answer', text: 'Your answer.' } } ] }; ``` ## SEO The pricing page includes structured data (JSON-LD) for: - Product schema - Breadcrumb schema - FAQ schema ## Best Practices 1. **Clear pricing** - Make prices and features easy to understand 2. **Highlight recommended plan** - Use the `recommended` flag to guide users 3. **Show value** - Include feature comparisons and benefits 4. **Mobile friendly** - Ensure pricing tables work on all screen sizes 5. **Clear CTAs** - Use action-oriented button text --- ## Observability **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/observability **Description**: Monitor your application's performance and track errors with Sentry and Vercel Analytics. The Pro Next.js Drizzle starter kit includes structured logging and optional integrations for error, traffic and performance monitoring. External services still require your own accounts and project configuration. ## Structured Logging The kit includes a high-performance logging system based on **Pino**. ### Usage Use the `logger` to record events with structured metadata. ```typescript filename="lib/actions/billing.ts" lineNumbers import { logger } from '@/lib/logger'; logger.info({ userId, amount }, 'Payment processed successfully'); ``` ### Log Levels You can control the verbosity of logs via the `NEXT_PUBLIC_LOG_LEVEL` environment variable. ```env filename=".env" lineNumbers NEXT_PUBLIC_LOG_LEVEL="debug" # trace, debug, info, warn, error, fatal ``` Available log levels (from most to least verbose): - `trace` - Very detailed debugging information - `debug` - Debug information - `info` - General informational messages (default) - `warn` - Warning messages - `error` - Error messages - `fatal` - Critical errors --- ## Logging **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/observability/logging **Description**: Learn how to use Pino for structured logging in your application. The starter kit uses [Pino](https://getpino.io/), a high-performance structured logging library for Node.js. Pino is one of the fastest logging libraries available and provides excellent performance with minimal overhead. ## Basic Usage Import and use the default logger: ```typescript filename="lib/actions/billing.ts" lineNumbers import { logger } from '@/lib/logger'; // Simple message logger.info('Payment processed successfully'); // With structured data logger.info( { userId, amount, transactionId }, 'Payment processed successfully' ); // Error logging logger.error({ error, userId }, 'Failed to process payment'); ``` ## Log Levels Pino supports six log levels, from most to least verbose: - `trace` - Very detailed debugging information - `debug` - Debug information - `info` - General informational messages (default) - `warn` - Warning messages - `error` - Error messages - `fatal` - Critical errors ### Setting Log Level Control the verbosity of logs via the `NEXT_PUBLIC_LOG_LEVEL` environment variable: ```env filename=".env.local" lineNumbers NEXT_PUBLIC_LOG_LEVEL="debug" # trace, debug, info, warn, error, fatal ``` The default log level is `info`. In production, you typically want `info` or `warn` to reduce noise. ## Grouped Loggers Create loggers for specific modules or features using the `LoggerFactory`: ```typescript filename="lib/billing/payment-processor.ts" lineNumbers import { LoggerFactory } from '@/lib/logger/factory'; const logger = LoggerFactory.getLogger('Billing'); logger.info({ userId, amount }, 'Processing payment'); // Output: [INFO] Billing: Processing payment ``` Predefined groups include: - `Billing` - Payment and subscription related logs - `Auth` - Authentication related logs - `Webhook` - Webhook processing logs - `Database` - Database operation logs - `API` - API request logs - `Organization` - Organization management logs - `User` - User management logs - `Email` - Email sending logs - `Storage` - File storage logs You can also create custom groups: ```typescript filename="lib/features/analytics.ts" lineNumbers import { LoggerFactory } from '@/lib/logger/factory'; const logger = LoggerFactory.getLogger('Analytics'); logger.info({ event: 'page_view', page: '/dashboard' }, 'Page viewed'); ``` ## Request Context The logger can merge request context from the `AsyncLocalStorage` helpers in `lib/logger/context.ts`. The repositories do not install a global request wrapper, so ordinary logger calls include only the fields you pass unless your code runs inside `runWithRequestContext` or `runWithAdditionalContext`. The context type supports: - `requestId` - Unique request identifier - `userId` - Current user ID - `userEmail` - Current user email - `userRole` - Current user role - `organizationId` - Active organization ID - `userAgent` - User agent string - `ip` - Client IP address - `endpoint` - API endpoint - `method` - HTTP method - `trpcProcedure` - tRPC procedure name - `trpcType` - tRPC call type (query/mutation) - `webhookType` - Webhook event type - `sessionId` - Session ID Initialize the context at a request boundary before relying on automatic enrichment: ```typescript filename="app/api/users/route.ts" lineNumbers import { headers } from 'next/headers'; import { logger } from '@/lib/logger'; import { runWithRequestContext } from '@/lib/logger/server'; export async function GET() { const requestHeaders = await headers(); return runWithRequestContext( { requestId: requestHeaders.get('x-request-id') ?? undefined, userAgent: requestHeaders.get('user-agent') ?? undefined, endpoint: '/api/users', method: 'GET' }, async () => { logger.info({ action: 'list_users' }, 'Fetching user list'); return Response.json({ users: [] }); } ); } ``` The shipped tRPC middleware does not use this storage wrapper. On failed tRPC procedures it explicitly logs the procedure, duration, request metadata and any available user or organization fields. Successful procedure calls are not logged automatically. User email, IP address and user-agent values are supported context fields. Add only what you need, define a retention policy and never log session cookies, authorization headers, passwords, API keys or payment details. ## Development vs Production In development, logs are formatted with colors and readable output: ``` [INFO] Billing: Payment processed successfully (userId=123 amount=29.99 transactionId=txn_abc) ``` In production, logs are output as JSON for easy parsing by log aggregation services: ```json { "level": 30, "time": 1234567890, "group": "Billing", "msg": "Payment processed successfully", "userId": "123", "amount": 29.99, "transactionId": "txn_abc" } ``` ## Examples ### Server Actions ```typescript filename="app/actions/create-user.ts" lineNumbers 'use server'; import { logger } from '@/lib/logger'; export async function createUser(email: string, name: string) { try { logger.info({ email, name }, 'Creating new user'); // ... create user logic ... logger.info({ userId: user.id, email }, 'User created successfully'); return { success: true, user }; } catch (error) { logger.error({ error, email }, 'Failed to create user'); throw error; } } ``` ### API Routes ```typescript filename="app/api/webhooks/stripe/route.ts" lineNumbers import { logger } from '@/lib/logger'; export async function POST(request: Request) { const body = await request.json(); logger.info( { webhookType: body.type, eventId: body.id }, 'Received Stripe webhook' ); // ... process webhook ... logger.info({ webhookType: body.type }, 'Webhook processed successfully'); return new Response('OK', { status: 200 }); } ``` ### tRPC Procedures ```typescript filename="trpc/routers/user.ts" lineNumbers import { createTRPCRouter, protectedProcedure } from '@/trpc/init'; import { z } from 'zod'; import { logger } from '@/lib/logger'; export const userRouter = createTRPCRouter({ update: protectedProcedure .input(z.object({ name: z.string() })) .mutation(async ({ ctx, input }) => { logger.info({ userId: ctx.user.id, name: input.name }, 'Updating user'); // ... update logic ... logger.info({ userId: ctx.user.id }, 'User updated successfully'); return user; }) }); ``` ### Error Handling ```typescript filename="lib/utils/error-handler.ts" lineNumbers import { logger } from '@/lib/logger'; export function handleError(error: unknown, context?: Record) { if (error instanceof Error) { logger.error( { error: error.message, stack: error.stack, ...context }, 'Error occurred' ); } else { logger.error({ error, ...context }, 'Unknown error occurred'); } } ``` ## Best Practices 1. **Use appropriate log levels**: Use `info` for normal operations, `warn` for recoverable issues, and `error` for failures. 2. **Include structured data**: Always include relevant context in your logs: ```typescript // ✅ Good logger.info({ userId, orderId, amount }, 'Order processed'); // ❌ Less useful logger.info('Order processed'); ``` 3. **Use grouped loggers**: Create loggers for different modules to make logs easier to filter and search. 4. **Don't log sensitive data**: Avoid logging passwords, tokens, or other sensitive information. 5. **Initialize request context deliberately**: Wrap the request before expecting automatic enrichment, or pass identifiers explicitly at the log call. 6. **Log at appropriate times**: Log important state changes, errors, and significant events, but avoid excessive logging that can impact performance. ## Integration with Log Aggregation Services The JSON output in production is compatible with popular log aggregation services: - **Vercel Logs**: Automatically captured in Vercel deployments - **Datadog**: Can parse JSON logs - **LogRocket**: Supports structured logging - **CloudWatch**: AWS CloudWatch Logs - **Google Cloud Logging**: Supports JSON logs ## Next Steps - Learn about [Sentry](/docs/starter-kits/pro-nextjs-drizzle/observability/sentry) for error tracking - Check out [Vercel Analytics](/docs/starter-kits/pro-nextjs-drizzle/observability/vercel) for traffic monitoring - Explore [Speed Insights](/docs/starter-kits/pro-nextjs-drizzle/observability/speed-insights) for performance monitoring --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/observability/overview **Description**: Monitor your application's performance and track errors. The Pro Next.js Drizzle starter kit includes structured logging and optional integrations for error, traffic and performance monitoring. Installing code is only the first step: Sentry and Vercel services must be enabled and verified in the environments where you expect them to collect data. ## What works after setup | Capability | Included in the repository | Required activation | | --------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------ | | Structured logs | Pino logger, grouped loggers and request context | Choose `NEXT_PUBLIC_LOG_LEVEL` and configure a log destination if needed | | Sentry | Client, server and edge instrumentation plus build config | Add a Sentry project and DSN; add build credentials for source maps | | Vercel Analytics | `` in the root layout | Enable Web Analytics for the deployed Vercel project | | Vercel Speed Insights | `` in the root layout | Enable Speed Insights for the deployed Vercel project | Leaving a provider unconfigured should not stop the application from running, but that provider will not give you useful production telemetry. ## Verify production monitoring After deploying, prove that each enabled signal reaches its destination: 1. Write a uniquely named structured log and find it in the deployment logs. 2. Send a controlled test exception to Sentry and confirm that its stack trace resolves to the original source. 3. Visit two or three production routes, then confirm page views appear in Vercel Analytics. 4. Load a public page from a real browser and confirm Speed Insights begins collecting field data. This data may not appear immediately. 5. Remove the test exception after verification and configure alerts for business-critical failures such as authentication, checkout and webhooks. Use separate Sentry environments or projects for preview and production. Development noise and deliberate test failures should not trigger production incident alerts. --- ## Sentry **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/observability/sentry **Description**: Learn how to configure and use Sentry for error tracking and performance monitoring. The starter kit includes a Sentry integration for client, server and edge error tracking. It remains inactive in development and when `NEXT_PUBLIC_SENTRY_DSN` is not configured. ## Setup ### 1. Create a Sentry Account 1. Go to [sentry.io](https://sentry.io/welcome/) and create an account 2. Create a new project (select **Next.js** as the platform) 3. Copy your **DSN** (Data Source Name) ### 2. Configure Environment Variables Add your Sentry credentials to your `.env` file: ```env filename=".env" lineNumbers NEXT_PUBLIC_SENTRY_DSN="https://xxxxx@xxxxx.ingest.sentry.io/xxxxx" SENTRY_ORG="your-org" SENTRY_PROJECT="your-project" SENTRY_AUTH_TOKEN="sntrys_xxxxx" ``` Getting Your Auth Token Create an auth token in Sentry: Settings →{' '} Auth TokensCreate New Token. Grant it{' '} project:read and project:releases scopes. ### 3. Verify Configuration Sentry is automatically configured in `instrumentation.ts`, `instrumentation-server.ts`, `instrumentation-edge.ts`, and `instrumentation-client.ts`. The configuration includes: - Automatic error capture for client and server - Performance monitoring - Production source map uploads through `withSentryConfig` in `next.config.ts` - User and request context for tRPC procedures ## Automatic Instrumentation Sentry is pre-configured to automatically capture: ### Client-Side - Unhandled JavaScript exceptions - Unhandled promise rejections - React component errors (via Error Boundaries) - Performance metrics (Core Web Vitals) - User session replays (optional) ### Server-Side - API route errors - Server component errors - Server action errors - tRPC procedure errors (via Sentry tRPC middleware) - Database query errors ## tRPC Integration The starter kit uses the **Sentry tRPC middleware** to automatically capture errors and performance metrics from all tRPC procedures. The middleware is configured in your tRPC setup and automatically: - Captures all tRPC procedure errors with full context - Tracks procedure execution time and performance - Associates errors with the procedure path and input parameters - Includes user context when available The middleware is automatically applied to all procedures, so you don't need to manually instrument your tRPC endpoints. The shipped tRPC middleware uses attachRpcInput: true and adds the authenticated user's ID and email to the Sentry scope. Server instrumentation also sets sendDefaultPii: true. Remove unnecessary input fields, disable default PII or scrub events in{' '} beforeSend before processing real customer data. Never send passwords, tokens, payment details or other secrets to Sentry. ## Manual Error Tracking ### Capture Errors You can manually capture errors in your code: ```typescript filename="lib/actions/example.ts" lineNumbers import * as Sentry from '@sentry/nextjs'; try { // Your code } catch (error) { Sentry.captureException(error, { tags: { section: 'billing' }, extra: { userId: user.id, amount: 100 } }); throw error; } ``` ### Capture Messages Log important events: ```typescript filename="lib/actions/example.ts" lineNumbers import * as Sentry from '@sentry/nextjs'; Sentry.captureMessage('Payment processed', { level: 'info', tags: { feature: 'billing' } }); ``` ### Set User Context Associate errors with users: ```typescript filename="lib/auth/session.ts" lineNumbers import * as Sentry from '@sentry/nextjs'; export function setSentryUser(user: { id: string; email: string }) { Sentry.setUser({ id: user.id, email: user.email }); } ``` ## Performance Monitoring Sentry automatically tracks: - **Page Load Performance** - Time to first byte, first contentful paint - **API Route Performance** - Response times for API routes - **Core Web Vitals** - LCP, FID, CLS metrics ### Custom Performance Monitoring Track custom operations using `startSpan`: ```typescript filename="lib/actions/example.ts" lineNumbers import * as Sentry from '@sentry/nextjs'; await Sentry.startSpan( { name: 'Process Payment', op: 'payment' }, async () => { // Your payment processing code await processPayment(); } ); ``` ## Source Maps Source maps are automatically uploaded during build to provide readable stack traces in production. ### Configuration Source maps are configured by `withSentryConfig` in `next.config.ts`. During a production Vercel or CI build, the configuration: 1. Generates source maps during build 2. Uploads them to Sentry (if `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, and `SENTRY_PROJECT` are set) 3. Associates them with releases ### Releases Releases help you track which version of your code caused an error. They are automatically configured in the instrumentation files: ```typescript filename="instrumentation-server.ts" lineNumbers import { init } from '@sentry/nextjs'; import { env } from '@/lib/env'; init({ dsn: env.NEXT_PUBLIC_SENTRY_DSN, environment: process.env.NODE_ENV // Release is automatically set by @sentry/nextjs from Vercel environment variables }); ``` ## Session Replay Session Replay is already enabled in `instrumentation-client.ts` when Sentry is active. The shipped sample rates capture 10% of ordinary sessions and 100% of sessions containing an error: ```typescript filename="instrumentation-client.ts" lineNumbers import { init, replayIntegration } from '@sentry/nextjs'; init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, integrations: [replayIntegration()], replaysSessionSampleRate: 0.1, replaysOnErrorSampleRate: 1.0 }); ``` Privacy Note Session Replay can capture sensitive data. Review Sentry's masking defaults, add product-specific masking rules and reduce or disable the sample rates if replay is not appropriate for your application. ## Filtering Errors The starter kit already includes comprehensive error filtering in `instrumentation-server.ts`, `instrumentation-edge.ts`, and `instrumentation-client.ts`. You can customize it: ```typescript filename="instrumentation-server.ts" lineNumbers import { init } from '@sentry/nextjs'; import { env } from '@/lib/env'; init({ dsn: env.NEXT_PUBLIC_SENTRY_DSN, beforeSend(event) { // Filter out specific errors const exception = event.exception?.values?.[0]; if (exception?.value?.includes('ResizeObserver')) { return null; // Don't send this error } return event; } }); ``` ## Environment-Specific Configuration Sentry is automatically disabled in development. The configuration in `instrumentation-server.ts` and `instrumentation-edge.ts` includes: - Automatic disabling in development mode - Sample rate of 0.1 (10%) for performance traces - Error filtering for common noisy errors (TRPCError NOT_FOUND, ChunkLoadError, network errors, ResizeObserver errors, browser extension errors) ## Best Practices 1. **Set appropriate sample rates** - Use lower sample rates in production to reduce costs 2. **Filter sensitive data** - Don't send passwords, tokens, or PII 3. **Use tags** - Add tags to categorize errors (e.g., `feature: "billing"`) 4. **Set user context** - Always set user context for better error tracking 5. **Monitor performance** - Track slow operations and optimize them 6. **Review errors regularly** - Set up alerts for critical errors --- ## Vercel Speed Insights **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/observability/speed-insights **Description**: Learn how to use Vercel Speed Insights for real-time performance monitoring. The starter kit already renders **Vercel Speed Insights** from the root layout. Enable it in Vercel to collect performance measurements from real visits to your deployed application. ## What is already configured The repository includes `@vercel/speed-insights` and renders its Next.js component in `app/layout.tsx`: ```tsx filename="app/layout.tsx" lineNumbers import { SpeedInsights } from '@vercel/speed-insights/next'; // Inside the root layout body ; ``` No environment variable is required. ## Enable Speed Insights 1. Open your project in the [Vercel dashboard](https://vercel.com/dashboard). 2. Select **Speed Insights** in the project sidebar. 3. Enable Speed Insights. 4. Deploy the application again. 5. Visit several pages on the deployed application, then check the Speed Insights dashboard. Expect real-user data Speed Insights is not a synthetic benchmark. Results appear as people use the deployed application, so a new or low-traffic project may take time to show a representative score. ## Read the dashboard Start with the page and device filters, then inspect the metrics that explain the experience: - **LCP** measures how quickly the main content becomes visible. - **INP** measures responsiveness across user interactions. - **CLS** measures unexpected layout movement. - **FCP** measures when the first content is painted. - **TTFB** helps identify slow initial server responses. Use field data to identify a slow page or device class. Reproduce the problem locally with browser performance tools before changing code. ## Control usage The default component sends all supported measurements. If usage becomes material, reduce the sample rate in the existing root-layout integration: ```tsx filename="app/layout.tsx" lineNumbers ``` A value of `0.5` samples approximately half of eligible page views. Keep the default while traffic is low so early measurements are not unnecessarily sparse. ## Verify the integration If no data appears: 1. Confirm Speed Insights is enabled for the correct Vercel project. 2. Confirm the latest deployment contains ``. 3. Visit the deployed application with tracking protection or ad blockers disabled. 4. Check that a reverse proxy or Content Security Policy is not blocking Vercel's collection requests. 5. Allow time for real visits to produce data. See the [Vercel Speed Insights quickstart](https://vercel.com/docs/speed-insights/quickstart) for current provider setup details and the [usage guide](https://vercel.com/docs/speed-insights/managing-usage) before changing the sample rate. --- ## Vercel Analytics **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/observability/vercel **Description**: Learn how to use Vercel Analytics for real-time traffic data. The starter kit already renders **Vercel Web Analytics** from the root layout. You only need to enable the service for your Vercel project and deploy the application. ## What is already configured The repository includes `@vercel/analytics` and renders its Next.js component in `app/layout.tsx`: ```tsx filename="app/layout.tsx" lineNumbers import { Analytics } from '@vercel/analytics/next'; // Inside the root layout body ; ``` This records page views after Web Analytics is enabled. It does not require an environment variable. ## Enable Web Analytics 1. Open your project in the [Vercel dashboard](https://vercel.com/dashboard). 2. Select **Analytics** in the project sidebar. 3. Enable Web Analytics. 4. Deploy the application again so Vercel can add the analytics routes to the deployment. 5. Visit the deployed application, then return to the Analytics dashboard to confirm that data arrives. Dashboard activation is required The package and component are already present, but they do not replace enabling Web Analytics for the Vercel project. ## Track a product event Automatic page views answer traffic questions. Add custom events only for product actions that matter, such as completing onboarding or starting checkout: ```tsx filename="components/checkout-button.tsx" lineNumbers 'use client'; import { track } from '@vercel/analytics'; export function CheckoutButton() { return ( ); } ``` Do not send email addresses, names or other personal data as event properties. ## Verify the integration If the dashboard remains empty: 1. Confirm Web Analytics is enabled for the correct Vercel project. 2. Confirm the deployment was created after activation. 3. Visit the production deployment with tracking protection or ad blockers disabled. 4. Check that requests to `/_vercel/insights/*` are not blocked by a reverse proxy or Content Security Policy. 5. Confirm `app/layout.tsx` still renders ``. Use [Vercel's Web Analytics troubleshooting guide](https://vercel.com/docs/analytics/troubleshooting) for provider-specific diagnostics. ## Privacy and consent Web Analytics is designed without cookies, but privacy obligations depend on your users, configuration and jurisdiction. Document the service in your privacy notice and review [Vercel's privacy guidance](https://vercel.com/docs/analytics/privacy-policy) before launch. Do not treat the default integration as a substitute for legal review. --- ## Configure **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/organizations/configure **Description**: Learn how to configure organizations in your application. Organizations are configured in the Better Auth setup in `lib/auth/index.ts`. The organization plugin is already enabled by default. ## Organization Plugin Configuration The organization plugin is configured in `lib/auth/index.ts`: ```typescript filename="lib/auth/index.ts" lineNumbers import { organization } from 'better-auth/plugins'; export const auth = betterAuth({ // ... other config plugins: [ organization({ // Organization configuration }) ] }); ``` ## Default Behavior By default, organizations are: - **Enabled** - Users can create and join organizations - **Optional** - Users don't need to be in an organization to use the app - **Visible** - Organization selection is shown in the UI - **User-creatable** - Users can create new organizations ## Customizing Organization Behavior ### Require Organization To require users to be in an organization to access the application, you can add middleware or route protection: ```typescript filename="middleware.ts" lineNumbers import { redirect } from 'next/navigation'; import { getSession } from '@/lib/auth/server'; export async function middleware(request: NextRequest) { const session = await getSession(); if (!session) { return redirect('/auth/sign-in'); } // Check if user has an active organization if (!session.session.activeOrganizationId) { // Redirect to organization creation/selection return redirect('/dashboard/onboarding'); } } ``` ### Hide Organization Selection If you want to build a single-tenant application where users should only be members of one organization, you can hide the organization switcher in your UI components. ### Disable Organization Creation To block regular users from creating organizations through the starter's tRPC procedure, change the shipped app configuration: ```typescript filename="config/app.config.ts" lineNumbers export const appConfig = { // ... other config organizations: { allowUserCreation: false } }; ``` The `trpc.organization.create` procedure enforces this setting for non-admin users. Platform admins can still create organizations through that procedure. The setting does not configure Better Auth's organization endpoint. Passing `allowUserToCreateOrganization: false` to that plugin blocks everyone through the direct endpoint. Use a function for an endpoint policy that still permits selected users such as platform admins. ## Shipped Organization Hooks The Better Auth organization plugin is configured in `lib/auth/index.ts`. The shipped integration provides a custom invitation email callback and hooks that synchronize subscription seats after membership changes: ```typescript filename="lib/auth/index.ts" lineNumbers organization({ sendInvitationEmail: async ({ email, inviter, id, organization }) => { // Check plan limits, build the invitation URL and send the email. }, organizationHooks: { afterAddMember: async ({ organization }) => { await syncOrganizationSeats(organization.id); }, afterRemoveMember: async ({ organization }) => { await syncOrganizationSeats(organization.id); }, afterAcceptInvitation: async ({ organization }) => { await syncOrganizationSeats(organization.id); } } }); ``` The repositories do not pass `memberRoles`, `invitation.expiresIn` or a top-level `hooks` object to this plugin. Add only options supported by the installed Better Auth version. ## Invite-Only Organizations There is no single invite-only organization switch. The starter includes member invitations and the `allowUserCreation` setting, but you must compose and enforce the policy your product needs. For an invite-only organization setup: 1. **Disable organization creation** - Remove or restrict the create organization functionality 2. **Require invitations** - Only allow users to join via invitations 3. **Control invitations** - Only allow admins/owners to send invitations The Better Auth organization plugin supplies the invitation workflow. Your application must still enforce who may create organizations and send invitations on every server-side path. ## Best Practices 1. **Use organization slugs** - Use URL-friendly slugs for organization identification 2. **Validate membership** - Always verify user membership before allowing access 3. **Scope data** - Always scope data queries by organization ID 4. **Handle edge cases** - Handle cases where users have no organizations 5. **Role-based access** - Use roles to control what users can do --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/organizations/overview **Description**: Learn how organizations work in the starter kit. Organizations are a way to share data between users. Organizations can have members with different roles and permissions. In the starter kit, organizations are handled by [Better Auth](https://better-auth.com) and therefore you have full control over the organization management and all data is stored in the database. ## How Organizations Work Organizations enable multi-tenancy in your application. Users can: - **Create organizations** - Users can create their own organizations - **Join organizations** - Users can be invited to join organizations - **Switch between organizations** - Users can be members of multiple organizations - **Have different roles** - Users can have different roles in different organizations ## Active Organization The active organization is stored in the Better Auth session. The `activeOrganizationId` is available in `session.activeOrganizationId` and can be accessed using Better Auth's hooks and APIs. This approach provides several benefits: 1. **Session-based** - The active organization persists across page navigations 2. **Simple access** - Use `authClient.useActiveOrganization()` on the client or `getSession()` on the server 3. **Automatic scoping** - tRPC's `protectedOrganizationProcedure` automatically uses the active organization 4. **Consistent state** - The active organization is managed by Better Auth and stays in sync ## Roles Organizations support the following roles: - **Owner** - Full control over the organization - **Admin** - Can manage members and organization settings - **Member** - Can access organization data A user can have different roles in different organizations. ## Default Permission Matrix The shipped settings and billing flows use the following policy: | Action | Owner | Admin | Member | Outsider | | ------------------------ | ----- | ------- | ------ | -------- | | Delete organization | Yes | No | No | No | | Manage billing | Yes | Yes | No | No | | Invite or revoke members | Yes | Yes | No | No | | Change roles | Yes | Limited | No | No | | Upload organization logo | Yes | Yes | No | No | An admin can change member and admin roles, but cannot modify an owner or assign the owner role. Application-level administrators do not automatically receive access to an organization: they must also be a member with the required organization role. Use the shared helpers in `lib/auth/organization-permissions.ts` for custom organization procedures instead of inferring access from the user's global role. --- ## Store Data **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/organizations/store-data **Description**: Learn how to store data for organizations in your application. When working with organizations, you typically want to store data that belongs to each organization and can be accessed by organization members. ## Database Schema ### Add Organization ID to Your Tables Add an `organizationId` field to tables that should be scoped to organizations: ```typescript filename="lib/db/schema/posts.ts" lineNumbers import { pgTable, text, timestamp } from 'drizzle-orm/pg-core'; import { organizations } from './organizations'; import { users } from './users'; export const postsTable = pgTable('posts', { id: text('id').primaryKey(), title: text('title').notNull(), content: text('content').notNull(), authorId: text('authorId') .notNull() .references(() => users.id, { onDelete: 'cascade' }), organizationId: text('organizationId') .notNull() .references(() => organizations.id, { onDelete: 'cascade' }), createdAt: timestamp('createdAt').defaultNow().notNull(), updatedAt: timestamp('updatedAt').defaultNow().notNull() }); export const postsRelations = relations(postsTable, ({ one }) => ({ author: one(users, { fields: [postsTable.authorId], references: [users.id] }), organization: one(organizations, { fields: [postsTable.organizationId], references: [organizations.id] }) })); ``` This allows: - All members of an organization to access the posts - The author to be tracked separately - Data to be properly scoped to organizations ## Creating Organization-Scoped Data ### Using tRPC Use `protectedOrganizationProcedure` to automatically scope data to the active organization: ```typescript filename="trpc/routers/posts.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; import { z } from 'zod'; import { assertUserIsOrgMember } from '@/lib/auth/server'; import { db } from '@/lib/db'; import { postsTable } from '@/lib/db/schema'; export const postsRouter = createTRPCRouter({ create: protectedOrganizationProcedure .input( z.object({ title: z.string().min(1), content: z.string().min(1) }) ) .mutation(async ({ input, ctx }) => { // ctx.organization is guaranteed to exist // User membership is already verified const [post] = await db .insert(postsTable) .values({ title: input.title, content: input.content, authorId: ctx.user.id, organizationId: ctx.organization.id }) .returning(); return post; }) }); ``` ### Verifying Membership If you need to verify membership manually: ```typescript filename="lib/auth/verify-membership.ts" lineNumbers import { assertUserIsOrgMember } from '@/lib/auth/server'; export async function verifyMembership(organizationId: string, userId: string) { // This will throw an error if user is not a member const { organization, membership } = await assertUserIsOrgMember( organizationId, userId ); return { organization, membership }; } ``` ## Querying Organization Data ### List Organization Posts Query posts for the active organization: ```typescript filename="trpc/routers/posts.ts" lineNumbers list: protectedOrganizationProcedure.query(async ({ ctx }) => { // Automatically scoped to ctx.organization.id const posts = await db.query.postsTable.findMany({ where: eq(postsTable.organizationId, ctx.organization.id), orderBy: desc(postsTable.createdAt), }); return posts; }), ``` ### Get Single Post Get a single post, ensuring it belongs to the organization: ```typescript filename="trpc/routers/posts.ts" lineNumbers getById: protectedOrganizationProcedure .input(z.object({ id: z.string() })) .query(async ({ input, ctx }) => { const post = await db.query.postsTable.findFirst({ where: and( eq(postsTable.id, input.id), eq(postsTable.organizationId, ctx.organization.id) ), }); if (!post) { throw new TRPCError({ code: "NOT_FOUND", message: "Post not found", }); } return post; }), ``` ## Client-Side Usage ### Creating Posts Create posts from the UI: ```tsx filename="components/create-post-form.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; import { useForm } from 'react-hook-form'; import { useActiveOrganization } from '@/hooks/use-active-organization'; export function CreatePostForm() { const { activeOrganization } = useActiveOrganization(); const utils = trpc.useUtils(); const createPost = trpc.posts.create.useMutation({ onSuccess: () => { utils.posts.list.invalidate(); } }); const onSubmit = async (data: { title: string; content: string }) => { if (!activeOrganization) { throw new Error('No active organization'); } await createPost.mutateAsync(data); }; return
{/* form fields */}
; } ``` ### Listing Posts List posts for the active organization: ```tsx filename="components/posts-list.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function PostsList() { const { data: posts, isLoading } = trpc.posts.list.useQuery(); if (isLoading) return
Loading...
; if (!posts?.length) return
No posts found
; return (
{posts.map((post) => (

{post.title}

{post.content}

))}
); } ``` ## Updating Organization Data ### Update with Permission Check Only allow admins to update organization-scoped data: ```typescript filename="trpc/routers/posts.ts" lineNumbers update: protectedOrganizationProcedure .input( z.object({ id: z.string(), title: z.string().optional(), content: z.string().optional(), }) ) .mutation(async ({ input, ctx }) => { // Check if user is admin or owner const isAdmin = ctx.membership.role === "admin" || ctx.membership.role === "owner"; const post = await db.query.postsTable.findFirst({ where: and( eq(postsTable.id, input.id), eq(postsTable.organizationId, ctx.organization.id) ), }); if (!post) { throw new TRPCError({ code: "NOT_FOUND" }); } // Only author or admin can update if (post.authorId !== ctx.user.id && !isAdmin) { throw new TRPCError({ code: "FORBIDDEN", message: "You can only edit your own posts or be an admin", }); } const [updatedPost] = await db .update(postsTable) .set({ title: input.title, content: input.content, updatedAt: new Date(), }) .where(eq(postsTable.id, input.id)) .returning(); return updatedPost; }), ``` ## Best Practices 1. **Always scope by organizationId** - Never query without organization scope 2. **Use protectedOrganizationProcedure** - Automatically handles scoping 3. **Verify membership** - Always verify user is a member before operations 4. **Check permissions** - Verify roles before allowing modifications 5. **Cascade deletes** - Use `onDelete: "cascade"` for organization-scoped data 6. **Index organizationId** - Add database indexes on `organizationId` for performance --- ## Use Organizations **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/organizations/use-organizations **Description**: Learn how to use organizations in your application. In the starter kit, the active organization is stored in the Better Auth session. The active organization ID is available in `session.activeOrganizationId` and can be accessed using Better Auth's hooks and APIs. ## How Active Organization Works The active organization is managed by Better Auth and stored in the user's session. When a user switches organizations, the `activeOrganizationId` in the session is updated, and this organization becomes available throughout your application. ## Client-Side Usage ### Using Better Auth's Hook Use `authClient.useActiveOrganization()` to access the active organization: ```tsx filename="components/organization-content.tsx" lineNumbers 'use client'; import { authClient } from '@/lib/auth/client'; export function OrganizationContent() { const { data: activeOrganization, isPending } = authClient.useActiveOrganization(); if (isPending) { return
Loading...
; } if (!activeOrganization) { return
No active organization found
; } return (

{activeOrganization.name}

); } ``` ### Switching Organizations Switch organizations using `authClient.organization.setActive()`: ```tsx filename="components/organization-switcher.tsx" lineNumbers 'use client'; import { useRouter } from 'next/navigation'; import { authClient } from '@/lib/auth/client'; export function OrganizationSwitcher() { const router = useRouter(); const handleSwitch = async (organizationId: string) => { // Set the active organization in Better Auth session await authClient.organization.setActive({ organizationId }); // Navigate to the organization dashboard router.push('/dashboard/organization'); }; return ( ); } ``` ### Getting Active Organization from Session You can also access the active organization ID directly from the session: ```tsx filename="components/example.tsx" lineNumbers 'use client'; import { useSession } from '@/hooks/use-session'; export function Example() { const { session } = useSession(); const activeOrganizationId = session?.activeOrganizationId; return
Active Org ID: {activeOrganizationId}
; } ``` ## Server-Side Usage ### Get Active Organization from Session Get the active organization from the session: ```tsx filename="app/(saas)/dashboard/organization/page.tsx" lineNumbers import { getOrganizationById, getSession } from '@/lib/auth/server'; export default async function OrganizationPage() { const session = await getSession(); if (!session?.session.activeOrganizationId) { return
No active organization
; } const organization = await getOrganizationById( session.session.activeOrganizationId ); if (!organization) { return
Organization not found
; } return
Active organization: {organization.name}
; } ``` ### Get Organization by ID Get organization data for a specific organization ID: ```typescript filename="lib/organization/get-organization.ts" lineNumbers import { getOrganizationById } from '@/lib/auth/server'; export async function getOrganization(organizationId: string) { const organization = await getOrganizationById(organizationId); return organization; } ``` ### Using in tRPC The active organization is automatically available in `protectedOrganizationProcedure`: ```typescript filename="trpc/routers/organization/index.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; export const organizationRouter = createTRPCRouter({ get: protectedOrganizationProcedure.query(async ({ ctx }) => { // ctx.organization is guaranteed to exist // ctx.membership contains the user's role return { organization: ctx.organization, role: ctx.membership.role }; }), update: protectedOrganizationProcedure .input(z.object({ name: z.string() })) .mutation(async ({ input, ctx }) => { // Only allow admins/owners to update if (ctx.membership.role !== 'admin' && ctx.membership.role !== 'owner') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only admins can update organizations' }); } // Update organization using Better Auth API await authClient.organization.update({ organizationId: ctx.organization.id, name: input.name }); return { success: true }; }) }); ``` ## Organization Switching ### Client-Side Switching Switch organizations using Better Auth's API: ```tsx filename="components/organization-switcher.tsx" lineNumbers 'use client'; import { useRouter } from 'next/navigation'; import { authClient } from '@/lib/auth/client'; export function OrganizationSwitcher() { const router = useRouter(); const handleSwitch = async (organizationId: string) => { try { // Set the active organization in Better Auth session await authClient.organization.setActive({ organizationId }); // Navigate to the organization dashboard router.push('/dashboard/organization'); } catch (error) { console.error('Failed to switch organization:', error); } }; return ( ); } ``` ### Server-Side Switching Update the active organization in the session: ```typescript filename="app/api/organization/switch/route.ts" lineNumbers import { headers } from 'next/headers'; import { NextResponse } from 'next/server'; import { auth } from '@/lib/auth'; import { assertUserIsOrgMember, getSession } from '@/lib/auth/server'; export async function POST(request: Request) { const { organizationId } = await request.json(); const session = await getSession(); if (!session) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } // Verify user is member of organization await assertUserIsOrgMember(organizationId, session.user.id); // Update active organization in session await auth.api.setActiveOrganization({ headers: await headers(), body: { organizationId } }); return NextResponse.json({ success: true }); } ``` ## Listing User's Organizations Get all organizations a user is a member of: ```typescript filename="trpc/routers/organization/index.ts" lineNumbers import { createTRPCRouter, protectedProcedure } from '@/trpc/init'; import { asc, eq, getTableColumns } from 'drizzle-orm'; import { db } from '@/lib/db'; import { memberTable, organizationTable } from '@/lib/db/schema'; export const organizationRouter = createTRPCRouter({ list: protectedProcedure.query(async ({ ctx }) => { const organizations = await db .select({ ...getTableColumns(organizationTable), membersCount: db .$count( memberTable, eq(memberTable.organizationId, organizationTable.id) ) .as('membersCount') }) .from(organizationTable) .innerJoin( memberTable, eq(organizationTable.id, memberTable.organizationId) ) .where(eq(memberTable.userId, ctx.user.id)) .orderBy(asc(organizationTable.createdAt)); return organizations.map((org) => ({ ...org, slug: org.slug || '' })); }) }); ``` Client-side: ```tsx filename="components/organizations-list.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function OrganizationsList() { const { data: organizations, isLoading } = trpc.organization.list.useQuery(); if (isLoading) return
Loading...
; return (
{organizations?.map((org) => (
{org.name}
))}
); } ``` ## Best Practices 1. **Always check membership** - Verify user is a member before allowing access 2. **Use protectedOrganizationProcedure** - Automatically handles organization scoping 3. **Handle loading states** - Show loading indicators while fetching organization 4. **Handle missing organizations** - Provide fallback UI when no organization is active 5. **Validate permissions** - Check roles before allowing actions 6. **Use session-based approach** - The active organization is stored in the session, not the URL --- ## Favicons & Icons **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/recipes/favicons **Description**: Learn how to generate and integrate favicons and app icons for your project. ## Generating a favicon 1. Visit [Favicon Generator](https://www.favicon-generator.org/). 2. Upload an image (recommended size: **at least 512×512px** for optimal resizing). 3. Click on **Create Favicon** ## Downloading Click **Download** to save the generated files. ## Copying and overwriting 1. Select all downloaded files, **excluding** `browserconfig.xml` and `manifest.json`. 2. Copy and paste the files into the `public` directory. ## Updating metadata Update the favicon references in `app/layout.tsx`: ```tsx filename="app/layout.tsx" lineNumbers export const metadata = { icons: { icon: '/favicon.ico', apple: '/apple-touch-icon.png' } // ... }; ``` Note that sometimes it takes time for the browser to reflect favicon changes. Try clearing your browser cache or doing a hard refresh. --- ## Fonts **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/recipes/fonts **Description**: Learn how to change fonts using Google Fonts or custom fonts. You can change the font in `app/layout.tsx`. The starter kit currently uses: ```tsx filename="app/layout.tsx" lineNumbers import { Literata } from 'next/font/google'; import { GeistSans } from 'geist/font/sans'; const literata = Literata({ subsets: ['latin'], variable: '--font-literata' }); export default function RootLayout({ children }) { return ( {/* ... */} ); } ``` To change to a different Google Font, for example `Inter`: ```tsx filename="app/layout.tsx" lineNumbers import { Inter } from 'next/font/google'; import { GeistSans } from 'geist/font/sans'; const inter = Inter({ subsets: ['latin'], variable: '--font-inter' }); export default function RootLayout({ children }) { return ( {/* ... */} ); } ``` Or use a custom font: ```tsx filename="app/layout.tsx" lineNumbers import localFont from 'next/font/local'; const customFont = localFont({ src: './fonts/custom-font.woff2', display: 'swap' }); ``` ## Font Variables Font variables are automatically available via the `variable` prop. You can use them in your CSS: ```css filename="app/globals.css" lineNumbers :root { --font-sans: var(--font-geist-sans); --font-literata: var(--font-literata); } ``` Then use them in your components: ```tsx filename="components/example.tsx" lineNumbers
This uses Geist Sans
This uses Literata
``` --- ## Naming & Branding **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/recipes/naming **Description**: Learn how to change the app name, description, and metadata throughout your application. ## App Name and Description The app name and description are used throughout the application. Update them in the configuration file: ```typescript filename="config/app.config.ts" lineNumbers export const appConfig = { appName: 'Your App Name', description: 'A fantastic SaaS to make your life easier.', baseUrl: 'https://yourdomain.com' // ... }; ``` This configuration is used in: - Navigation and headers - Email templates - SEO metadata - Social sharing ## Metadata The metadata in `app/layout.tsx` automatically uses values from `appConfig`: ```typescript filename="app/layout.tsx" lineNumbers import { appConfig } from '@/config/app.config'; export const metadata: Metadata = { metadataBase: new URL(appConfig.baseUrl), title: { absolute: appConfig.appName, default: appConfig.appName, template: `%s | ${appConfig.appName}` }, description: appConfig.description, openGraph: { type: 'website', locale: 'en_US', siteName: appConfig.appName, title: appConfig.appName, description: appConfig.description }, twitter: { card: 'summary_large_image', title: appConfig.appName, description: appConfig.description } }; ``` ## Package.json Update the name and description in `package.json`: ```json filename="package.json" lineNumbers { "name": "your-app-name", "version": "1.0.0", "description": "Your app description", "author": "Your Name", "license": "MIT" // ... } ``` ## Environment Variables The `baseUrl` in `appConfig` uses `getBaseUrl()` which reads from `NEXT_PUBLIC_SITE_URL` if set, otherwise falls back to the request URL. You can set it in your `.env`: ```env filename=".env" lineNumbers NEXT_PUBLIC_SITE_URL=https://yourdomain.com ``` ## Email Branding Update email templates to reflect your branding. Email templates are located in `lib/email/templates/`: ```typescript filename="lib/email/templates/welcome-email.tsx" lineNumbers export function WelcomeEmail({ name }: { name: string }) { return ( Welcome to Your App Name! Welcome to Your App Name! Hi {name}, Welcome to Your App Name! We're excited to have you. ); } ``` --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/recipes/overview **Description**: Common recipes and guides to help you extend your application. Recipes are step-by-step guides for common tasks and integrations that help you extend your application with additional features and services. --- ## Supabase Setup **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/recipes/supabase-setup **Description**: Learn how to set up your application with Supabase as the database and storage provider. In this guide, we'll show you how to set up your application with Supabase as the database and storage provider. Note We will use Supabase as the database and storage provider. The authentication feature of Supabase is not used, as we use Better Auth for authentication, which stores user data directly in your database. Before we start, make sure you have a Supabase account. If you don't have one yet, you can create one for free at [supabase.com](https://supabase.com/). ## 1. Create a new Supabase project 1. Go to [supabase.com](https://supabase.com/) and sign in 2. Click **New Project** 3. Fill in your project details: - **Name**: Your project name - **Database Password**: Choose a strong password (save this!) - **Region**: Choose a region close to your deployment (see note below) Region Selection Make sure your application and database are physically close. If you use Vercel and Supabase, make sure they are in the same AWS region. For example: - Vercel (D.C.) and Supabase (Virginia): Both are in the us-east-1 region, which results in a fast application - Vercel (D.C.) and Supabase (Ohio): You are dealing with two different AWS regions, the app can be up to 3-12x slower! ## 2. Get connection strings In the Supabase dashboard, click the **Connect** button in the top row. Select the **ORM** tab and **Drizzle** as the tool. You will need the `DATABASE_URL`. For Drizzle, you can use the connection pooling URL or the direct connection URL. ## 3. Set environment variables Open your `.env` file and set the environment variables as follows: ### For Connection Pooling (Recommended) ```env filename=".env" lineNumbers # Connection pooling URL (recommended for production) DATABASE_URL="postgres://postgres.[your-supabase-project]:[password]@aws-0-[aws-region].pooler.supabase.com:6543/postgres?pgbouncer=true" ``` ### For Direct Connection ```env filename=".env" lineNumbers # Direct connection URL (for migrations and development) DATABASE_URL="postgresql://postgres:[password]@db.[your-project-ref].supabase.co:5432/postgres" ``` Important Make sure to replace the password and project ref placeholders with your own values from the Supabase dashboard. ## 4. Run migrations To push the database schema to Supabase, run the following command: ```bash filename="Terminal" lineNumbers npm run db:push ``` Or if you prefer to use migrations: ```bash filename="Terminal" lineNumbers npm run db:generate npm run db:migrate ``` Database access and RLS The shipped app uses Drizzle on the server. It does not configure the Supabase Data API or map Better Auth sessions or JWT claims into RLS policies. Better Auth does not replace RLS. If you expose the Data API or query Supabase from client code, design and test RLS policies for every exposed table first. ## 5. Connect Supabase storage for file uploads To enable the shipped user avatar and organization logo uploads, you can use Supabase Storage through its S3-compatible endpoint. ### Create a storage bucket 1. Go to the **Storage** tab in the Supabase dashboard 2. Click the **Create bucket** button 3. Name the bucket, for example `avatars` 4. Deactivate the **Public bucket** switch to prevent direct anonymous object access 5. Optionally, define a maximum file size and restrict file types for this bucket Application access remains public by key The private bucket setting does not add authorization to the starter kit. The shipped /storage/[...path] route does not read a session or check ownership. Anyone who knows an image key can ask the application for a signed download redirect. Add a protected route and file ownership metadata before storing private files. ### Get storage credentials 1. Navigate to **Project settings** from the sidebar 2. Select the **Storage** tab 3. Scroll down to the **S3 access keys** section 4. Click the **New access key** button 5. Enter a description for your access key 6. After clicking **Create access key**, copy the **Access key ID** and **Secret access key** ### Configure environment variables Add the following environment variables to your `.env` file: ```env filename=".env" lineNumbers S3_ACCESS_KEY_ID="your-access-key" S3_SECRET_ACCESS_KEY="your-secret-key" S3_ENDPOINT="https://[YOUR-PROJECT-REF].storage.supabase.co/storage/v1/s3" S3_REGION="[YOUR-PROJECT-REGION]" NEXT_PUBLIC_IMAGES_BUCKET_NAME="avatars" ``` Copy the endpoint and region shown with the S3 access keys in the Supabase dashboard. The region participates in request signing, so do not substitute a region from another project. ## 6. Confirm storage configuration No storage code change is required for the bucket name. The shipped configuration reads `NEXT_PUBLIC_IMAGES_BUCKET_NAME`: ```typescript filename="config/storage.config.ts" lineNumbers import { env } from '@/lib/env'; export const storageConfig = { bucketNames: { images: env.NEXT_PUBLIC_IMAGES_BUCKET_NAME ?? '' } } satisfies StorageConfig; ``` ## 7. Run development server Now you should be able to start the development server: ```bash filename="Terminal" lineNumbers npm run dev ``` ## Troubleshooting ### Connection issues If you're experiencing connection issues: 1. Verify your connection string is correct 2. Check that your IP is allowed in Supabase (if IP restrictions are enabled) 3. Ensure you're using the correct region 4. Try using the direct connection URL instead of the pooled connection ### Migration issues If migrations fail: 1. Make sure you're using the direct connection URL for migrations 2. Check that your database password is correct 3. Verify that your project has the necessary permissions ### Storage issues If file uploads aren't working: 1. Verify your S3 credentials are correct 2. Check that the bucket exists and is accessible 3. Ensure the bucket name matches your configuration 4. Verify the endpoint URL is correct That's all it takes to set up your application with Supabase! If you have questions or need help, refer to the [Supabase documentation](https://supabase.com/docs). --- ## Theming & Styling **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/recipes/theming **Description**: Learn how to customize colors, themes, and styling with Tailwind CSS and shadcn/ui. The starter kit uses [Tailwind CSS](https://tailwindcss.com) for styling and [shadcn/ui](https://ui.shadcn.com) components backed by [Base UI](https://base-ui.com/). This gives you complete control over the visual appearance of your application while keeping accessible interaction behavior in unstyled primitives. ## Color System The color system is based on CSS variables defined in `app/globals.css`: ```css filename="app/globals.css" lineNumbers :root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; --secondary: 210 40% 96.1%; --secondary-foreground: 222.2 47.4% 11.2%; --muted: 210 40% 96.1%; --muted-foreground: 215.4 16.3% 46.9%; --accent: 210 40% 96.1%; --accent-foreground: 222.2 47.4% 11.2%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 222.2 84% 4.9%; --radius: 0.5rem; } .dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; /* ... dark mode colors */ } ``` ## Tailwind Configuration The starter kit uses Tailwind CSS v4, which uses CSS-based configuration instead of a config file. All configuration is done in `app/globals.css`: ```css filename="app/globals.css" lineNumbers @import 'tailwindcss'; @import 'tw-animate-css'; /* Specify content paths */ @source "./**/*.{ts,tsx}"; @source "../components/**/*.{ts,tsx}"; @source "../lib/**/*.{ts,tsx}"; @source "../hooks/**/*.{ts,tsx}"; /* Custom dark mode variant */ @custom-variant dark (&:is(.dark *)); /* Define theme values */ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); } ``` To add new content paths, add more `@source` directives. To customize theme values, add them to the `@theme inline` block. ## shadcn/ui Components The starter kit uses the Base UI variant of [shadcn/ui](https://ui.shadcn.com). The source lives in `components/ui/`, so you can change the styles and composition directly. Base UI supplies the accessible behavior for dialogs, menus, selects, tooltips and other interactive primitives. The repository's `components.json` sets `base-nova` as its shadcn style. The CLI therefore installs compatible Base UI components instead of Radix variants. ### Installing Components You can install additional components using the shadcn CLI: ```bash filename="Terminal" lineNumbers npx shadcn@latest add button npx shadcn@latest add card npx shadcn@latest add dialog ``` ### Customizing Components Components are located in `components/ui/` and can be customized directly: ```tsx filename="components/ui/button.tsx" lineNumbers import type * as React from 'react'; import { Button as ButtonPrimitive } from '@base-ui/react/button'; export type ButtonProps = ButtonPrimitive.Props & { loading?: boolean; }; function Button({ children, loading = false, ...props }: ButtonProps) { return ( {loading ? 'Loading…' : children} ); } export { Button }; ``` Base UI uses the `render` prop for composition. When an installed component documents `render`, prefer it over Radix's former `asChild` pattern: ```tsx filename="components/example-dialog.tsx" lineNumbers }>Open dialog ``` ## Dark Mode Dark mode is automatically handled by the theme system. Users can toggle between light and dark themes: ```tsx filename="components/theme-toggle.tsx" lineNumbers 'use client'; import { useTheme } from 'next-themes'; import { Button } from '@/components/ui/button'; export function ThemeToggle() { const { theme, setTheme } = useTheme(); return ( ); } ``` ## Global Styles Customize global styles in `app/globals.css`: ```css filename="app/globals.css" lineNumbers @import 'tailwindcss'; @layer base { * { @apply border-border; } body { @apply bg-background text-foreground; } } ``` ## Custom Themes You can create custom themes by modifying the CSS variables: ```css filename="app/globals.css" lineNumbers [data-theme='custom'] { --primary: 142 76% 36%; --primary-foreground: 355 100% 97%; /* ... other custom colors */ } ``` Then apply the theme: ```tsx filename="app/layout.tsx" lineNumbers {/* ... */} ``` --- ## Setup **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/setup **Description**: Get your Pro Next.js Drizzle project up and running in less than 30 minutes. This guide will walk you through the steps to set up your project locally and start developing. ## Prerequisites Install these before cloning the repository: - Node.js `22.21.1`, matching the version in `package.json` - npm, included with Node.js - Git - Docker Desktop or another PostgreSQL 17 installation - A Resend account for password signups. A verified domain is required before sending to arbitrary recipients, but Resend's test sender can be used with your own account email during initial local setup. Start from a clean baseline Complete this guide and verify the included application before renaming the product, replacing providers or moving routes. This keeps setup failures separate from customization failures. ## Step 1: Clone the Repository Clone the project to your local machine: ```bash filename="Terminal" lineNumbers git clone my-saas-app cd my-saas-app ``` ## Step 2: Install Dependencies We use `npm` for dependency management: ```bash filename="Terminal" lineNumbers npm install ``` ## Step 3: Configure Environment Variables Copy the example environment file: ```bash filename="Terminal" lineNumbers cp .env.example .env ``` Generate a secret for Better Auth and add it to `.env`: You can use the generated value above or create one from your terminal: ```bash filename="Terminal" lineNumbers openssl rand -base64 32 ``` ```ini filename=".env" lineNumbers BETTER_AUTH_SECRET="paste-the-generated-secret-here" ``` ## Step 4: Database Setup 1. Make sure you have PostgreSQL running. We provide a `docker-compose.yml` for convenience: ```bash filename="Terminal" lineNumbers npm run docker:up ``` 2. The database is automatically created by Docker Compose. If you're using a local PostgreSQL installation, create the database: ```bash filename="Terminal" lineNumbers createdb database ``` 3. Update `DATABASE_URL` in `.env` to match your local setup: ```ini filename=".env" lineNumbers # For Docker (default): DATABASE_URL="postgresql://postgres:password@localhost:5432/database" # For local PostgreSQL: DATABASE_URL="postgresql://your_user:your_password@localhost:5432/database" ``` 4. Apply the migrations committed with the starter kit: ```bash filename="Terminal" lineNumbers npm run db:migrate ``` Start from the committed migration history A fresh clone already contains the migrations required by the shipped schema. Applying them keeps your local database aligned with staging and production. After changing the TypeScript schema, generate a new SQL migration and apply it: ```bash filename="Terminal" lineNumbers npm run db:generate npm run db:migrate ``` Commit the generated files in `lib/db/migrations/` with the schema change. On staging and production, run only `npm run db:migrate` against the target database. Do not generate new migrations during deployment. Use `npm run db:push` only for disposable local prototyping when you deliberately do not need a migration file. It must not replace the initial migration step or be used against staging or production. ## Step 5: Configure Email Email verification is required for password signups. Configure `RESEND_API_KEY` and `EMAIL_FROM` before creating an account. For the quickest local check, use `onboarding@resend.dev` as the sender and sign up with the email address attached to your Resend account. Verify a domain before testing other recipients or deploying the application. Follow the [email configuration guide](/docs/starter-kits/pro-nextjs-drizzle/email/configuration) to create a Resend API key and verify your sending domain. ## Step 6: Start Development Server ```bash filename="Terminal" lineNumbers npm run dev ``` Open [http://localhost:3000](http://localhost:3000) - your app is running! ## Step 7: Create Your First Account 1. Go to [http://localhost:3000/auth/sign-up](http://localhost:3000/auth/sign-up) 2. Enter your name, email and password 3. Open the verification email sent through Resend 4. Click the link to verify your email 5. You're in! ## Step 8: Verify the Baseline Before customizing the product, confirm that the repository passes its included quality checks: ```bash filename="Terminal" lineNumbers npm run typecheck npm run lint npm run format npm run test -- --run ``` The explicit `--run` makes Vitest execute once and exit instead of opening its local watch workflow. Then verify these flows in the browser: - Create and verify an account - Create an organization - Invite a second member if you have another test email - Open account and organization settings - Confirm the dashboard loads without server errors The repository also includes authenticated Playwright coverage for sign-in, organizations, settings, two-factor authentication, AI credit enforcement and the admin area. Point `DATABASE_URL` at an isolated disposable test database, install the browser once and run the suite: ```bash filename="Terminal" lineNumbers npm run test:e2e:setup npm run test:e2e ``` The E2E seed resets deterministic users and authentication state. Never run it against a development database containing data you need or against any production database. See the [E2E testing guide](/docs/starter-kits/pro-nextjs-drizzle/tests/e2e) for the fixture accounts and browser workflow. ## Step 9: Make Yourself an Admin The first user should be a platform admin to access the admin dashboard (`/dashboard/admin`). **Option A: Using Drizzle Studio (Recommended)** ```bash filename="Terminal" lineNumbers # Open Drizzle Studio npm run db:studio ``` 1. Open [https://local.drizzle.studio](https://local.drizzle.studio) in your browser 2. Click on the `user` table 3. Find your user and click to edit 4. Change `role` from `user` to `admin` 5. Save **Option B: Using SQL directly** ```bash filename="Terminal" lineNumbers # If using Docker (container name may vary based on directory name): docker compose exec postgres psql -U postgres -d database # If using local PostgreSQL: psql -d database # Then run: UPDATE "user" SET role = 'admin' WHERE email = 'your@email.com'; \q ``` Now you can access the admin panel at [http://localhost:3000/dashboard/admin](http://localhost:3000/dashboard/admin). ## Next Steps Keep the verified baseline working while you turn it into your product: 1. Bookmark the [common commands](/docs/starter-kits/pro-nextjs-drizzle/codebase/commands) used for development, tests and local services. 2. Update the product name, theme and assets using the [customization guide](/docs/starter-kits/pro-nextjs-drizzle/customization/overview). 3. Enable only the providers your product needs in [configuration](/docs/starter-kits/pro-nextjs-drizzle/configuration). 4. Add or change the product-specific data model through the [Drizzle database guide](/docs/starter-kits/pro-nextjs-drizzle/database). 5. Complete the [production deployment checklist](/docs/starter-kits/pro-nextjs-drizzle/deployment) before inviting real users. Change one subsystem at a time Keep authentication, email and the database working while you customize the product. Run the typecheck, lint and test commands after each meaningful change so failures remain easy to trace. --- ## Storage **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/storage **Description**: Understand the image storage integration that ships with the Pro Next.js Drizzle starter kit. The starter kit includes an S3-compatible image storage integration for user avatars and organization logos. It is configured for Cloudflare R2 and can be adapted to another provider that supports the S3 API. Security boundary The upload signer requires an authenticated user. The download route does not. The shipped /storage/[...path] route will generate a one-hour signed download URL for anyone who knows a valid image key. A signed URL limits how long storage credentials can be used, but it does not prove file ownership or organization membership. ## What ships - `lib/storage/s3.ts` creates an S3 client and presigns `PutObject` and `GetObject` commands. - `trpc/routers/storage/index.ts` exposes an authenticated `storage.signedUploadUrl` mutation for the configured images bucket. - `app/storage/[...path]/route.ts` exposes a public image redirect route. - `hooks/use-storage.tsx` converts a stored image key into the public route URL. - Avatar and organization logo components crop an image, upload it directly and save its key through Better Auth. The included flow is intended for display images such as avatars and logos. The generated UUID-based keys make accidental discovery less likely, but an unguessable key is not authorization. ## What does not ship The repositories do not include: - A file metadata table - User or organization ownership checks for storage objects - Private download, listing or deletion procedures - File quota enforcement, malware scanning or audit logs - Automatic object deletion when an avatar or logo is removed - Server-enforced file size or MIME type validation Add those controls before using the storage integration for invoices, exports, identity documents or other private files. See [Access Files](/docs/starter-kits/pro-nextjs-drizzle/storage/access) for the required design changes. ## Environment variables ```env filename=".env" lineNumbers S3_ACCESS_KEY_ID="your-access-key" S3_SECRET_ACCESS_KEY="your-secret-key" S3_ENDPOINT="https://your-s3-endpoint" S3_REGION="auto" NEXT_PUBLIC_IMAGES_BUCKET_NAME="your-images-bucket" ``` `NEXT_PUBLIC_IMAGES_BUCKET_NAME` is public configuration. Keep the access key and secret key server-only. --- ## Access Files **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/storage/access **Description**: Understand the shipped public image route and how to add private file authorization. The kit stores image object keys rather than permanent provider URLs. `useStorage` converts those keys into a URL handled by the application. ## Shipped behavior The current route is `app/storage/[...path]/route.ts`: ```typescript filename="app/storage/[...path]/route.ts" lineNumbers import { NextResponse } from 'next/server'; import { storageConfig } from '@/config/storage.config'; import { getSignedUrl } from '@/lib/storage'; export const GET = async ( _req: Request, { params }: { params: Promise<{ path: string[] }> } ) => { const { path } = await params; const [bucket, filePath] = path; if (!(bucket && filePath)) { return new Response('Invalid path', { status: 400 }); } if (bucket === storageConfig.bucketNames.images) { const signedUrl = await getSignedUrl(filePath, bucket, 60 * 60); return NextResponse.redirect(signedUrl, { headers: { 'Cache-Control': 'max-age=3600' } }); } return new Response('Not found', { status: 404 }); }; ``` This route: - Is public and does not read a session - Allows only the configured images bucket - Generates a signed `GetObject` URL that expires after one hour - Caches the redirect for up to one hour - Does not query a file record or verify user or organization ownership Anyone who knows a valid key can request a signed download URL through this route. Keeping the bucket itself private prevents direct anonymous bucket access, but it does not make this application route private. ## Using `useStorage` ```tsx filename="components/user/user-avatar.tsx" lineNumbers import { useStorage } from '@/hooks/use-storage'; export function Image({ imageKey }: { imageKey: string }) { const src = useStorage(imageKey); return ( ); } ``` For a local key, the hook returns: ```text /storage/{NEXT_PUBLIC_IMAGES_BUCKET_NAME}/{imageKey} ``` If the value starts with `http`, the hook returns it unchanged. If the value is empty, it returns the optional fallback. ## Flat keys only Although the route uses a catch-all segment, the shipped handler reads only the first two segments: ```typescript const [bucket, filePath] = path; ``` The included avatar and logo components therefore use flat keys such as `user-id-uuid.png`. A nested key such as `users/user-id/avatar.png` will not be reconstructed by the current route. To support nested keys, change the handler to read `[bucket, ...filePath]` and join the remaining segments after validation. ## Signed URLs are not authorization A signed URL is a temporary bearer credential. Anyone who receives it can use it until it expires. Signing a URL proves that your server authorized the storage operation, but the shipped public route does not decide whether the requester owns the object. The included route is suitable for avatars and logos that are expected to be visible. Do not use it for private documents or tenant-confidential exports. ## Adding private file access The following work is a customization. It is not included in either Pro repository. 1. Add a file metadata table with the object key, bucket, owner or organization ID, content type, byte size and lifecycle status. 2. Create object keys on the server from the authenticated user or active organization. Do not accept an unrestricted owner prefix from the client. 3. Replace the public image route for private files with a protected tRPC procedure or route handler. 4. Load the file record and verify current organization membership and resource permission before signing a short-lived download URL. 5. Use private cache headers or `no-store` for protected redirects. 6. Add rate limits, access logs and deletion cleanup for your requirements. Keep public display images and private documents in separate buckets or separate route policies. This makes it harder to expose a private object through the convenience image route. ## Listing and deleting The storage module exports only `getSignedUploadUrl` and `getSignedUrl`. It does not export the S3 client and there are no shipped list or delete procedures. Removing an avatar or organization logo clears the database reference but does not delete the object from storage. Implement listing, deletion and orphan cleanup only after adding file metadata and ownership checks. Examples that refer to a `File` model, `filesTable`, `verifyFileAccess` or `storageService.getS3Client()` are custom designs rather than repository APIs. --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/storage/overview **Description**: Learn how the shipped S3-compatible image storage flow works. The Pro Next.js Drizzle kit ships a focused image storage flow for avatars and organization logos. Both Pro kits use the same storage implementation. ## Upload flow 1. A signed-in user selects and crops an image in the browser. 2. The client calls `storage.signedUploadUrl` with an object key and the configured images bucket. 3. The protected tRPC procedure checks only that the bucket equals `storageConfig.bucketNames.images`. 4. `getSignedUploadUrl` validates the key syntax and returns a `PutObject` URL that expires after 60 seconds. 5. The browser uploads directly to the storage provider. 6. Better Auth stores the object key in the user `image` field or organization `logo` field. The server does not create an ownership record for the object. It also does not derive the key from the authenticated user, enforce a size limit or inspect the uploaded bytes. ## Read flow 1. `useStorage(image)` returns `/storage/{imagesBucket}/{image}` for a local image key. 2. The public route reads the bucket and image key from the URL. 3. If the bucket matches the configured images bucket, it returns a redirect to a signed `GetObject` URL. 4. The signed URL and redirect cache both use a one-hour lifetime. The route does not read the current session. It does not query the database or verify ownership. This is suitable for product images that are intended to be displayed wherever their key is known, not for confidential files. The upload signer accepts keys containing slashes, but the current read route destructures only the bucket and the first segment after it. Avatar and logo keys must therefore be flat, such as 550e8400.png. If you need nested keys such as users/123/avatar.png, update the route to join all remaining path segments before requesting the object. ## Included functions ```typescript filename="lib/storage/s3.ts" lineNumbers getSignedUploadUrl(path, bucket); // PutObject URL, 60 seconds getSignedUrl(path, bucket, expiresIn); // GetObject URL ``` Both functions validate that a path: - Is not absolute - Contains no `..`, null byte or hidden path segment - Uses only letters, numbers, hyphens, underscores, slashes and dots Path validation prevents malformed object keys. It is not a user or organization authorization check. ## Current scope | Capability | Shipped behavior | | ---------------------------- | ---------------- | | Avatar and logo upload | Included | | Direct browser upload | Included | | Authenticated upload signing | Included | | Public image redirect | Included | | Private file authorization | Not included | | File ownership metadata | Not included | | Listing and deletion APIs | Not included | | Storage quota enforcement | Not included | | Nested-key download routing | Not included | Use the included integration as a starting point for public display images. Build a separate authorized download flow for private files. --- ## Setup **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/storage/setup **Description**: Configure the shipped S3-compatible image storage integration. The storage implementation uses `S3Client` from the AWS SDK with a configurable endpoint and `forcePathStyle: true`. Cloudflare R2 is the documented default. Other S3-compatible providers can work, but you must verify their endpoint, path-style support and CORS behavior. ## Environment variables The repositories read these exact names. `S3_REGION` is optional and defaults to `auto`; the other values are required for the included image flow. ```env filename=".env" lineNumbers S3_ACCESS_KEY_ID="your-access-key" S3_SECRET_ACCESS_KEY="your-secret-key" S3_ENDPOINT="https://your-s3-endpoint" S3_REGION="auto" NEXT_PUBLIC_IMAGES_BUCKET_NAME="your-images-bucket" ``` There is no `S3_BUCKET` environment variable in the shipped configuration. `NEXT_PUBLIC_IMAGES_BUCKET_NAME` supplies the only configured bucket name. ## Cloudflare R2 ### 1. Create a bucket 1. Open the Cloudflare dashboard and select **R2 Object Storage**. 2. Create a bucket for avatars and organization logos. 3. Keep direct public bucket access disabled. The application still exposes a public image redirect route. A private bucket stops direct anonymous requests to R2, but it does not add user or organization checks to `/storage/[...path]`. ### 2. Create credentials Create an R2 API token with object read and write permission scoped to this bucket. The current code signs `PutObject` and `GetObject` operations. It does not need account-wide administration permission. Copy the access key ID and secret access key when the token is created. ### 3. Configure the endpoint ```env filename=".env" lineNumbers S3_ACCESS_KEY_ID="your-r2-access-key-id" S3_SECRET_ACCESS_KEY="your-r2-secret-access-key" S3_ENDPOINT="https://.r2.cloudflarestorage.com" S3_REGION="auto" NEXT_PUBLIC_IMAGES_BUCKET_NAME="my-app-images" ``` ### 4. Configure CORS Direct browser uploads are part of the shipped avatar and logo flow, so the bucket must allow `PUT` from every application origin you use. ```json filename="R2 CORS policy" lineNumbers [ { "AllowedOrigins": ["http://localhost:3000", "https://yourdomain.com"], "AllowedMethods": ["GET", "PUT"], "AllowedHeaders": ["Content-Type"], "ExposeHeaders": ["ETag"], "MaxAgeSeconds": 3600 } ] ``` Replace the example production origin. Do not use `*` for production origins unless your application intentionally accepts uploads from every website. ## AWS S3 Create a private bucket and credentials that are limited to the required object operations. A minimal starting policy for the shipped flow is: ```json filename="IAM policy" lineNumbers { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject"], "Resource": "arn:aws:s3:::YOUR_BUCKET/*" } ] } ``` Configure the same CORS origins and methods on the bucket, then set: ```env filename=".env" lineNumbers S3_ACCESS_KEY_ID="your-aws-access-key-id" S3_SECRET_ACCESS_KEY="your-aws-secret-access-key" S3_ENDPOINT="https://s3.us-east-1.amazonaws.com" S3_REGION="us-east-1" NEXT_PUBLIC_IMAGES_BUCKET_NAME="my-app-images" ``` Use your bucket region in both the endpoint and `S3_REGION`. ## Other providers DigitalOcean Spaces, MinIO and providers with an S3 gateway require the same five variables. Provider compatibility is not abstracted behind separate adapters. The single client in `lib/storage/s3.ts` always sets `forcePathStyle: true`, so change that option if your provider requires virtual-hosted bucket URLs. For Supabase's S3 gateway, follow the [Supabase setup guide](/docs/starter-kits/pro-nextjs-drizzle/recipes/supabase-setup) and keep the public application route limitation in mind. ## Storage configuration The bucket is exposed through `config/storage.config.ts`: ```typescript filename="config/storage.config.ts" lineNumbers import { env } from '@/lib/env'; export const storageConfig = { bucketNames: { images: env.NEXT_PUBLIC_IMAGES_BUCKET_NAME ?? '' } } satisfies StorageConfig; ``` The S3 client and signing functions are in `lib/storage/s3.ts`. There is no `lib/storage/service.ts` or `storageService` object. ## Verify the complete flow Generating a presigned URL happens locally and does not prove that the credentials or bucket are valid. Test the full shipped flow: 1. Start the application with the storage variables set. 2. Sign in and upload a user avatar or organization logo. 3. Confirm that the direct `PUT` request returns a successful status. 4. Confirm that the object key is saved to the user or organization record. 5. Request `/storage/{bucket}/{key}` and confirm that it redirects and displays the image. Keep `{key}` to one URL segment with the shipped route. Although the signer validates nested S3 keys, `/storage/[...path]` currently reads only the first segment after the bucket. Extend that route before introducing folder-style keys. If the upload returns a signature error, verify the endpoint, region, clock and request content type. The current signer uses `image/jpeg`, while the included crop components send `image/png`. Some providers require those values to match. --- ## Upload Files **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/storage/upload **Description**: Use the shipped image upload flow and understand the validation you must add for other files. Before uploading images, [configure the storage provider](/docs/starter-kits/pro-nextjs-drizzle/storage/setup). ## Shipped upload flow The repository includes purpose-specific tRPC mutations that return presigned `PutObject` URLs. The server derives the object key from the authenticated user or active organization: ```typescript filename="trpc/routers/storage/index.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure, protectedProcedure } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; import { storageConfig } from '@/config/storage.config'; import { canUploadOrganizationLogo } from '@/lib/auth/organization-permissions'; import { getSignedUploadUrl } from '@/lib/storage'; export const storageRouter = createTRPCRouter({ userAvatarUploadUrl: protectedProcedure.mutation(async ({ ctx }) => { const path = `${ctx.user.id}-${crypto.randomUUID()}.png`; const signedUrl = await getSignedUploadUrl( path, storageConfig.bucketNames.images ); return { path, signedUrl }; }), organizationLogoUploadUrl: protectedOrganizationProcedure.mutation( async ({ ctx }) => { if (!canUploadOrganizationLogo(ctx.membership.role)) { throw new TRPCError({ code: 'FORBIDDEN' }); } const path = `logo-${ctx.organization.id}-${crypto.randomUUID()}.png`; const signedUrl = await getSignedUploadUrl( path, storageConfig.bucketNames.images ); return { path, signedUrl }; } ) }); ``` The avatar procedure requires a signed-in user. The organization-logo procedure additionally requires owner or admin membership in the active organization. Neither procedure accepts a client-selected bucket or object path. The avatar and organization logo components then: 1. Accept PNG or JPEG input in the browser. 2. Open `CropImageModal` and produce a cropped image blob. 3. Request a signed upload URL and server-generated `.png` object key. 4. Upload the blob directly with `PUT`. 5. Save the object key to Better Auth after the upload succeeds. ## Requesting an upload URL ```tsx filename="components/example-image-upload.tsx" lineNumbers const { path, signedUrl } = await trpc.storage.userAvatarUploadUrl.mutateAsync(); const response = await fetch(signedUrl, { method: 'PUT', body: imageBlob, headers: { 'Content-Type': 'image/png' } }); if (!response.ok) { throw new Error('Failed to upload image'); } ``` This example mirrors the included avatar and logo components. It is not a generic file upload API. ## Current validation `getSignedUploadUrl` rejects absolute paths, hidden path segments, null bytes, `..` and characters outside its allowlist. It signs the URL for 60 seconds. The shipped server derives avatar and organization-logo keys from the authenticated context. It does not: - Enforce a maximum byte size - Inspect the uploaded file contents - Create file metadata or enforce storage plan limits The browser file picker accepts image types, but client validation is not a security boundary. ## Content type detail The current `PutObjectCommand` sets `ContentType` to `image/jpeg`, while the included crop upload components send `Content-Type: image/png`. Providers can enforce signed headers differently. If uploads fail with a signature mismatch, make the signer and client use the same content type. When adding multiple upload types, accept a small server-validated content type enum and pass the validated value into `PutObjectCommand`. Do not forward an arbitrary header from the client. ## Production hardening The following controls are customizations and do not ship in the repository: 1. Validate an allowed content type and file size before signing. 2. Enforce provider-side upload limits where your S3-compatible provider supports them. 3. Add a file metadata record with ownership and an upload lifecycle state. 4. Confirm the object after upload before marking the record ready. 5. Add rate limits, quotas, malware scanning and orphan cleanup as required. Do not add private document uploads to the existing image signer without also implementing the authorized read flow described in [Access Files](/docs/starter-kits/pro-nextjs-drizzle/storage/access). --- ## Tech Stack **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/tech-stack **Description**: An overview of the modern and powerful technologies used in the Pro Next.js Drizzle starter kit. We've selected the best-in-class tools to provide a professional, scalable, and high-performance foundation for your SaaS. ## Core Framework - **[Next.js](https://nextjs.org/)**: The React framework for the web, using the App Router for modern features like Server Components and Streaming. - **[TypeScript](https://www.typescriptlang.org/)**: For end-to-end type safety and an excellent developer experience. ## Database & Authentication - **[Drizzle ORM](https://orm.drizzle.team/)**: A lightweight, high-performance TypeScript ORM for SQL databases. - **[PostgreSQL](https://www.postgresql.org/)**: The world's most advanced open-source relational database. - **[Better Auth](https://better-auth.com/)**: A robust and flexible authentication library for Next.js. ## API & State Management - **[tRPC](https://trpc.io/)**: For building end-to-end type-safe APIs without the boilerplate. - **[TanStack Query](https://tanstack.com/query/latest)**: For powerful data fetching, caching, and state management on the client. ## Styling & UI - **[Tailwind CSS](https://tailwindcss.com/)**: A utility-first CSS framework for rapid UI development. - **[shadcn/ui](https://ui.shadcn.com/)**: Beautifully designed components built with Base UI and Tailwind CSS. - **[Lucide React](https://lucide.dev/)**: Flexible and beautiful icons. ## Advanced Features - **[Stripe](https://stripe.com/en-ch)**: For payments and subscription management. - **[Vercel AI SDK](https://ai-sdk.dev/)**: For building AI-powered features with ease. - **[React Email](https://react.email/)**: For creating beautiful, responsive email templates. - **[Sentry](https://sentry.io/welcome/)**: For error tracking and performance monitoring. - **[Cloudflare R2](https://www.cloudflare.com/products/r2/)**: For S3-compatible file storage. --- ## E2E Tests **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/tests/e2e **Description**: Test complete user flows and interactions with Playwright. End-to-end (E2E) tests verify that your application works correctly from a user's perspective. They test complete user flows by simulating real user interactions in a browser. The Pro Next.js Drizzle starter kit uses [Playwright](https://playwright.dev) for E2E testing. Playwright is a modern, reliable testing framework that supports multiple browsers and provides excellent debugging tools. Playwright provides automatic waiting, network interception and useful debugging tools. Playwright supports Chromium, Firefox and WebKit. The shipped configuration runs Desktop Chrome only so the default suite stays focused and reasonably fast. ## Why write E2E tests? E2E tests verify that your application works correctly as a whole. They catch issues that unit tests might miss, such as: - **Integration problems**: Issues between different parts of your application - **User flow bugs**: Problems with complete user journeys - **UI regressions**: Visual or interaction issues - **Browser behavior**: Issues that only appear in a real browser E2E tests are slower than unit tests, so use them strategically for critical user flows rather than trying to test everything. ## Configuration The Playwright configuration is in `playwright.config.ts`: ```typescript filename="playwright.config.ts" lineNumbers import path from 'node:path'; import { defineConfig, devices } from '@playwright/test'; import dotenv from 'dotenv'; dotenv.config({ path: path.resolve(__dirname, '.env') }); const isCI = !!process.env.CI; export default defineConfig({ testDir: './tests/e2e', fullyParallel: false, forbidOnly: isCI, retries: isCI ? 1 : 0, workers: 1, reporter: [['html']], use: { baseURL: 'http://localhost:3000', trace: 'on-first-retry', video: { mode: 'retain-on-failure', size: { width: 640, height: 480 } } }, projects: [ { name: 'setup', testMatch: /.*\.setup\.ts/ }, { name: 'chromium', use: { ...devices['Desktop Chrome'] } } ], webServer: { command: 'npm run build && npm run start', url: 'http://localhost:3000', reuseExistingServer: !isCI, stdout: 'pipe', timeout: 180 * 1000 } }); ``` Key features: - **Test directory**: Tests are located in `./tests/e2e` - **Automatic server**: Builds and starts the app automatically - **Video recording**: Records videos of failed tests - **Trace viewer**: Captures traces for debugging failed tests - **Deterministic execution**: Uses one worker because authenticated tests reset shared database fixtures such as users, organizations and two-factor state - **Configured browser**: Runs the Chromium project with the Desktop Chrome device profile. Add Firefox or WebKit projects explicitly if your support policy requires them ## Running E2E tests ### Run all E2E tests ```bash filename="Terminal" lineNumbers npm run test:e2e ``` This runs all E2E tests. The app is automatically built and started before tests run. ### Run with UI mode ```bash filename="Terminal" lineNumbers npm run test:e2e:ui ``` This opens Playwright's UI mode, which provides a visual interface for running and debugging tests. ### Run in debug mode ```bash filename="Terminal" lineNumbers npm run test:e2e:debug ``` This opens Playwright Inspector, allowing you to step through tests and see what's happening. ### Run in headed mode ```bash filename="Terminal" lineNumbers npm run test:e2e:headed ``` This runs tests with a visible browser window, useful for debugging visual issues. ### Setup Playwright Install Playwright browsers (first time only): ```bash filename="Terminal" lineNumbers npm run test:e2e:setup ``` ## Writing E2E tests ### Example: Testing authentication pages Here's an example of testing authentication pages: ```typescript filename="tests/e2e/auth.spec.ts" lineNumbers import { expect, test } from '@playwright/test'; test.describe('Authentication Pages', () => { test('sign-in page loads correctly', async ({ page }) => { await page.goto('/auth/sign-in'); // Check page title await expect(page).toHaveTitle(/Sign in/); await expect( page.getByText('Sign in to your account', { exact: true }) ).toBeVisible(); // Check form elements await expect(page.getByLabel('Email')).toBeVisible(); await expect(page.getByLabel('Password', { exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible(); // Check links await expect( page.getByRole('link', { name: 'Forgot password?' }) ).toBeVisible(); await expect(page.getByRole('link', { name: 'Sign up' })).toBeVisible(); }); test('sign-up page loads correctly', async ({ page }) => { await page.goto('/auth/sign-up'); // Check page title await expect(page).toHaveTitle(/Create an account/); await expect( page.getByText('Create your account', { exact: true }) ).toBeVisible(); }); }); ``` ### Common patterns #### Navigation ```typescript filename="tests/e2e/navigation.spec.ts" lineNumbers import { test } from '@playwright/test'; test('navigates to dashboard', async ({ page }) => { await page.goto('/'); await page.click('text=Dashboard'); await expect(page).toHaveURL('/dashboard'); }); ``` #### Form interactions ```typescript filename="tests/e2e/forms.spec.ts" lineNumbers import { expect, test } from '@playwright/test'; test('fills out and submits form', async ({ page }) => { await page.goto('/contact'); await page.fill('input[name="name"]', 'John Doe'); await page.fill('input[name="email"]', 'john@example.com'); await page.fill('textarea[name="message"]', 'Test message'); await page.click('button[type="submit"]'); await expect(page.locator('text=Message sent')).toBeVisible(); }); ``` #### Waiting for elements ```typescript filename="tests/e2e/waiting.spec.ts" lineNumbers import { expect, test } from '@playwright/test'; test('waits for dynamic content', async ({ page }) => { await page.goto('/dashboard'); // Wait for data to load await page.waitForSelector('text=Loading...', { state: 'hidden' }); // Check that data is displayed await expect(page.locator('text=Total Users')).toBeVisible(); }); ``` #### Assertions ```typescript filename="tests/e2e/assertions.spec.ts" lineNumbers import { expect, test } from '@playwright/test'; test('checks various assertions', async ({ page }) => { await page.goto('/'); // Check visibility await expect(page.locator('h1')).toBeVisible(); // Check text content await expect(page.locator('h1')).toHaveText('Welcome'); // Check URL await expect(page).toHaveURL('http://localhost:3000/'); // Check element count await expect(page.locator('button')).toHaveCount(3); }); ``` ## Best practices ### Test user flows, not implementation Focus on what users do, not how the code works. Test complete user journeys rather than individual components. ```typescript // ✅ Good - tests user flow test('user can sign up and access dashboard', async ({ page }) => { await page.goto('/auth/sign-up'); await page.fill('input[name="email"]', 'test@example.com'); await page.fill('input[name="password"]', 'password123'); await page.click('button[type="submit"]'); await expect(page).toHaveURL('/dashboard'); }); // ❌ Not so good - tests implementation test('calls signup API', async ({ page }) => { // Testing API calls directly }); ``` ### Use page object model for complex flows For complex pages or flows, use the page object model to keep tests maintainable. ```typescript class SignInPage { constructor(private page: Page) {} async goto() { await this.page.goto('/auth/sign-in'); } async signIn(email: string, password: string) { await this.page.fill('input[name="email"]', email); await this.page.fill('input[name="password"]', password); await this.page.click('button[type="submit"]'); } } test('user can sign in', async ({ page }) => { const signInPage = new SignInPage(page); await signInPage.goto(); await signInPage.signIn('test@example.com', 'password123'); await expect(page).toHaveURL('/dashboard'); }); ``` ### Use data-testid for stable selectors Use `data-testid` attributes for elements that are likely to change, making tests more resilient. ```typescript // In your component // In your test await page.click('[data-testid="submit-button"]'); ``` ### Make shared state explicit Prefer independent tests when a flow can create and remove its own data. The shipped authenticated application suite is deliberately serial because it shares deterministic users and resets mutable authentication state between security scenarios. The authenticated suite uses `tests/e2e/seed.mjs` to create test-only owner and administrator accounts in the configured test database. It also resets mutable security state such as TOTP enrollment before the relevant flow. Never point the E2E environment at a development, staging or production database containing real users. Set `DATABASE_URL` in the local `.env` file to a disposable E2E database. The seed script inserts predictable fixtures and is intentionally safe to rerun, but it must never operate on customer data. The seed is executed from `tests/e2e/application.spec.ts` before the authenticated suite. Run it through Node with the test environment loaded when you need to restore those fixtures manually: ```sh filename="Terminal" lineNumbers node --env-file=.env tests/e2e/seed.mjs ``` Do not add a setup project or saved browser authentication state unless you also change the tests to consume it. The current tests sign in through the UI so they exercise the real authentication flow. ## Debugging failed tests When a test fails, Playwright provides several tools to help debug: ### View test report ```bash filename="Terminal" lineNumbers npx playwright show-report ``` This opens the HTML test report showing all test results, screenshots, and videos. ### Use trace viewer The configuration captures a trace on the first retry. CI retries failures once, so its failed-test artifacts can include a trace. Local runs use no retries; enable tracing explicitly or reproduce the failure with debug mode when needed. View a captured trace with: ```bash filename="Terminal" lineNumbers npx playwright show-trace trace.zip ``` The trace viewer shows a timeline of all actions, network requests, and console logs. ### Videos and screenshots The shipped configuration retains video for failed tests in `test-results/`. Screenshots are not enabled by default. Add `screenshot: 'only-on-failure'` to the Playwright `use` configuration if your CI artifacts should include them. ## Next steps With E2E tests set up, you can now: - **Test complete user flows** to ensure everything works together - **Catch integration issues** before they reach production - **Verify UI behavior** across different browsers - **Debug failures** with powerful debugging tools For faster feedback during development, use [Unit Tests](/docs/starter-kits/pro-nextjs-drizzle/tests/unit) to test individual functions and components. --- ## Overview **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/tests/overview **Description**: Learn about the testing setup and how to write tests for your application. The Pro Next.js Drizzle starter kit includes a comprehensive testing setup with unit tests and end-to-end (E2E) tests to help you build reliable applications. ## Testing Stack The starter kit uses modern testing tools: - **[Vitest](https://vitest.dev)**: Fast unit testing framework built on Vite - **[Playwright](https://playwright.dev)**: Reliable end-to-end testing framework - **[Testcontainers](https://testcontainers.com)**: For database integration tests ## Test Structure Tests are organized in the `tests/` directory: ``` tests/ e2e/ # End-to-end tests (Playwright) lib/ # Unit tests for utility functions trpc/ # tRPC router tests support/ # Test setup and utilities ``` ## Running Tests ### Unit Tests Run all unit tests: ```bash filename="Terminal" lineNumbers npm run test:unit ``` Run tests in watch mode: ```bash filename="Terminal" lineNumbers npm run test:watch ``` Generate coverage report: ```bash filename="Terminal" lineNumbers npm run test:coverage ``` ### E2E Tests Run E2E tests: ```bash filename="Terminal" lineNumbers npm run test:e2e ``` Run E2E tests with UI: ```bash filename="Terminal" lineNumbers npm run test:e2e:ui ``` Run E2E tests in debug mode: ```bash filename="Terminal" lineNumbers npm run test:e2e:debug ``` ### Database Tests Run database integration tests (requires Docker): ```bash filename="Terminal" lineNumbers npm run test:db ``` ## Test Configuration ### Vitest Configuration The Vitest configuration is in `vitest.config.mts`: - Uses `vite-tsconfig-paths` for path resolution - Includes tests from `tests/**/*.{test,spec}.?(c|m)[jt]s?(x)` and `lib/**/*.test.ts` - Excludes database tests unless `RUN_DB_TESTS=true` - Uses Testcontainers for database tests when enabled ### Playwright Configuration The Playwright configuration is in `playwright.config.ts`: - Tests located in `./tests/e2e` - Automatically builds and starts the app for testing - Uses Chromium by default - Includes video recording and tracing for failed tests ## Best Practices ### Write focused unit tests Unit tests should test individual functions or components in isolation. Keep them fast and focused on specific behavior. ### Use E2E tests for user flows E2E tests should cover complete user journeys, not individual components. They're slower but catch integration issues. ### Test critical paths Focus on testing the most important user flows and business logic. Don't try to achieve 100% coverage. ### Keep tests maintainable Write clear, readable tests that serve as documentation. If a test is hard to understand, refactor it. ## Next Steps Ready to start writing tests? Check out the guides for [Unit Tests](/docs/starter-kits/pro-nextjs-drizzle/tests/unit) and [E2E Tests](/docs/starter-kits/pro-nextjs-drizzle/tests/e2e) to learn more. --- ## Unit Tests **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/tests/unit **Description**: Write and run fast unit tests for individual functions and components with instant feedback. Unit tests are a type of automated test where individual units or components are tested. The "unit" in "unit test" refers to the smallest testable parts of an application. These tests are designed to verify that each unit of code performs as expected. The Pro Next.js Drizzle starter kit uses [Vitest](https://vitest.dev) as the unit testing framework. It's a blazing-fast test runner built on top of [Vite](https://vite.dev), designed for modern JavaScript and TypeScript projects. If you've used [Jest](https://jestjs.io) before, you already know Vitest - it shares the same API. But Vitest is built for speed: native TypeScript support without transpilation, parallel test execution, and a smart watch mode that only re-runs tests affected by your changes. The repository configures coverage, environment stubs, database isolation and TypeScript path aliases for you. You can extend those defaults in `vitest.config.mts` as the application grows. ## Why write unit tests? Unit tests give you **fast, focused feedback** on small pieces of your code - individual functions, hooks, or components. Instead of debugging an entire page or flow, you can verify just the logic you care about in isolation. They also act as **living documentation**: a good test tells you how a function is supposed to behave, which edge cases are important, and what assumptions the code makes. This makes it much easier to safely refactor or extend features later. In the starter kit, unit tests are designed to be **cheap and quick to run**, so you can keep Vitest running in watch mode while you code. Every change you make is immediately checked, helping you catch regressions before they ever reach integration or end‑to‑end tests. ## Configuration The Vitest configuration is in `vitest.config.mts`: ```typescript filename="vitest.config.mts" lineNumbers import { defineConfig } from 'vitest/config'; const runDbTests = process.env.RUN_DB_TESTS === 'true'; export default defineConfig({ resolve: { tsconfigPaths: true }, test: { coverage: { provider: 'v8' }, passWithNoTests: true, watch: false, testTimeout: 10_000, exclude: [ '**/node_modules/**', '**/dist/**', '**/.next/**', '**/e2e/**', // Exclude database tests unless RUN_DB_TESTS is true ...(runDbTests ? [] : [ '**/organizations.test.ts', '**/tests/trpc/routers/**', '**/*db*.test.ts' ]) ], include: ['tests/**/*.{test,spec}.?(c|m)[jt]s?(x)', 'lib/**/*.test.ts'], environment: 'node', pool: runDbTests ? 'forks' : 'threads', fileParallelism: !runDbTests, sequence: { concurrent: !runDbTests }, globalSetup: runDbTests ? './tests/support/setup-global.ts' : undefined, setupFiles: runDbTests ? ['./tests/support/setup-shared-db.ts'] : ['./tests/support/setup-env.ts'] } }); ``` Key features: - **Path resolution**: Uses Vitest's native `tsconfigPaths` support for aliases - **Coverage**: Uses v8 provider for code coverage - **Database tests**: Optional Testcontainers suite using a shared PostgreSQL container and an isolated schema (requires Docker) - **Test locations**: Includes tests from `tests/` directory and `lib/**/*.test.ts` files ## Running tests There are several ways to run unit tests: ### Run all tests ```bash filename="Terminal" lineNumbers npm run test:unit ``` This runs all unit tests once and exits. Perfect for CI/CD pipelines. ### Watch mode ```bash filename="Terminal" lineNumbers npm run test:watch ``` This starts Vitest in watch mode. As you edit files, only the affected tests are re-run, giving you fast feedback while you work. ### Code coverage Generate a code coverage report: ```bash filename="Terminal" lineNumbers npm run test:coverage ``` This runs all tests and generates a coverage report showing which lines, branches, and functions are covered by tests. ### Database tests Run database integration tests (requires Docker): ```bash filename="Terminal" lineNumbers npm run test:db ``` This starts one temporary PostgreSQL container through Testcontainers. Database tests receive an isolated schema and truncate its tables before each test. The global teardown stops the container after the run. Start Docker Desktop or your Docker daemon before running this command. The suite creates and destroys its own PostgreSQL container; it does not use the database from your normal `DATABASE_URL`. ## Writing unit tests ### Example: Testing utility functions Here's an example of testing a utility function: ```typescript filename="tests/lib/utils.test.ts" lineNumbers import { describe, expect, it } from 'vitest'; import { capitalize, getInitials } from '@/lib/utils'; describe('capitalize', () => { it('capitalizes the first letter of a word', () => { expect(capitalize('hello')).toBe('Hello'); }); it('returns empty string if input is empty', () => { expect(capitalize('')).toBe(''); }); it('capitalizes a single character', () => { expect(capitalize('a')).toBe('A'); }); }); describe('getInitials', () => { it('returns initials for a two-word name', () => { expect(getInitials('John Doe')).toBe('JD'); }); it('handles single name', () => { expect(getInitials('John')).toBe('J'); }); it('handles empty string', () => { expect(getInitials('')).toBe(''); }); }); ``` ### Test structure - **`describe`**: Groups related tests together - **`it` or `test`**: Defines an individual test case - **`expect`**: Makes assertions about the code being tested ### Common assertions ```typescript filename="tests/example.test.ts" lineNumbers import { describe, expect, it } from 'vitest'; describe('Common assertions', () => { it('checks equality', () => { expect(1 + 1).toBe(2); }); it('checks object equality', () => { expect({ name: 'John' }).toEqual({ name: 'John' }); }); it('checks truthiness', () => { expect(true).toBeTruthy(); expect(false).toBeFalsy(); }); it('checks for null/undefined', () => { expect(null).toBeNull(); expect(undefined).toBeUndefined(); }); it('checks strings', () => { expect('hello').toContain('ell'); expect('hello').toMatch(/^h/); }); it('checks arrays', () => { expect([1, 2, 3]).toContain(2); expect([1, 2, 3]).toHaveLength(3); }); it('checks errors', () => { expect(() => { throw new Error('test'); }).toThrow('test'); }); }); ``` ## Best practices Unit tests should work **for you**, not the other way around. Focus on writing tests that make it easier to change code with confidence, not on satisfying arbitrary rules or reaching a magic number in a dashboard. ### Test behavior, not implementation Focus on what the function does, not how it does it. This makes tests more resilient to refactoring. ```typescript // ✅ Good - tests behavior expect(capitalize('hello')).toBe('Hello'); // ❌ Not so good - tests implementation details expect(capitalize.toString()).toContain('charAt'); ``` ### Keep tests focused Each test should verify one specific behavior. If a test is checking multiple things, split it into multiple tests. ```typescript // ✅ Good - focused test it('capitalizes the first letter', () => { expect(capitalize('hello')).toBe('Hello'); }); // ❌ Not so good - testing multiple things it('handles various inputs', () => { expect(capitalize('hello')).toBe('Hello'); expect(capitalize('')).toBe(''); expect(capitalize('a')).toBe('A'); }); ``` ### Use descriptive test names Test names should clearly describe what is being tested. ```typescript // ✅ Good - descriptive it('returns empty string if input is empty', () => { expect(capitalize('')).toBe(''); }); // ❌ Not so good - unclear it('handles edge case', () => { expect(capitalize('')).toBe(''); }); ``` ### Test edge cases Don't just test the happy path. Test edge cases like empty strings, null values, and boundary conditions. ```typescript describe('capitalize', () => { it('handles normal input', () => { expect(capitalize('hello')).toBe('Hello'); }); it('handles empty string', () => { expect(capitalize('')).toBe(''); }); it('handles single character', () => { expect(capitalize('a')).toBe('A'); }); }); ``` ### Code coverage is a guide, not a goal Code coverage helps you find untested code, but it shouldn't be the primary goal. Focus on testing critical paths and edge cases, not achieving 100% coverage. ## Next steps With unit tests set up, you can now: - **Test utility functions** to ensure they work correctly - **Test business logic** in isolation - **Catch regressions** before they reach production - **Refactor with confidence** knowing tests will catch breaking changes Ready to test complete user flows? Check out the [E2E Tests](/docs/starter-kits/pro-nextjs-drizzle/tests/e2e) guide. --- ## Authentication **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/troubleshooting/authentication **Description**: Learn about authentication troubles and their solutions. Start with the first server-side error produced by the failed request. Browser messages such as `Failed to fetch` often hide a database, environment or OAuth configuration error logged by the server. ## Sign-up or sign-in fails Check the shared prerequisites before changing authentication code: 1. Confirm the root `.env` contains a valid `DATABASE_URL` and a non-empty `BETTER_AUTH_SECRET`. 2. Apply the committed schema with `npm run db:migrate`. 3. Restart `npm run dev` after changing environment variables. 4. Reproduce the request while watching the terminal that runs Next.js. Use a unique `BETTER_AUTH_SECRET` in every deployed environment. Do not copy the development value from `.env.example` into production. ## Production redirects to localhost Better Auth receives its `baseURL` in `lib/auth/index.ts`. The value comes from `getBaseUrl()` in `lib/utils.ts` in this order: 1. A Vercel branch URL for a non-staging Preview deployment. 2. `NEXT_PUBLIC_SITE_URL`. 3. Vercel's generated deployment URL. 4. `http://localhost:3000` when none of the values above exist. Set `NEXT_PUBLIC_SITE_URL` to the final HTTPS production origin, without a path or trailing route, then redeploy. Confirm the variable exists in the Production environment rather than only Development or Preview. ## Google OAuth returns a callback error Verify the configuration as one complete set: 1. Set both `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in the environment where the deployment runs. 2. Register the exact callback URL `https://yourdomain.com/api/auth/callback/google` in Google Cloud. 3. Register the matching application origin. 4. Keep `enableSocialLogin` enabled in `config/auth.config.ts` only when the credentials are ready. 5. Confirm `google` remains in `lib/auth/oauth-providers.tsx` and in the `socialProviders` configuration in `lib/auth/index.ts`. Google compares callback URLs exactly. Scheme, hostname, port and path must all match. A Vercel branch preview can use a different hostname from production, so register that exact preview callback or test OAuth on a stable staging domain. Use the [OAuth setup guide](/docs/starter-kits/pro-nextjs-drizzle/authentication/oauth) to generate the callback URL for each environment. ## Request rejected because of its origin `config/auth.config.ts` builds Better Auth's trusted origins from `getBaseUrl()`, the configured site URL and Vercel deployment URLs. If a custom frontend origin calls the auth API, add that exact HTTPS origin deliberately. Do not use a broad wildcard for production origins. After changing trusted origins, restart or redeploy the application and test sign-in, sign-out and an authenticated request from the intended frontend. --- ## Codebase **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/troubleshooting/codebase **Description**: Learn about codebase troubles and their solutions. ## Build errors or TypeScript errors Make sure you've installed all dependencies: ```sh filename="Terminal" lineNumbers npm install ``` If errors persist, try clearing the Next.js cache: ```sh filename="Terminal" lineNumbers rm -rf .next npm run build ``` ## Port already in use If port 3000 is already in use, you can change it: ```sh filename="Terminal" lineNumbers PORT=3001 npm run dev ``` Or update the port in your `package.json` scripts. ## Module not found errors If you're seeing module not found errors: 1. Make sure all dependencies are installed: `npm install` 2. Check that the import path is correct 3. Restart your development server 4. Clear the Next.js cache: `rm -rf .next` --- ## Customization **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/troubleshooting/customization **Description**: Learn about customization troubles and their solutions. ## Tailwind styles not applying If Tailwind styles aren't being applied: 1. Make sure the file is included in `app/globals.css` using `@source` directives 2. Check that `@import 'tailwindcss'` is present in your global CSS 3. Restart your development server after changing Tailwind configuration 4. Clear the Next.js cache: `rm -rf .next` ## Component styles not working If component styles aren't working: 1. Verify that the component is importing the correct CSS 2. Check that Tailwind is properly configured 3. Make sure you're using the correct class names 4. Check for any CSS conflicts or overrides ## Theme not applying If theme changes aren't working: 1. Check that the theme provider is wrapping your app 2. Verify theme configuration in your config files 3. Clear browser cache and cookies 4. Check that theme variables are correctly defined --- ## Database **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/troubleshooting/database **Description**: Learn about database troubles and their solutions. Use the first database error from the server log as the starting point. Do not paste a complete `DATABASE_URL` into an issue or support message because it contains credentials. ## The application cannot connect Check the connection in this order: 1. Confirm `DATABASE_URL` exists in the root `.env` file for local development or in the deployed environment's settings. 2. Restart the development server or redeploy after changing the value. 3. For the included local PostgreSQL service, run `npm run docker:up` and inspect startup failures with `npm run docker:logs`. 4. For a managed database, confirm the hostname, database name, user, password and required SSL query parameters with the provider. 5. Confirm the provider allows connections from the application environment. Run `npm run db:studio` only after the connection is available. If Studio also fails, diagnose the database connection before changing application code. ## Production queries are slow Place the application and PostgreSQL database in nearby regions. Cross-region network latency is paid on every query and transaction, but the exact impact depends on the query count and provider network. Measure a slow request in server traces or logs before changing regions. Check whether it issues repeated queries, waits for a connection or spends most of its time on one database operation. Moving regions does not fix missing indexes or an inefficient query. ## A committed migration fails Treat a production migration as a release operation: 1. Back up data you need before applying a schema change. 2. Confirm the deployment uses the intended `DATABASE_URL` without printing its value. 3. Review the committed Drizzle migration files included with the release. 4. Confirm the database user can change the required schema objects. 5. Run `npm run db:migrate` once from CI or a one-off release task. Do not replace a failed production migration with `npm run db:push`. Do not delete committed migration history to make the current environment appear clean. Resolve the reported migration or schema difference and test the fix on a disposable copy first. Follow the [Drizzle database guide](/docs/starter-kits/pro-nextjs-drizzle/database) for the development workflow used to author new migrations. --- ## Troubleshooting **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/troubleshooting/overview **Description**: Common troubles and their solutions. Find answers to questions other developers have asked - it might be just what you need! ## Common Issues ### Environment variables from .env are not being loaded Make sure you are running the `npm run dev` command from the root directory of your project (where the `package.json` file is located). Note Next.js automatically loads environment variables from `.env` when running the development server. Make sure the file exists in the root directory and contains all required variables. Also make sure that the environment variable you are trying to access in your application is prefixed with `NEXT_PUBLIC_` if you want to use it in client-side code. ### Application is very slow in production The most common reason for a slow application in production is the physical distance between the server or serverless functions and the database. Make sure to deploy your application to a region that is close to your database. For example when you are using Vercel, you can select the region of the Vercel serverless functions in the project settings under the **Functions** tab. ### Database connection issues If you're experiencing database connection issues: 1. Verify your `DATABASE_URL` in `.env` is correct 2. Make sure your database is running and accessible 3. Check if your database allows connections from your IP address (for managed databases) 4. Verify the database credentials are correct ### Port already in use If port 3000 is already in use, you can change it by setting the `PORT` environment variable: ```sh filename="Terminal" lineNumbers PORT=3001 npm run dev ``` --- ## tRPC **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/trpc **Description**: Build end-to-end type-safe APIs with tRPC. The Pro Next.js Drizzle starter kit uses **tRPC** for its API layer, providing seamless type safety between your server-side logic and client-side components. ## Architecture Our tRPC setup is designed for performance and maintainability, with built-in support for authentication and organization-scoped data. ### Root Router The root router is located in `trpc/routers/app.ts` and aggregates all feature-specific routers using lazy loading: ```typescript filename="trpc/routers/app.ts" lineNumbers import { createTRPCRouter } from '@/trpc/init'; import { lazy } from '@trpc/server'; export const appRouter = createTRPCRouter({ admin: lazy(() => import('./admin')), organization: lazy(() => import('./organization')), user: lazy(() => import('./user')), upload: lazy(() => import('./upload')), contact: lazy(() => import('./contact')) }); export type AppRouter = typeof appRouter; ``` Lazy Loading Routers are lazy-loaded to improve initial bundle size and enable code splitting. ## Procedures We provide several base procedures to simplify development: - **`publicProcedure`**: No authentication required - **`protectedProcedure`**: Requires a valid user session - **`protectedOrganizationProcedure`**: Requires a valid session and an active organization ### Example: Public Endpoint ```typescript filename="trpc/routers/public.ts" lineNumbers import { createTRPCRouter, publicProcedure } from '@/trpc/init'; import { z } from 'zod'; export const publicRouter = createTRPCRouter({ health: publicProcedure.query(() => { return { status: 'ok', timestamp: new Date() }; }) }); ``` ### Example: Protected Endpoint ```typescript filename="trpc/routers/user.ts" lineNumbers import { createTRPCRouter, protectedProcedure } from '@/trpc/init'; import { z } from 'zod'; export const userRouter = createTRPCRouter({ getProfile: protectedProcedure.query(async ({ ctx }) => { // ctx.user is guaranteed to exist return ctx.user; }), updateProfile: protectedProcedure .input( z.object({ name: z.string().min(1).optional(), email: z.string().email().optional() }) ) .mutation(async ({ input, ctx }) => { // Update user profile return await updateUser(ctx.user.id, input); }) }); ``` ### Example: Organization-Scoped Endpoint ```typescript filename="trpc/routers/organization.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; import { eq } from 'drizzle-orm'; import { z } from 'zod'; import { db } from '@/lib/db'; import { leadTable } from '@/lib/db/schema'; export const organizationRouter = createTRPCRouter({ getLeads: protectedOrganizationProcedure.query(async ({ ctx }) => { // ctx.organization is guaranteed to exist return await db.query.leadTable.findMany({ where: eq(leadTable.organizationId, ctx.organization.id) }); }), createLead: protectedOrganizationProcedure .input( z.object({ name: z.string().min(1), email: z.string().email() }) ) .mutation(async ({ input, ctx }) => { const [lead] = await db .insert(leadTable) .values({ ...input, organizationId: ctx.organization.id }) .returning(); return lead; }) }); ``` ## Client Usage ### React Hooks On the client, use the `trpc` object to access your API procedures via React Query hooks. ```tsx filename="components/user-profile.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function UserProfile() { const { data: user, isLoading } = trpc.user.getProfile.useQuery(); if (isLoading) return
Loading...
; if (!user) return
Not found
; return
Hello, {user.name}!
; } ``` ### Mutations For actions that modify data, use mutations. ```tsx filename="components/update-profile-form.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; import { useForm } from 'react-hook-form'; export function UpdateProfileForm() { const utils = trpc.useUtils(); const updateProfile = trpc.user.updateProfile.useMutation({ onSuccess: () => { // Invalidate and refetch utils.user.getProfile.invalidate(); } }); const onSubmit = async (data: { name: string }) => { await updateProfile.mutateAsync(data); }; return
{/* form fields */}
; } ``` ### Optimistic Updates For better UX, use optimistic updates: ```tsx filename="components/optimistic-update.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function OptimisticComponent() { const utils = trpc.useUtils(); const updateMutation = trpc.user.updateProfile.useMutation({ onMutate: async (newData) => { // Cancel outgoing refetches await utils.user.getProfile.cancel(); // Snapshot previous value const previous = utils.user.getProfile.getData(); // Optimistically update utils.user.getProfile.setData(undefined, (old) => ({ ...old!, ...newData })); return { previous }; }, onError: (err, newData, context) => { // Rollback on error utils.user.getProfile.setData(undefined, context?.previous); }, onSettled: () => { // Refetch to ensure consistency utils.user.getProfile.invalidate(); } }); // ... use mutation } ``` ## Server-Side Usage ### Prefetching For better performance, prefetch data on the server in your Next.js Server Components. ```tsx filename="app/(saas)/dashboard/profile/page.tsx" lineNumbers import { HydrateClient, trpc } from '@/trpc/server'; export default async function ProfilePage() { // Prefetch data on the server await trpc.user.getProfile.prefetch(); return ( ); } ``` ### Server-Side Calls You can also call tRPC procedures directly on the server: ```typescript filename="app/api/example/route.ts" lineNumbers import { trpc } from '@/trpc/server'; export async function GET() { const user = await trpc.user.getProfile(); return Response.json(user); } ``` ## Organization Scoping The starter kit includes organization scoping to automatically filter data by organization: ```typescript filename="trpc/organization-scope.ts" lineNumbers // Organization is automatically determined from the request context // and made available in ctx.organization ``` When using `protectedOrganizationProcedure`, the organization is automatically determined from: - The active organization ID stored in the session (`session.activeOrganizationId`) - The active organization in the session - Custom organization resolution logic ## Type Inference Extract types from your procedures for use in your components. ```typescript filename="types/lead.ts" lineNumbers import type { AppRouter } from '@/trpc/routers/app'; import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server'; type RouterOutputs = inferRouterOutputs; type RouterInputs = inferRouterInputs; // Extract output type export type Lead = RouterOutputs['organization']['getLeads'][number]; // Extract input type export type CreateLeadInput = RouterInputs['organization']['createLead']; ``` ## Error Handling tRPC automatically handles errors and provides type-safe error handling: ```typescript filename="trpc/routers/example.ts" lineNumbers import { createTRPCRouter, protectedProcedure } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; export const exampleRouter = createTRPCRouter({ getData: protectedProcedure.query(async ({ ctx }) => { const data = await fetchData(); if (!data) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Data not found' }); } return data; }) }); ``` ```tsx filename="components/error-handling.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function DataComponent() { const { data, error, isLoading } = trpc.example.getData.useQuery(); if (error) { if (error.data?.code === 'NOT_FOUND') { return
Data not found
; } return
Error: {error.message}
; } if (isLoading) return
Loading...
; return
{data}
; } ``` ## Best Practices 1. **Use appropriate procedures** - Choose `publicProcedure`, `protectedProcedure` or `protectedOrganizationProcedure` based on your needs 2. **Validate inputs** - Always use Zod schemas for input validation 3. **Handle errors** - Use `TRPCError` for consistent error handling 4. **Optimize queries** - Use prefetching and optimistic updates for better UX 5. **Type safety** - Leverage TypeScript inference for type safety --- ## Define Endpoint **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/trpc/define-endpoint **Description**: Learn how to create new tRPC endpoints. This guide shows you how to create new tRPC endpoints in your application. We'll create a complete CRUD example for a `posts` feature. ## Creating a Router Create a new router file in `trpc/routers/`: ```typescript filename="trpc/routers/posts.ts" lineNumbers import { protectedProcedure, router } from '@/trpc/init'; import { eq } from 'drizzle-orm'; import { z } from 'zod'; import { db } from '@/lib/db'; import { postsTable } from '@/lib/db/schema'; export const postsRouter = router({ // Endpoints will go here }); ``` ## List Posts (Query) Create a query to list posts: ```typescript filename="trpc/routers/posts.ts" lineNumbers list: protectedProcedure .input( z.object({ limit: z.number().min(1).max(100).default(10), offset: z.number().min(0).default(0), }) ) .query(async ({ input, ctx }) => { const posts = await db.query.postsTable.findMany({ limit: input.limit, offset: input.offset, orderBy: (posts, { desc }) => [desc(posts.createdAt)], }); return posts; }), ``` ## Create Post (Mutation) Create a mutation to create a new post: ```typescript filename="trpc/routers/posts.ts" lineNumbers create: protectedProcedure .input( z.object({ title: z.string().min(1).max(255), content: z.string().min(1), }) ) .mutation(async ({ input, ctx }) => { const [post] = await db .insert(postsTable) .values({ title: input.title, content: input.content, authorId: ctx.session.user.id, }) .returning(); return post; }), ``` ## Get Post by ID (Query) Create a query to get a single post: ```typescript filename="trpc/routers/posts.ts" lineNumbers getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ input }) => { const post = await db.query.postsTable.findFirst({ where: eq(postsTable.id, input.id), }); if (!post) { throw new TRPCError({ code: "NOT_FOUND", message: "Post not found", }); } return post; }), ``` ## Update Post (Mutation) Create a mutation to update a post: ```typescript filename="trpc/routers/posts.ts" lineNumbers update: protectedProcedure .input( z.object({ id: z.string(), title: z.string().min(1).max(255).optional(), content: z.string().min(1).optional(), }) ) .mutation(async ({ input, ctx }) => { // Verify post exists and user is author const existingPost = await db.query.postsTable.findFirst({ where: eq(postsTable.id, input.id), }); if (!existingPost) { throw new TRPCError({ code: "NOT_FOUND", message: "Post not found", }); } if (existingPost.authorId !== ctx.session.user.id) { throw new TRPCError({ code: "FORBIDDEN", message: "You are not the author of this post", }); } const [updatedPost] = await db .update(postsTable) .set({ title: input.title, content: input.content, updatedAt: new Date(), }) .where(eq(postsTable.id, input.id)) .returning(); return updatedPost; }), ``` ## Delete Post (Mutation) Create a mutation to delete a post: ```typescript filename="trpc/routers/posts.ts" lineNumbers delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ input, ctx }) => { // Verify post exists and user is author const existingPost = await db.query.postsTable.findFirst({ where: eq(postsTable.id, input.id), }); if (!existingPost) { throw new TRPCError({ code: "NOT_FOUND", message: "Post not found", }); } if (existingPost.authorId !== ctx.session.user.id) { throw new TRPCError({ code: "FORBIDDEN", message: "You are not the author of this post", }); } await db.delete(postsTable).where(eq(postsTable.id, input.id)); return { success: true }; }), ``` ## Complete Router Example Here's the complete router: ```typescript filename="trpc/routers/posts.ts" lineNumbers import { protectedProcedure, router } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; import { desc, eq } from 'drizzle-orm'; import { z } from 'zod'; import { db } from '@/lib/db'; import { postsTable } from '@/lib/db/schema'; export const postsRouter = router({ list: protectedProcedure .input( z.object({ limit: z.number().min(1).max(100).default(10), offset: z.number().min(0).default(0) }) ) .query(async ({ input }) => { return await db.query.postsTable.findMany({ limit: input.limit, offset: input.offset, orderBy: [desc(postsTable.createdAt)] }); }), getById: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ input }) => { const post = await db.query.postsTable.findFirst({ where: eq(postsTable.id, input.id) }); if (!post) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Post not found' }); } return post; }), create: protectedProcedure .input( z.object({ title: z.string().min(1).max(255), content: z.string().min(1) }) ) .mutation(async ({ input, ctx }) => { const [post] = await db .insert(postsTable) .values({ title: input.title, content: input.content, authorId: ctx.session.user.id }) .returning(); return post; }), update: protectedProcedure .input( z.object({ id: z.string(), title: z.string().min(1).max(255).optional(), content: z.string().min(1).optional() }) ) .mutation(async ({ input, ctx }) => { const existingPost = await db.query.postsTable.findFirst({ where: eq(postsTable.id, input.id) }); if (!existingPost) { throw new TRPCError({ code: 'NOT_FOUND' }); } if (existingPost.authorId !== ctx.session.user.id) { throw new TRPCError({ code: 'FORBIDDEN' }); } const [updatedPost] = await db .update(postsTable) .set({ title: input.title, content: input.content, updatedAt: new Date() }) .where(eq(postsTable.id, input.id)) .returning(); return updatedPost; }), delete: protectedProcedure .input(z.object({ id: z.string() })) .mutation(async ({ input, ctx }) => { const existingPost = await db.query.postsTable.findFirst({ where: eq(postsTable.id, input.id) }); if (!existingPost) { throw new TRPCError({ code: 'NOT_FOUND' }); } if (existingPost.authorId !== ctx.session.user.id) { throw new TRPCError({ code: 'FORBIDDEN' }); } await db.delete(postsTable).where(eq(postsTable.id, input.id)); return { success: true }; }) }); ``` ## Adding Router to App Router Add your new router to the main app router: ```typescript filename="trpc/routers/app.ts" lineNumbers import { createTRPCRouter } from '@/trpc/init'; import { lazy } from '@trpc/server'; export const appRouter = createTRPCRouter({ admin: lazy(() => import('./admin')), organization: lazy(() => import('./organization')), user: lazy(() => import('./user')), posts: lazy(() => import('./posts')) // Add your new router // ... other routers }); export type AppRouter = typeof appRouter; ``` ## Using the Endpoint ### Client-Side ```tsx filename="components/posts-list.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function PostsList() { const { data: posts, isLoading } = trpc.posts.list.useQuery({ limit: 10, offset: 0 }); if (isLoading) return
Loading...
; return (
{posts?.map((post) => (

{post.title}

{post.content}

))}
); } ``` ### Server-Side ```tsx filename="app/(saas)/dashboard/posts/page.tsx" lineNumbers import { HydrateClient, trpc } from '@/trpc/server'; export default async function PostsPage() { await trpc.posts.list.prefetch({ limit: 10, offset: 0 }); return ( ); } ``` ## Best Practices 1. **Use appropriate procedures** - Choose `publicProcedure`, `protectedProcedure`, or `protectedOrganizationProcedure` 2. **Validate inputs** - Always use Zod schemas for input validation 3. **Handle errors** - Use `TRPCError` with appropriate error codes 4. **Check permissions** - Verify user has access before operations 5. **Use returning()** - For Drizzle, use `.returning()` to get the created/updated record 6. **Type safety** - Let TypeScript infer types from your procedures --- ## Protect Endpoint **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/trpc/protect-endpoint **Description**: Learn how to protect tRPC endpoints with authentication and authorization. The starter kit provides several base procedures for protecting endpoints. Choose the appropriate procedure based on your security requirements. ## Available Procedures ### Public Procedure No authentication required. Use for public endpoints: ```typescript filename="trpc/routers/public.ts" lineNumbers import { createTRPCRouter, publicProcedure } from '@/trpc/init'; export const publicRouter = createTRPCRouter({ health: publicProcedure.query(() => { return { status: 'ok', timestamp: new Date() }; }) }); ``` ### Protected Procedure Requires a valid user session. The session and user are available in `ctx`: ```typescript filename="trpc/routers/user.ts" lineNumbers import { createTRPCRouter, protectedProcedure } from '@/trpc/init'; export const userRouter = createTRPCRouter({ getProfile: protectedProcedure.query(async ({ ctx }) => { // ctx.user and ctx.session are guaranteed to exist return ctx.user; }) }); ``` ### Protected Admin Procedure Requires authentication AND admin role: ```typescript filename="trpc/routers/admin.ts" lineNumbers import { createTRPCRouter, protectedAdminProcedure } from '@/trpc/init'; export const adminRouter = createTRPCRouter({ getAllUsers: protectedAdminProcedure.query(async ({ ctx }) => { // ctx.user.role is guaranteed to be "admin" return await getAllUsers(); }) }); ``` ### Protected Organization Procedure Requires authentication AND an active organization. The organization is available in `ctx`: ```typescript filename="trpc/routers/organization.ts" lineNumbers import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init'; export const organizationRouter = createTRPCRouter({ getData: protectedOrganizationProcedure.query(async ({ ctx }) => { // ctx.organization is guaranteed to exist // ctx.membership contains the user's role in the organization return await getOrganizationData(ctx.organization.id); }) }); ``` ## Custom Authorization ### Role-Based Access Check user roles within a procedure: ```typescript filename="trpc/routers/example.ts" lineNumbers import { createTRPCRouter, protectedProcedure } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; export const exampleRouter = createTRPCRouter({ adminOnly: protectedProcedure.query(async ({ ctx }) => { if (ctx.user.role !== 'admin') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin access required' }); } return { data: 'admin data' }; }) }); ``` ### Resource Ownership Verify the user owns the resource: ```typescript filename="trpc/routers/posts.ts" lineNumbers import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { eq } from "drizzle-orm"; import { protectedProcedure } from "@/trpc/init"; import { db } from "@/lib/db"; import { postTable } from "@/lib/db/schema"; update: protectedProcedure .input(z.object({ id: z.string(), title: z.string() })) .mutation(async ({ input, ctx }) => { const [post] = await db .select() .from(postTable) .where(eq(postTable.id, input.id)) .limit(1); if (!post) { throw new TRPCError({ code: "NOT_FOUND", message: "Post not found" }); } // Check ownership if (post.authorId !== ctx.user.id) { throw new TRPCError({ code: "FORBIDDEN", message: "You can only edit your own posts", }); } // Update post const [updatedPost] = await db .update(postTable) .set({ title: input.title }) .where(eq(postTable.id, input.id)) .returning(); return updatedPost; }), ``` ### Organization Membership The `protectedOrganizationProcedure` automatically verifies organization membership. For additional checks: ```typescript filename="trpc/routers/organization.ts" lineNumbers import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { protectedOrganizationProcedure } from "@/trpc/init"; import { assertUserIsOrgMember } from "@/lib/auth/server"; getData: protectedOrganizationProcedure .input(z.object({ organizationId: z.string() })) .query(async ({ input, ctx }) => { // Verify user is member (if different from active org) if (input.organizationId !== ctx.organization.id) { await assertUserIsOrgMember(input.organizationId, ctx.user.id); } return await getData(input.organizationId); }), ``` ### Plan-Based Access Check if organization has required plan: ```typescript filename="trpc/routers/premium.ts" lineNumbers import { TRPCError } from "@trpc/server"; import { protectedOrganizationProcedure } from "@/trpc/init"; import { requirePaidPlan, hasSpecificPlan } from "@/lib/billing"; premiumFeature: protectedOrganizationProcedure.query(async ({ ctx }) => { // Option 1: Throw error if no paid plan await requirePaidPlan(ctx.organization.id); // Option 2: Check for specific plan (doesn't throw) const hasProPlan = await hasSpecificPlan(ctx.organization.id, "pro"); if (!hasProPlan) { throw new TRPCError({ code: "FORBIDDEN", message: "This feature requires a Pro plan", }); } return { data: "premium content" }; }), ``` ## Creating Custom Procedures You can create custom procedures for common authorization patterns: ```typescript filename="trpc/procedures.ts" lineNumbers import { protectedOrganizationProcedure, protectedProcedure } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; /** * Procedure that requires user to have completed onboarding */ export const onboardedProcedure = protectedProcedure.use( async ({ ctx, next }) => { if (!ctx.user.onboardingComplete) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Please complete onboarding first' }); } return next({ ctx }); } ); /** * Procedure that requires organization admin role */ export const organizationAdminProcedure = protectedOrganizationProcedure.use( async ({ ctx, next }) => { const isAdmin = ctx.membership.role === 'admin' || ctx.membership.role === 'owner'; if (!isAdmin) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Organization admin access required' }); } return next({ ctx }); } ); ``` Usage: ```typescript filename="trpc/routers/example.ts" lineNumbers import { createTRPCRouter } from '@/trpc/init'; import { onboardedProcedure, organizationAdminProcedure } from '@/trpc/procedures'; import { z } from 'zod'; export const exampleRouter = createTRPCRouter({ // Requires onboarding getDashboard: onboardedProcedure.query(async ({ ctx }) => { return await getDashboardData(ctx.user.id); }), // Requires org admin updateSettings: organizationAdminProcedure .input(z.object({ settings: z.object({}) })) .mutation(async ({ input, ctx }) => { return await updateOrgSettings(ctx.organization.id, input.settings); }) }); ``` ## Error Codes Use appropriate TRPC error codes: - **`UNAUTHORIZED`** - User is not authenticated - **`FORBIDDEN`** - User is authenticated but lacks permission - **`NOT_FOUND`** - Resource doesn't exist - **`BAD_REQUEST`** - Invalid input - **`INTERNAL_SERVER_ERROR`** - Server error ```typescript filename="trpc/routers/example.ts" lineNumbers import { createTRPCRouter, protectedProcedure } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; import { z } from 'zod'; export const exampleRouter = createTRPCRouter({ getResource: protectedProcedure .input(z.object({ id: z.string() })) .query(async ({ input, ctx }) => { const resource = await getResource(input.id); if (!resource) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Resource not found' }); } // Check access if (!hasAccess(resource, ctx.user)) { throw new TRPCError({ code: 'FORBIDDEN', message: "You don't have access to this resource" }); } return resource; }) }); ``` ## Best Practices 1. **Fail fast** - Check authentication and authorization early 2. **Use appropriate procedures** - Don't use `protectedProcedure` when `publicProcedure` is sufficient 3. **Verify ownership** - Always verify resource ownership before mutations 4. **Clear error messages** - Provide helpful error messages (but don't leak sensitive info) 5. **Log access attempts** - Log failed authorization attempts for security monitoring --- ## Usage in Frontend **URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/trpc/usage-frontend **Description**: Learn how to use tRPC endpoints in your React components. tRPC provides type-safe hooks for using your API in React components. All procedures are automatically typed based on your router definitions. ## Queries Use `useQuery` for data fetching: ```tsx filename="components/user-profile.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function UserProfile() { const { data: user, isLoading, error } = trpc.user.getProfile.useQuery(); if (isLoading) return
Loading...
; if (error) return
Error: {error.message}
; if (!user) return
Not found
; return
Hello, {user.name}!
; } ``` ### Query with Input Pass input parameters to queries: ```tsx filename="components/post-detail.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function PostDetail({ postId }: { postId: string }) { const { data: post, isLoading } = trpc.posts.getById.useQuery({ id: postId }); if (isLoading) return
Loading...
; if (!post) return
Post not found
; return (

{post.title}

{post.content}

); } ``` ### Conditional Queries Enable/disable queries conditionally: ```tsx filename="components/conditional-query.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function ConditionalQuery({ enabled }: { enabled: boolean }) { const { data } = trpc.posts.list.useQuery( { limit: 10 }, { enabled } // Only fetch when enabled is true ); return
{/* render data */}
; } ``` ## Mutations Use `useMutation` for data modifications: ```tsx filename="components/create-post-form.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; import { useForm } from 'react-hook-form'; export function CreatePostForm() { const utils = trpc.useUtils(); const { handleSubmit } = useForm<{ title: string; content: string }>(); const createPost = trpc.posts.create.useMutation({ onSuccess: () => { // Invalidate and refetch posts list utils.posts.list.invalidate(); } }); const onSubmit = async (data: { title: string; content: string }) => { try { await createPost.mutateAsync(data); // Handle success } catch (error) { // Handle error } }; return
{/* form fields */}
; } ``` ### Optimistic Updates Update the UI optimistically for better UX: ```tsx filename="components/optimistic-update.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function OptimisticUpdate() { const utils = trpc.useUtils(); const updatePost = trpc.posts.update.useMutation({ onMutate: async (newData) => { // Cancel outgoing refetches await utils.posts.getById.cancel({ id: newData.id }); // Snapshot previous value const previous = utils.posts.getById.getData({ id: newData.id }); // Optimistically update utils.posts.getById.setData({ id: newData.id }, (old) => ({ ...old!, ...newData })); return { previous }; }, onError: (err, newData, context) => { // Rollback on error utils.posts.getById.setData({ id: newData.id }, context?.previous); }, onSettled: (data, error, variables) => { // Refetch to ensure consistency utils.posts.getById.invalidate({ id: variables.id }); } }); return ( ); } ``` ## Server-Side Usage ### Prefetching in Server Components Prefetch data on the server for better performance: ```tsx filename="app/(saas)/dashboard/posts/page.tsx" lineNumbers import { HydrateClient, trpc } from '@/trpc/server'; export default async function PostsPage() { // Prefetch data on the server await trpc.posts.list.prefetch({ limit: 10, offset: 0 }); return ( ); } ``` ### Direct Server Calls Call tRPC procedures directly on the server: ```typescript filename="app/api/posts/route.ts" lineNumbers import { trpc } from '@/trpc/server'; export async function GET() { const posts = await trpc.posts.list({ limit: 10, offset: 0 }); return Response.json(posts); } ``` ## Error Handling Handle errors gracefully: ```tsx filename="components/error-handling.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; import { TRPCClientError } from '@trpc/client'; export function ErrorHandling() { const { data, error, isLoading } = trpc.posts.getById.useQuery( { id: '123' }, { retry: (failureCount, error) => { // Don't retry on 404 if (error.data?.code === 'NOT_FOUND') { return false; } // Retry up to 3 times for other errors return failureCount < 3; } } ); if (error) { if (error.data?.code === 'NOT_FOUND') { return
Post not found
; } if (error.data?.code === 'FORBIDDEN') { return
You don't have permission to view this post
; } return
Error: {error.message}
; } if (isLoading) return
Loading...
; return
{/* render data */}
; } ``` ## Type Inference Extract types from your procedures: ```typescript filename="types/post.ts" lineNumbers import type { AppRouter } from '@/trpc/routers/app'; import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server'; type RouterOutputs = inferRouterOutputs; type RouterInputs = inferRouterInputs; // Extract output type export type Post = RouterOutputs['posts']['getById']; // Extract input type export type CreatePostInput = RouterInputs['posts']['create']; export type UpdatePostInput = RouterInputs['posts']['update']; ``` Use in components: ```tsx filename="components/typed-component.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; import type { Post } from '@/types/post'; export function TypedComponent() { const { data: post } = trpc.posts.getById.useQuery({ id: '123' }); // post is automatically typed as Post return
{post?.title}
; } ``` ## Query Invalidation Invalidate queries to trigger refetches: ```tsx filename="components/invalidation.tsx" lineNumbers 'use client'; import { trpc } from '@/trpc/client'; export function InvalidationExample() { const utils = trpc.useUtils(); const createPost = trpc.posts.create.useMutation(); const handleCreate = async (data: { title: string; content: string }) => { await createPost.mutateAsync(data); // Invalidate specific query utils.posts.list.invalidate(); // Or invalidate all posts queries utils.posts.invalidate(); }; return ( ); } ``` ## Best Practices 1. **Use prefetching** - Prefetch data on the server for better performance 2. **Handle loading states** - Always show loading indicators 3. **Handle errors** - Provide user-friendly error messages 4. **Use optimistic updates** - Update UI immediately for better UX 5. **Invalidate queries** - Invalidate related queries after mutations 6. **Type safety** - Leverage TypeScript inference for type safety --- # Achromatic blog ## How to Prevent Ownerless Organizations in a Next.js SaaS **URL**: https://www.achromatic.dev/blog/prevent-ownerless-organizations-nextjs-saas **Description**: Protect multi-tenant organizations when users leave or delete their accounts with server-side ownership checks for Better Auth, Prisma and Drizzle. **Published**: 2026-09-05 Deleting a user account looks like a user-scoped operation. In a multi-tenant SaaS application, it can also change the authorization structure of every organization that user owns. If membership rows cascade when a user is deleted, a sole owner can disappear while the organization remains. The result may be an empty organization or, worse, an organization with active members but nobody authorized to administer it. That makes account deletion an organization integrity boundary. The application must check ownership on the server before the authentication system removes the user. This guide explains the approach used by the Achromatic Pro Prisma and Drizzle starter kits with Better Auth. ## The orphaned organization problem Consider an organization with three members: | User | Role | | ------ | ------ | | Alex | Owner | | Sam | Member | | Taylor | Member | If Alex deletes their account and the membership relation uses `ON DELETE CASCADE`, Alex's membership disappears automatically. Sam and Taylor can still belong to the organization, but neither can perform owner-only actions such as transferring ownership, changing sensitive settings or deleting the workspace. The database has done exactly what its foreign keys requested. The application has still allowed an invalid business state. The same loophole appears when the product already blocks a sole owner from leaving an organization but account deletion bypasses that workflow. Every path that removes the final owner needs the same invariant. ## Define the invariant precisely A user should be blocked from deleting their account when at least one organization satisfies both conditions: 1. The user has an owner membership in the organization. 2. No different user has an owner membership in that organization. The number of ordinary members does not change the answer. An organization with one owner and ten members still has a sole owner. Likewise, account deletion should remain available when: - the user belongs only as a member - every organization they own has another owner - the user does not belong to an organization Keeping the rule this narrow avoids turning a safety check into unnecessary account lock-in. ## Enforce it at the authentication boundary A disabled button is useful guidance, but it is not authorization. A user can call the deletion endpoint directly, use another client or submit a request from an older browser tab. Better Auth exposes a `beforeDelete` hook for the server-side decision: ```typescript filename="lib/auth/index.ts" lineNumbers import { betterAuth } from 'better-auth'; import { assertAccountDeletionAllowedForUser } from '@/lib/auth/account-deletion'; export const auth = betterAuth({ user: { deleteUser: { enabled: true, beforeDelete: async (user) => { await assertAccountDeletionAllowedForUser(user.id); } } } }); ``` The hook runs immediately before Better Auth deletes the user. If the guard throws, deletion stops before cascade behavior can remove memberships or other dependent records. Keep this check close to the destructive mutation. A page loader or React component can improve the experience, but neither can protect API calls made outside that render cycle. ## Query sole ownership with Prisma With Prisma, express the two halves of the invariant through relation filters: ```typescript filename="lib/auth/account-deletion.ts" lineNumbers import { MemberRole } from '@prisma/client'; import { prisma } from '@/lib/db'; export async function findSoleOwnedOrganizations(userId: string) { return prisma.organization.findMany({ where: { AND: [ { members: { some: { userId, role: MemberRole.owner } } }, { members: { none: { userId: { not: userId }, role: MemberRole.owner } } } ] }, select: { id: true, name: true } }); } ``` The `some` clause proves that the deleting user owns the organization. The `none` clause proves that a different owner does not exist. Select only the fields needed by the guard. This keeps the query small and also makes it possible to add organization names to future guidance without exposing the full membership graph. ## Query sole ownership with Drizzle The equivalent Drizzle query can use a correlated `NOT EXISTS` subquery. Alias the membership table so the database can distinguish the current user's owner row from a possible second owner: ```typescript filename="lib/auth/account-deletion.ts" lineNumbers import { and, eq, ne, notExists } from 'drizzle-orm'; import { alias } from 'drizzle-orm/pg-core'; import { db } from '@/lib/db'; import { MemberRole } from '@/lib/db/schema/enums'; import { memberTable, organizationTable } from '@/lib/db/schema/tables'; export async function findSoleOwnedOrganizations(userId: string) { const otherOwner = alias(memberTable, 'other_owner'); return db .select({ id: organizationTable.id, name: organizationTable.name }) .from(memberTable) .innerJoin( organizationTable, eq(organizationTable.id, memberTable.organizationId) ) .where( and( eq(memberTable.userId, userId), eq(memberTable.role, MemberRole.owner), notExists( db .select({ id: otherOwner.id }) .from(otherOwner) .where( and( eq(otherOwner.organizationId, memberTable.organizationId), eq(otherOwner.role, MemberRole.owner), ne(otherOwner.userId, userId) ) ) ) ) ); } ``` `NOT EXISTS` maps directly to the business rule: no other owner may exist for the same organization. It also lets PostgreSQL stop searching as soon as it finds a qualifying row. ## Return a stable application error Do not throw a generic database error and make the browser guess what happened. Turn the failed invariant into an explicit API response: ```typescript filename="lib/auth/account-deletion.ts" lineNumbers import { APIError } from 'better-auth/api'; export const ACCOUNT_DELETION_BLOCKED_CODE = 'ACCOUNT_DELETION_BLOCKED_BY_ORGANIZATION_OWNERSHIP'; export const ACCOUNT_DELETION_BLOCKED_MESSAGE = 'Transfer ownership or delete the organizations you solely own before deleting your account.'; export async function assertAccountDeletionAllowedForUser(userId: string) { const organizations = await findSoleOwnedOrganizations(userId); if (organizations.length > 0) { throw new APIError('FORBIDDEN', { code: ACCOUNT_DELETION_BLOCKED_CODE, message: ACCOUNT_DELETION_BLOCKED_MESSAGE }); } } ``` A stable code is useful for clients, logs and tests. The message should explain the remedy instead of merely saying that deletion is forbidden. Avoid returning organization details unless the authenticated user is allowed to see them. The guard needs names only if the UI deliberately lists the workspaces that require action. ## Make the confirmation honest The confirmation modal should prepare the user before they submit: > Are you sure you want to delete your account? You must transfer ownership or > delete any organization you solely own first. Then handle the structured server error and keep the modal open. Closing it before the mutation succeeds makes a recoverable ownership problem feel like a broken action. Good destructive-action behavior includes: - clear irreversible-action copy - an explicit destructive button label - a pending state that prevents duplicate submissions - the server's actionable ownership message - no optimistic removal of the account - no duplicate generic toast layered over the specific error The interface communicates the rule. The server enforces it. ## Test the ownership matrix The most important tests cover role combinations rather than component markup. At minimum, verify that deletion is: - blocked for an organization's only owner - blocked when other members exist but none is an owner - blocked if any one of several organizations is solely owned - allowed when every owned organization has another owner - allowed for an ordinary member - allowed for a user without memberships Also test that the Better Auth hook invokes the guard and propagates the structured `FORBIDDEN` response. ORM mocks are helpful for testing the assertion behavior, but run the ownership query against PostgreSQL too. A real database test catches aliasing, enum and relation-filter mistakes that a mocked return value cannot reveal. Finally, smoke test the account settings flow in a browser with both a sole owner and a co-owner account. Confirm the displayed message, pending state and successful deletion path. ## Consider concurrency separately The preflight closes the direct account-deletion bypass, but highly concurrent ownership changes can require stronger guarantees. For example, two co-owners could attempt to remove themselves at nearly the same time. Products with that risk should serialize final-owner mutations in a transaction or enforce the invariant through a database strategy appropriate to their write model. A simple row count followed by a separate delete is not automatically a global concurrency guarantee. This is also why ownership transfer should be a deliberate server operation, not two unrelated client calls that demote one owner and promote another. ## Apply the invariant to every exit path Account deletion is only one way an owner can disappear. Review every operation that can affect the final owner: - leaving an organization - removing a member - changing an owner's role - deleting or anonymizing a user through an admin panel - identity-provider deprovisioning - automated retention workflows Each path should either preserve another owner, transfer ownership atomically or delete the organization intentionally. The exact policy can differ by product. The invariant should not. ## What ships in Achromatic Achromatic Pro Prisma and Pro Drizzle now enforce the ownership check inside Better Auth before account deletion. Both implementations include ORM-specific queries, structured API errors, actionable confirmation copy and focused tests. The guard complements the existing protection that prevents a sole owner from leaving an organization. Together, they remove two common paths to ownerless tenant data while preserving account deletion for members and co-owners. See the complete release in the [Achromatic changelog](/changelog/september-2026-account-recovery-and-auth-reliability) or compare the available [Next.js SaaS starter kits](/docs/starter-kits). --- ## Passkeys with Better Auth in Next.js **URL**: https://www.achromatic.dev/blog/passkeys-better-auth-nextjs **Description**: Add secure passwordless passkey authentication to a Next.js SaaS with Better Auth, required user verification, account management and browser tests. **Published**: 2026-08-16 Passkeys remove the reusable secret from sign-in. Instead of typing a password, the user authorizes a cryptographic credential with a biometric, device PIN or external security key. The server receives a signed WebAuthn assertion, not the private key and not a biometric template. That makes passkeys attractive for SaaS products, but installing a plugin is only the beginning. A production implementation must decide how credentials are managed, whether user verification is required, how passkeys interact with TOTP, what happens when the feature is disabled and how the browser ceremony is tested. Achromatic now ships that baseline in its Prisma and Drizzle starter kits. This article explains the security decisions behind the implementation. ## Store public credential metadata, not private keys The [Better Auth passkey plugin](https://better-auth.com/docs/plugins/passkey) stores one row for each registered credential. The row contains the public key, credential ID, signature counter, authenticator details, optional name and the user relationship. The private key stays inside the authenticator. Face ID, Touch ID, Windows Hello or the device PIN unlocks that authenticator locally; the application never receives the biometric. Both Achromatic editions therefore add an ORM-specific `passkey` table and migration. Existing applications must apply that migration before enabling the plugin: ```bash filename="Terminal" npm run db:migrate ``` No new secret or environment variable is required. Production WebAuthn does require HTTPS, while browsers permit `localhost` for development. ## Register the plugin conditionally A feature flag should not merely hide a button while leaving authentication endpoints active. Register the server plugin only when the feature is enabled: ```ts filename="lib/auth/index.ts" lineNumbers plugins: [ // Other Better Auth plugins ...(authConfig.enablePasskeys ? [ passkey({ authenticatorSelection: { userVerification: 'required' } }) ] : []), twoFactor() ]; ``` The same `enablePasskeys` value controls the sign-in button and account security card. Turning it off therefore removes both the interface and the Better Auth passkey routes rather than creating a cosmetic security control. ## Require user verification twice WebAuthn distinguishes proving possession of an authenticator from verifying the person using it. A credential ceremony can be cryptographically valid even when the authenticator did not verify a biometric or PIN, depending on server policy and authenticator behavior. Request verification during registration and enforce the result after authentication: ```ts filename="lib/auth/index.ts" lineNumbers passkey({ authenticatorSelection: { userVerification: 'required' }, authentication: { afterVerification: async ({ verification }) => { if (!verification.authenticationInfo.userVerified) { throw new APIError('UNAUTHORIZED', { code: 'PASSKEY_USER_VERIFICATION_REQUIRED', message: 'Verify your identity with a PIN or biometric to use this passkey.' }); } } } }); ``` The first setting tells authenticators what the relying party expects. The second check prevents session creation if a ceremony reaches the server without the verified-user flag. Keeping both makes the policy explicit at the request and trust boundaries. ## Do not automatically append TOTP Better Auth's TOTP flow protects credential sign-in. Passkeys are a separate passwordless method and are not automatically routed through the password two-factor hook. That is appropriate when passkey authentication itself requires biometric or device-PIN verification. Prompting for a TOTP immediately afterward adds friction without restoring a missing password factor. Achromatic treats a user-verified passkey as the complete sign-in ceremony while password sign-in still follows the user's configured TOTP flow. Products with higher-risk operations can add step-up authentication around the operation—changing payout details, exporting sensitive data or rotating API credentials—rather than applying the same extra prompt to every passkey login. ## Give users control over registered credentials Passkeys are easier to trust when users can see and manage them. The account security page should support: - registering more than one device or security key - assigning a recognizable name after registration - renaming a credential when its purpose changes - deleting a credential only after confirmation - clear empty, loading and error states Achromatic places Passkeys below connected accounts in the existing Security tab. The sign-in action sits below Google so passwordless alternatives remain grouped without making passkeys look like a social provider. Browser error codes also need translation. Cancellation, duplicate registration, unavailable authenticators and missing user verification should not all collapse into “Something went wrong.” Map the ceremony code to a stable message and render root-level errors even though clicking a passkey button does not submit the password form. ## Test the browser ceremony, not only the API Unit tests can verify configuration and error mapping, but they cannot prove the whole WebAuthn interaction works. Chromium exposes a virtual authenticator over the Chrome DevTools Protocol, which makes the ceremony deterministic in Playwright. The released test covers this sequence: 1. Sign in with a seeded credential account. 2. Register a resident passkey with user verification. 3. Name and rename the new credential. 4. Sign out and disable user verification on the virtual authenticator. 5. Confirm passkey sign-in is rejected with an actionable message. 6. Re-enable verification and complete passwordless sign-in. 7. Delete the credential through the confirmation dialog. This catches bugs that API-only coverage misses: hidden root errors, incorrect WebAuthn option names, stale passkey lists and browser error-code mismatches. ## Plan recovery and domain stability Passkeys are scoped to a relying party. A later domain change can prevent an existing credential from matching the new site, so decide the durable authentication domain before presenting passkeys as the primary sign-in method. Keep an account-recovery path and encourage important users to register more than one authenticator. Passwordless does not mean recoveryless. The complete implementation, migrations and tests now ship in both Achromatic editions. Follow the [Pro Prisma passkey guide](/docs/starter-kits/pro-nextjs-prisma/authentication/passkeys) or [Pro Drizzle passkey guide](/docs/starter-kits/pro-nextjs-drizzle/authentication/passkeys), and review the release details in the [changelog](/changelog/august-2026-passkeys). --- ## Building In-App Notifications for Next.js SaaS **URL**: https://www.achromatic.dev/blog/in-app-notifications-nextjs-saas **Description**: Design a secure database-backed notification center with unread state, targeted messages, broadcasts and an admin workflow in a multi-user Next.js application. **Published**: 2026-08-15 In-app notifications look simple until they must work for real users. A bell, an unread badge and a list are only the visible layer. The application also needs recipient-scoped queries, reliable read state, safe links, an administrative sending workflow and a retention strategy. Achromatic Pro now includes that complete baseline in both the Prisma and Drizzle starter kits. This guide explains the design choices behind it and the parts you should preserve when adapting the feature to another Next.js SaaS application. ## Start with one notification per recipient A broadcast can be represented in two broad ways: 1. Store one message and join it to a separate recipient-state table. 2. Store one notification row for each recipient. The first approach reduces repeated message content. The second keeps the most common queries and mutations direct: list my notifications, count my unread notifications and mark one row as read. Achromatic uses one row per recipient. Each row records: | Field | Purpose | | ------------- | ------------------------------------------------- | | `userId` | The only user allowed to read or update the row | | `createdById` | The administrator who created it, when applicable | | `title` | A short, scannable summary | | `message` | The full notification body | | `type` | `info`, `success` or `warning` | | `actionUrl` | An optional internal destination | | `readAt` | Both the read state and the time it changed | | `createdAt` | Stable chronological ordering | The schema indexes `(userId, createdAt)` for the inbox and `(userId, readAt)` for the unread count. A creator relation uses `SET NULL`, so removing an admin does not delete messages already delivered to users. Removing the recipient does delete their notifications. This model deliberately does not pretend that a broadcast is one mutable object after delivery. If an admin deletes selected rows, only those recipients lose them. If the product later needs campaign analytics or editable message templates, add a separate campaign entity rather than overloading recipient state. ## Scope every user operation on the server Hiding another user's notification in the interface is not authorization. Every read and mutation must include the authenticated user ID in its database condition. In the Prisma edition, the core ownership conditions are small. The Drizzle edition applies the same constraints with `eq` and `and`: ```ts filename="trpc/routers/notification/index.ts" lineNumbers const notifications = await prisma.notification.findMany({ where: { userId: ctx.user.id, readAt: input.status === 'unread' ? null : undefined }, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], take: input.limit }); await prisma.notification.updateMany({ where: { id: input.id, userId: ctx.user.id, readAt: null }, data: { readAt: new Date() } }); ``` The same rule applies to a notification detail endpoint. Query by both `id` and `userId`; do not fetch by ID and hope a component checks ownership afterward. Achromatic exposes listing, unread count, one-notification lookup, mark-read and mark-all-read procedures through an authenticated tRPC router. Admin list, send and delete operations use a separate platform-admin procedure. ## Make the bell a fast summary, not a second inbox page The notification center sits beside the organization switcher in the expanded application sidebar and inside the mobile drawer. Its popover has two views: all notifications and unread notifications. That placement solves three practical problems: - It is available across the authenticated application. - The unread count remains visible without consuming navigation space. - Users can inspect a message without losing their current page. The list initially loads 20 recent rows. Long messages expand in place, while an optional action opens only after the row is selected. Empty, loading and error states live inside the same surface so a failed query does not turn the whole dashboard into an error page. Marking a message as read should feel immediate. The client optimistically updates the row, the unread tab and the badge count, then rolls those values back if the mutation fails. It still invalidates the authoritative queries after completion. This gives the user instant feedback without treating the client cache as the source of truth. ## Keep notification actions internal A notification action is effectively an application-authored redirect. If an admin form accepts arbitrary URLs, a compromised admin account or an incorrect integration could turn a trusted notification center into a phishing surface. Validate actions when they are created and again before they are followed. The Achromatic schema accepts only values that its shared redirect utility recognizes as an internal path: ```ts filename="schemas/notification-schemas.ts" lineNumbers actionUrl: z.string() .trim() .max(500) .refine( (value) => value === '' || getSafeRedirectPath(value, '') === value, 'Action URL must be an internal path' ) .optional(); ``` That rejects external URLs, protocol-relative values and malformed paths. It also keeps the notification portable between localhost, preview deployments and production because the stored destination does not contain an environment origin. If your product genuinely needs external actions, model them as a separate, explicit capability with an allowlist and clear external-link treatment. Do not silently relax the internal redirect check. ## Give administrators a workflow, not just a mutation A send endpoint is not enough for daily operations. Administrators need to understand the audience before delivery and inspect what has already been sent. The shipped admin page includes: - search across notification copy, recipient and creator - read-state and notification-type filters - server-side pagination - details in a side sheet - selection and bulk deletion with confirmation - a send sheet for one active user or all active users Broadcast delivery runs inside a database transaction and inserts recipients in bounded batches. Banned users are excluded from both recipient search and broadcasts. The confirmation step states the computed audience before the write begins. This is still a synchronous baseline. A very large customer base should move broadcast fan-out to a durable background job and record campaign progress. The user-facing data model can remain the same. ## Separate in-app delivery from email and push An in-app notification is durable product state. Email and browser push are delivery channels with different permissions, retry behavior and privacy constraints. Keep those concerns separate: - Insert the in-app row as the canonical message for the signed-in product. - Enqueue optional external delivery after the transaction succeeds. - Record channel attempts separately from `readAt`. - Do not treat an email open or push receipt as reading the in-app message. The Achromatic release does not claim real-time push delivery. The notification center reads database-backed state through tRPC and refreshes its cache after mutations. Products that need live arrival can add polling, server-sent events or a realtime provider without changing the authorization boundary. ## Plan retention before the table grows One-row-per-recipient broadcasts trade storage for simple queries. That is a reasonable default, but the product should still define retention. Common policies include: - delete read notifications after a fixed period - retain warning or compliance messages longer than informational messages - archive campaign-level analytics separately - cap the inbox query even when old rows remain in the database Make deletion explicit. A bulk administrative delete in Achromatic removes the selected rows from recipients' notification centers and confirms that effect before proceeding. ## Test boundaries and state transitions The most valuable tests exercise ownership and transitions rather than visual markup. Cover at least these cases: - a user can list only their own rows - another user's ID returns not found and cannot be marked read - mark-all affects only the current user - unread count changes after individual and bulk read mutations - a non-admin cannot list recipients, send or delete notifications - a banned user is not selectable and does not receive a broadcast - external and malformed action URLs are rejected - deleting a recipient cascades their rows - deleting a creator preserves delivered messages - optimistic UI rolls back after a failed mutation Then use a browser smoke test to verify the expanded sidebar, mobile drawer, popover tabs, unread badge, action navigation, admin filters, details sheet and bulk confirmation. ## What ships in Achromatic Pro Prisma and Pro Drizzle now share the same notification behavior and UI. Each edition includes its ORM-specific schema and database migration, the authenticated and admin tRPC routers, notification center, admin table, send and details sheets, validation schemas and focused tests. Existing projects need to apply the new migration before using the updated application code. No new environment variable is required. Follow the implementation guide for [Pro Prisma](/docs/starter-kits/pro-nextjs-prisma/admin-panel/notifications) or [Pro Drizzle](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/notifications), and review the exact release in the [Achromatic changelog](/changelog/august-2026-in-app-notifications). --- ## Next.js 16.3 and TypeScript 7 Upgrade Guide **URL**: https://www.achromatic.dev/blog/nextjs-16-3-typescript-7-upgrade **Description**: Upgrade a production Next.js SaaS application to Next.js 16.3 and native TypeScript 7 without overlooking compiler-API tooling, lockfiles or regression checks. **Published**: 2026-08-04 Next.js 16.3 and TypeScript 7 are complementary upgrades: the framework now supports the native TypeScript compiler during `next build`, while TypeScript 7 replaces the JavaScript compiler with a multithreaded native port written in Go. The dependency changes are small. The compatibility review should still be deliberate. TypeScript 7 removes old compiler options and does not yet expose a stable JavaScript compiler API, while Next.js 16.3 includes new opt-in runtime behavior that should not be enabled as an accidental side effect of a package upgrade. Both Achromatic starter kits now ship Next.js 16.3.0 and TypeScript 7.0.2. This guide explains what changed, which claims come from the upstream projects and what we checked before preparing the release. ## What Next.js 16.3 changes The official [Next.js 16.3 announcement](https://nextjs.org/blog/next-16-3) groups the release around navigation, development performance, builds and tooling. The headline addition is **Instant Navigations**, an opt-in set of caching and navigation features intended to make App Router transitions feel closer to a single-page application. Next.js 16.3 also adds persistent Turbopack build caching, lower development-server memory use, TypeScript 7 support in `next build` and server-rendering improvements. The performance figures in the announcement are Vercel's upstream benchmarks, not Achromatic measurements. Vercel reports up to 90% lower development-server memory use, cached build improvements that vary by codebase and up to 22% higher server-rendering throughput in its tests. Treat those figures as useful release context, not a promise for every application. For an existing SaaS product, the safest first step is simply to update the framework. Do not enable a new caching or navigation model in the same commit unless you intend to review its data-freshness and loading behavior separately. ## Why TypeScript 7 is different TypeScript 7 is a native port of the compiler and language service. Microsoft reports typical full-build speedups between 8x and 12x across the large open-source projects in its release tests, alongside lower aggregate memory use in those examples. Again, these are upstream results; measure your own project and CI environment before turning them into planning assumptions. The migration is more than a faster `tsc`. TypeScript 7 adopts the TypeScript 6 defaults and converts several TypeScript 6 deprecations into hard errors. Projects still using options such as `target: es5`, `baseUrl`, classic or Node 10 module resolution, or legacy module formats must update their configuration. Modern Next.js App Router projects are usually well positioned. Achromatic already used an ES2022 target, `moduleResolution: "Bundler"`, strict checking and project-relative path mappings, so both kits compiled without a tsconfig compatibility change. ## The compiler API needs special treatment TypeScript 7.0 does not ship a stable JavaScript compiler API. That matters to tools that import `typescript` and inspect an abstract syntax tree, even when the application itself type-checks cleanly. Pro Drizzle's local MCP server is one example. Its database adapter reads the checked-in TypeScript schema to describe tables, fields, indexes and constraints. That parser needs the TypeScript compiler API; the application compiler does not. Microsoft explicitly supports this transition with [`@typescript/typescript6`](https://www.npmjs.com/package/@typescript/typescript6). Achromatic keeps `typescript@7.0.2` as the project compiler and imports the official TypeScript 6 compatibility API only inside the Drizzle MCP parser. This preserves the read-only schema inspection behavior without running the application typecheck on the old compiler. Search your own repository before upgrading: ```bash filename="Terminal" lineNumbers rg 'typescript' --glob '*.ts' --glob '*.tsx' ``` If the result includes a custom code generator, AST transform or build tool, read Microsoft's [side-by-side guidance](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/#running-side-by-side-with-typescript-60) before changing its import. Do not add the compatibility package when no tool uses the compiler API. ## A focused upgrade sequence Update Next.js and its matching analyzer together, then install the new compiler: ```bash filename="Terminal" lineNumbers npm install --save-exact next@16.3.0 @next/bundle-analyzer@16.3.0 npm install --save-dev --save-exact typescript@7.0.2 ``` Verify the versions recorded by the lockfile: ```bash filename="Terminal" lineNumbers npm ls next @next/bundle-analyzer typescript ``` Then run validation in layers: ```bash filename="Terminal" lineNumbers npm run typecheck npm run lint npm run format npm run test:unit npm run build ``` Compile custom tooling independently when the repository has a separate tsconfig. Achromatic runs `npm run mcp:build` in addition to the application typecheck so a green app build cannot hide an MCP compiler-API failure. Finally, exercise the workflows that framework upgrades cannot prove through static analysis: authentication, organization selection, account settings, administrative authorization, billing callbacks and any custom Server Actions. ## What changed in Achromatic The Pro Prisma and Pro Drizzle releases contain the same framework and project compiler versions: | Dependency | Previous | Current | | ----------------------- | -------- | ------- | | Next.js | 16.2.12 | 16.3.0 | | `@next/bundle-analyzer` | 16.2.12 | 16.3.0 | | TypeScript | 6.0.3 | 7.0.2 | | React | 19.2.8 | 19.2.8 | Pro Drizzle additionally records the official TypeScript 6 compatibility package for its MCP parser and explicit PostgreSQL declarations used by the test database helper. Pro Prisma does not parse TypeScript source through the compiler API, so it does not need that package. Its Prisma client, PostgreSQL adapter and CLI were also moved to 7.9.1 after that patch resolved newly reported dependency advisories; the patch does not change the database schema. Both editions passed clean lockfile installation, application type checking on TypeScript 7, MCP compilation, Oxlint, Oxfmt, their unit suites and production builds. The update does not change the database schema and requires no migration. ## Keep feature adoption separate A dependency upgrade is easier to review when it preserves application behavior. Instant Navigations, Cache Components and experimental compiler options deserve their own design decision, test plan and rollout. That separation gives you a useful rollback boundary: first adopt the supported framework and compiler versions; then evaluate opt-in behavior with real route, cache and product requirements. It also keeps upstream performance claims from being confused with results you have actually measured in your application. The refreshed [Pro Prisma documentation](/docs/starter-kits/pro-nextjs-prisma) and [Pro Drizzle documentation](/docs/starter-kits/pro-nextjs-drizzle) describe the current kits, while the [release entry](/changelog/august-2026-nextjs-typescript-upgrade) records the exact coordinated update. --- ## Building a Repository-Aware MCP Server for Next.js **URL**: https://www.achromatic.dev/blog/repository-aware-mcp-server-nextjs **Description**: Learn how a local, read-only MCP server can give coding agents accurate architecture, implementation, documentation and database context without granting production access. **Published**: 2026-07-28 AI coding agents are much more useful when they can inspect a project before they propose a change. A generic model may know Next.js, but it does not automatically know which commands your repository exposes, where tenant authorization lives, whether the database uses Prisma or Drizzle, or which components already exist. A local [Model Context Protocol server](https://modelcontextprotocol.io/docs/getting-started/intro) can close that gap. Instead of putting an entire repository into one prompt, the server gives an agent a small set of structured discovery tools. Achromatic Pro now includes that server in both current starter kits. This article explains the design choices behind it and the patterns that also apply to custom Next.js applications. ## The problem is repository context, not model intelligence Most costly agent mistakes begin with an incorrect assumption: - inventing a package script that does not exist - using a migration command from the wrong ORM - creating a second component instead of reusing the shipped one - protecting a route without protecting the underlying operation - changing a tenant-owned query without preserving `organizationId` - treating old documentation or generated output as the source of truth A long instruction file helps, but it cannot answer every repository question. It also becomes stale when it repeats information that could have been read from source. The better split is: - instructions define non-negotiable engineering rules - MCP tools retrieve current repository facts - the agent combines both when planning or reviewing work That keeps static guidance compact while making frequently changing information discoverable. ## Why use narrow tools instead of one repository dump? Sending the full codebase to a model is expensive and noisy. It also makes it hard to know which information the agent actually used. Achromatic exposes 19 focused read-only tools across five areas: | Area | Questions the tools answer | | -------------- | ----------------------------------------------------------------------------------------------------------------- | | Project | What architecture, package versions, scripts, guardrails and validation commands does this checkout use? | | Components | Does a suitable UI or feature component already exist, where is it used, and what does its source look like? | | Implementation | Which routes, configuration, hooks, libraries, Zod schemas, tRPC files and types implement this system? | | Documentation | Which local guide covers this system, and where does a term appear? | | Database | What models, tables and constraints exist, which migrations are checked in, and what is the correct ORM workflow? | The agent requests only the context needed for the current task. A UI change does not need the full database schema. A migration review does not need every React component. Bounded output matters too. Achromatic limits component and implementation lists to 250 entries and searches to 50 line-level matches. Area and query filters let the agent narrow a broad result before it requests file contents. Limited tools include structured metadata indicating whether the cap was reached, so clients do not have to guess whether an exact-limit response is complete. This separation also produces clearer tool descriptions and more useful audit trails in clients that display tool calls. ## Keep the local server read-only An agent that needs context does not automatically need authority. The local Achromatic server deliberately does not expose tools that: - execute shell commands - connect to PostgreSQL - read `.env` files - change source files - call external APIs - deploy an application Its file tools work from lists of known documentation, components, implementation sources, schemas and migrations. Implementation reads are further limited to routes, configuration, core libraries, Zod schemas, tRPC files and selected root entry points. A caller must use a path returned by a discovery tool rather than requesting an arbitrary file. That is an important boundary, but it is not the only one a custom server should enforce. A robust file reader should also: 1. reject absolute paths and parent traversal 2. resolve the real repository and target paths before checking containment 3. reject symbolic-link escapes 4. limit the size and type of exposed files 5. return errors without leaking secret content 6. cover those conditions with regression tests Read-only does not mean risk-free. Tool output enters the model's context, so a server should expose the smallest useful surface. It should also tell the client to treat returned files as repository data rather than new user authority: an embedded command should not be executed merely because it appears inside source or documentation. ## Derive facts from the checkout Repository-aware tools are valuable only if they remain more accurate than a README summary. The Achromatic server derives: - package scripts from `package.json` - documentation paths and titles from local Markdown and MDX - component paths and public exported names from the current `components` directory - searchable implementation paths and source from the current `app`, `config`, `hooks`, `lib`, `schemas`, `trpc` and `types` directories plus selected root entry points - schema, constraint and migration information from the current ORM files The database adapter is the main place where the two starter kits differ. For the Prisma kit, the adapter parses `prisma/schema.prisma`, including field and model-level attributes, and lists the reviewed `migration.sql` files and migration lock metadata. For the Drizzle kit, it uses the TypeScript compiler API to inspect `pgTable` field builders, nullability, defaults, keys, references, index and constraint builders and enum objects, then lists the checked-in SQL migrations, snapshots and journal. Using an AST for TypeScript schema files is more reliable than a regular expression because chained builders, property access and formatting can vary. The adapter still has a deliberately narrow job: summarize the patterns used by the shipped schema instead of pretending to be a second ORM compiler. ## Tools, resources and prompts serve different jobs MCP supports more than tool calls. ### Tools Tools answer a bounded question, such as listing components, searching documentation or retrieving the schema-change workflow. For interoperability, each Achromatic tool returns a serialized text block for the model and the same value as machine-readable MCP structured content for clients that consume it programmatically. Each tool also publishes an output schema for the shared result envelope so the server validates the structured payload before returning it. ### Resources Resources provide stable context that a client can attach directly. Achromatic publishes project overview, database schema and documentation index resources. ### Prompts Prompts encode a repeatable workflow without adding authority. The feature planning prompt asks the agent to inspect the project, schema, documentation and reusable components. The review prompt focuses on tenant isolation, authorization, migrations, billing effects, secret handling and validation. Prompts should direct an agent toward tools, not restate the entire repository. ## A practical feature-planning sequence Suppose you want to add an organization audit log. A grounded agent can: 1. load the project overview and organization security rules 2. search the documentation for tenant and authorization patterns 3. inspect the current schema and request the ORM-specific change workflow 4. inspect the relevant route, core library, validation and tRPC source files 5. look for existing table, pagination and filter components 6. produce a plan covering the schema, migration, validation, tRPC procedure, interface, tests and documentation 7. return the repository's supported health-check commands The useful result is not merely more detailed. It is tied to actual files and commands in the current checkout. ## Keep provider access separate Repository context and external service access have different risk profiles. Stripe, Vercel and Linear publish provider-hosted MCP servers. They can be useful alongside the local Achromatic server, but they require separate authorization and may expose tools that affect external systems. Do not hide those permissions inside a repository discovery server. Register providers separately, select the narrowest useful scopes and use OAuth where the provider supports it: - [Stripe MCP documentation](https://docs.stripe.com/mcp) - [Vercel MCP documentation](https://vercel.com/docs/ai-tooling/vercel-mcp) - [Linear MCP documentation](https://linear.app/docs/mcp) This makes it obvious when an agent is reading local source and when it is interacting with an account. ## Set up the Achromatic server Install either current starter kit: ```bash filename="Terminal" lineNumbers npm install ``` The checked-in `.mcp.json` runs `npm run mcp:start` over stdio for Claude Code. Cursor, Visual Studio Code and Codex use different project configuration filenames, so the starter-kit guides provide a matching example for each client. The start command writes compiled output to the ignored `dist/` directory before connecting, so a clean checkout does not depend on committed or stale build output. Because project MCP configuration can launch local commands, review config changes after a pull or branch switch before approving the server. The complete references are available in the [Pro Prisma MCP guide](/docs/starter-kits/pro-nextjs-prisma/codebase/mcp-server) and [Pro Drizzle MCP guide](/docs/starter-kits/pro-nextjs-drizzle/codebase/mcp-server). ## The standard to aim for An MCP integration should make an agent more accurate without quietly making it more powerful than necessary. For a starter kit, that means: - repository-derived facts instead of copied version claims - ORM-specific workflows instead of generic database advice - component discovery before generation - explicit tenant and authorization guardrails - bounded, read-only local access - separate authorization for external providers - tests for the protocol contract and file boundary The protocol is only the transport. The quality of an MCP server comes from the context it selects, the authority it refuses and how reliably it stays aligned with the repository. --- ## Secure Email Changes with Better Auth in Next.js 16 **URL**: https://www.achromatic.dev/blog/secure-email-change-better-auth-nextjs **Description**: Build a two-step email change flow with Better Auth, Next.js, React Email and verification checks that protect both the current and new address. **Published**: 2026-07-28 Changing an account's email address looks like a small settings form. In practice, it changes the user's sign-in identity, recovery destination and the address that receives future security messages. That makes it an account takeover boundary, not a profile edit. A secure flow should prove control of the current mailbox, prove control of the new mailbox and update the account only after both steps succeed. Better Auth provides the primitives for this sequence, while your Next.js application still owns the form, email delivery, callback experience and any application-specific side effects. This guide follows the same architecture used by the current Achromatic Pro Prisma and Drizzle starter kits. ## The two-mailbox flow Better Auth's email change behavior depends on whether you configure current-email confirmation. With `sendChangeEmailConfirmation`, the complete journey is: 1. An authenticated user submits a new email address. 2. Better Auth sends an approval link to the **current** address. 3. Opening that link authorizes the request and triggers verification for the **new** address. 4. The user opens the second link from the new mailbox. 5. Better Auth updates the account email and redirects to the configured callback. The first step tells the existing account owner that a sensitive change was requested. The second proves that the replacement address is deliverable and controlled by the same person. Without current-email confirmation, Better Auth starts with verification of the new address. That is simpler, but anyone holding an authenticated session can initiate the change without proving access to the account's existing mailbox. The official [Better Auth user and account guide](https://better-auth.com/docs/concepts/users-accounts) documents both modes and leaves the feature disabled until you enable it explicitly. ## Configure both email stages Enable the change and provide two different email callbacks: ```typescript filename="lib/auth/index.ts" lineNumbers import { after } from 'next/server'; import { betterAuth } from 'better-auth'; export const auth = betterAuth({ user: { changeEmail: { enabled: true, sendChangeEmailConfirmation: async ({ user, newEmail, url }) => { after(() => { return sendCurrentEmailApproval({ recipient: user.email, newEmail, approvalLink: url }); }); } } }, emailVerification: { sendVerificationEmail: async ({ user, url }) => { after(() => { return sendEmailAddressVerification({ recipient: user.email, verificationLink: url }); }); } } }); ``` The callback names in your application should make the recipients unambiguous: - `sendCurrentEmailApproval` goes to the address already stored on the account - `sendEmailAddressVerification` goes to whichever address Better Auth is asking the user to verify. During an email change, that is the proposed address after the first link is approved Do not use one vague template for both messages. The current mailbox should say which new address was requested and offer guidance when the owner did not initiate the change. The new mailbox should say that opening its link completes verification. The Achromatic starter kits keep these concerns separate: Better Auth creates and validates the links, React Email renders the message and the configured mail provider delivers it. ## Keep email delivery outside the security decision An email provider can be slow or temporarily unavailable. The HTTP response time should not reveal whether delivery performed extra work for a particular address. Better Auth recommends dispatching transactional email without blocking the authentication request. The example uses Next.js [`after`](https://nextjs.org/docs/app/api-reference/functions/after), which schedules the provider call after the response finishes and extends the request lifetime on supported deployments. When self-hosting, configure the runtime support described by Next.js or use a durable queue whose completion does not depend on the request remaining open. The important distinction is: - Better Auth owns the signed verification state and database mutation - Your delivery layer receives an already-created URL and sends it to the intended mailbox - A delivery retry must not invent a new account update Do not put the raw verification token in logs, analytics events or error monitoring metadata. Treat the full URL as a credential until it expires or is consumed. ## Build the account settings form The browser starts the flow through `authClient.changeEmail`: ```typescript filename="components/user/change-email-card.tsx" lineNumbers 'use client'; import { authClient } from '@/lib/auth/client'; async function submitEmailChange(newEmail: string) { const { error } = await authClient.changeEmail({ newEmail, callbackURL: '/dashboard/settings?tab=profile' }); if (error) { throw new Error(error.message ?? 'Could not request email change'); } } ``` The callback should be a fixed application route or a value validated against your trusted origins. Do not accept an arbitrary URL from a query parameter and pass it through as the post-verification redirect. Client-side validation improves the interaction but is not the security boundary. A small Zod schema can reject malformed or unchanged values before the request: ```typescript filename="schemas/user-schemas.ts" lineNumbers import { z } from 'zod'; export const changeEmailSchema = z.object({ email: z.string().trim().email() }); ``` In the UI: - Display the current address as read-only - Label the new address explicitly - Disable repeat submission while the request is pending - Confirm that the first message was sent to the current mailbox - Do not imply that the account email has already changed The last point prevents a common support problem. After the first request succeeds, the user is waiting for two security checks—not an immediate profile update. ## Write each message for its actual recipient The approval email to the current address should contain: - The proposed new address - A clear “Approve email change” action - A statement that the account has not changed yet - Instructions to secure the account if the request was unexpected - The visible destination URL as a fallback The verification email to the new address should contain: - A clear “Verify new email” action - The product name and account context - A statement that verification completes the change - Expiration guidance that matches your Better Auth configuration Avoid asking the user to reply to either message with passwords, codes or personal information. Never include a password or session token in the template. You can preview both templates locally with React Email before exercising the live provider. Achromatic documents the shared email setup for [Prisma](/docs/starter-kits/pro-nextjs-prisma/email/overview) and [Drizzle](/docs/starter-kits/pro-nextjs-drizzle/email/overview). ## Handle application-owned side effects carefully Better Auth updates its user record after successful verification. Many SaaS applications also duplicate or derive identity data elsewhere: - Audit logs - Customer records - Search indexes - Notification preferences - Analytics profiles - External support or CRM systems Do not update those systems when the form is submitted or when only the current mailbox approves the request. The proposed address is not the account identity until verification of the new mailbox completes. If you run side effects after the final verification: 1. Read the canonical user state after Better Auth completes its mutation. 2. Scope the update by stable user ID, not by the old email alone. 3. Make the handler idempotent so a retry cannot create duplicate records or notifications. 4. Record the old and new values in an audit event without recording verification tokens. 5. Decide deliberately whether active sessions should be refreshed or revoked. Email addresses are mutable identifiers. Database relations and tenant membership should use the stable user ID. ## Account linking is a separate decision Changing an email and linking a social provider solve different problems. If the proposed address already belongs to another account, do not merge identities silently. Better Auth exposes separate account-linking configuration for connecting credential and OAuth identities. Keep that policy explicit and test the conflict path. Likewise, changing the local account email should not rewrite the email asserted by Google, GitHub or another provider. OAuth profile data belongs to the provider account; your application user is the stable identity that can hold several accounts. The [Better Auth options reference](https://better-auth.com/docs/reference/options) documents account linking independently from user email changes. ## Test the complete sequence A form-rendering test is not enough. Cover the state transitions that can strand or compromise an account. ### Local and integration tests Verify: - The endpoint requires an authenticated session - An invalid address is rejected - The current address cannot be submitted as a meaningful change - A conflicting address does not merge two users - The first message is addressed to the current mailbox - The first link alone does not update the account - Approving the current mailbox triggers a message to the new mailbox - The final link updates the canonical user - Expired or malformed links leave the account unchanged - Replaying a completed link does not repeat application side effects ### Browser and staging smoke tests Use dedicated test mailboxes to prove: - The settings form shows an accurate pending-state message - Both links open on the expected application origin - The callback returns to account settings - The old credential identifier works before completion - The new credential identifier works after completion - The session and displayed profile agree with the database Provider-backed smoke tests belong in staging rather than the deterministic local Playwright suite. That keeps CI reliable while still catching delivery configuration, domain and redirect regressions. ## Common mistakes ### Updating immediately Setting the email directly after form submission skips proof of mailbox control and can lock the owner out. ### Confirming only the new address This proves access to the destination but does not alert or involve the current mailbox. Decide whether that is sufficient for your threat model rather than accepting the simpler default accidentally. ### Sending both messages with the same copy The user cannot tell whether a link authorizes the request or completes it. Distinct subjects and actions make the security state clear. ### Trusting a client callback URL An unvalidated redirect can turn a legitimate verification message into an open-redirect or phishing path. ### Triggering side effects too early CRM, analytics or audit integrations should observe the final verified identity, not a pending request. ### Using email as a relation key An email change should not break organization membership, billing ownership or application data. Reference a stable user ID. ## Production checklist Before enabling email changes: - Current-email confirmation is enabled when your threat model requires it - The current and new mailboxes receive purpose-specific templates - Verification URLs use the canonical HTTPS application origin - Callback destinations are fixed or allowlisted - Token-bearing URLs are excluded from logs and analytics - Delivery runs through a request-safe background or queue mechanism - Conflicting destination addresses fail safely - Application side effects wait for final verification and are idempotent - Audit records use stable user IDs - Session behavior after completion is tested and documented - Expired, malformed and replayed links cannot change the account - A staging smoke test proves both real messages and redirects ## Treat the email as an identity The safest implementation is also the easiest to explain: approve the request from the current mailbox, verify the replacement mailbox and only then change the account. Better Auth handles the signed state and core mutation. Next.js supplies the account settings and callback experience. React Email makes each step understandable. Your application is responsible for keeping redirects, delivery and downstream side effects inside that verified boundary. Both Achromatic Pro editions ship the form, Better Auth configuration and transactional email foundation needed for this flow. Start with the [Prisma authentication guide](/docs/starter-kits/pro-nextjs-prisma/authentication/overview) or [Drizzle authentication guide](/docs/starter-kits/pro-nextjs-drizzle/authentication/overview). --- ## Better Auth End-to-End Testing with Playwright in Next.js 16 **URL**: https://www.achromatic.dev/blog/better-auth-playwright-testing-nextjs **Description**: Build reliable Playwright tests for Better Auth sign-in, TOTP, organization access, AI credit enforcement and admin authorization in a production Next.js SaaS. **Published**: 2026-07-27 An authentication page can render perfectly while the product behind it is broken. The session may not survive a redirect. Two-factor enrollment may succeed but the next sign-in may fail. An organization member may reach another tenant's route. A regular user may see an administrator page. Those are integration failures, so they need integration-level evidence. This guide builds a focused Playwright suite around the authentication and authorization flows that matter in a production Next.js SaaS. The examples come from the end-to-end tests shipped in both Achromatic Pro starter kits, with the same behavior covered in the [Prisma](/docs/starter-kits/pro-nextjs-prisma/tests/e2e) and [Drizzle](/docs/starter-kits/pro-nextjs-drizzle/tests/e2e) editions. ## Test outcomes, not authentication components Playwright recommends testing user-visible behavior instead of implementation details. That distinction is especially useful for authentication. Your suite should not care which React component owns the email field; it should prove what a user can do after the server accepts their credentials. A compact SaaS authentication matrix can look like this: | Boundary | Behavior to prove | | ------------------------- | ------------------------------------------------------------------- | | Credential sign-in | Valid credentials create a session and reach the dashboard | | Session | Authenticated navigation stays authenticated | | Two-factor authentication | A user can enroll and must provide a valid TOTP on the next sign-in | | Organization access | A member can enter the expected workspace routes | | Product entitlement | A server-enforced credit or plan limit blocks an unavailable action | | Global administration | An application administrator can reach protected admin surfaces | This is deliberately smaller than a test for every page. E2E tests are most valuable at boundaries where the browser, application server, authentication library and database must agree. ## Start every test from known database state Authentication tests mutate durable state: sessions are created, TOTP secrets are stored and organization memberships are read. Reusing whatever happens to be in a developer database makes failures difficult to reproduce. The Achromatic suite runs an idempotent seed before each authenticated scenario: ```typescript filename="tests/e2e/application.spec.ts" lineNumbers import { execFileSync } from 'node:child_process'; import { test } from '@playwright/test'; test.describe.configure({ mode: 'serial' }); test.beforeEach(() => { execFileSync(process.execPath, ['--env-file=.env', 'tests/e2e/seed.mjs']); }); ``` The seed creates two identities: - An organization owner with a known credential account and membership - An application administrator with a known credential account It also resets mutable two-factor state. Re-running the suite therefore starts with the same users, password hashes, roles and organization on every pass. Keep test-only identities local to the test environment. Do not seed predictable credentials into production, and never commit a Playwright storage-state file containing live cookies. The [Playwright authentication guide](https://playwright.dev/docs/auth) warns that saved browser state can impersonate the account it belongs to. ## Use a small sign-in helper The sign-in helper should cross the public UI and assert the resulting session boundary. Keep it boring: ```typescript filename="tests/e2e/application.spec.ts" lineNumbers import { expect, type Page } from '@playwright/test'; async function signIn(page: Page, email: string) { await page.goto('/auth/sign-in'); await page.getByLabel('Email').fill(email); await page.getByLabel('Password', { exact: true }).fill('E2e-password-123!'); await page.getByRole('button', { name: 'Sign in' }).click(); await expect(page).toHaveURL(/\/dashboard/); } ``` Role and label locators describe what the user interacts with and remain more resilient than selectors coupled to CSS classes or DOM nesting. Do not hide the entire journey behind a large helper. The test should still make the important transition legible: which identity signed in, which route it opened and what authorization outcome it observed. ## Prove the session across organization routes Reaching `/dashboard` proves only the initial redirect. A useful B2B test continues into organization-scoped product surfaces: ```typescript filename="tests/e2e/application.spec.ts" lineNumbers test('owner can navigate account and organization surfaces', async ({ page }) => { await signIn(page, 'owner@e2e.local'); await expect( page.getByRole('heading', { name: 'Your Organizations' }) ).toBeVisible(); await page.getByText('Open', { exact: true }).click(); await expect(page).toHaveURL(/\/dashboard\/organization/); for (const path of ['leads', 'settings', 'chatbot']) { await page.goto(`/dashboard/organization/${path}`); await expect(page).not.toHaveURL(/auth\/sign-in/); } }); ``` This single scenario exercises the credential flow, session cookie, organization membership and protected route handling. It complements narrower unit tests around permission functions. Better Auth's [organization plugin](https://better-auth.com/docs/plugins/organization) supports organizations, members, invitations, teams and access control. Your application still owns the product-specific question: which organization is active, which records belong to it and which routes a member may open. Test those decisions through your application, not just the plugin API. ## Test TOTP enrollment and the next sign-in Two-factor testing often stops after the setup dialog says “enabled.” That misses the most important half of the flow: whether the next credential sign-in is challenged and accepts the enrolled factor. TOTP is time based, so the test needs a real code generated from the secret shown during enrollment. A minimal generator uses the standard 30-second counter and HMAC-SHA1: ```typescript filename="tests/e2e/application.spec.ts" lineNumbers import { createHmac } from 'node:crypto'; function totp(secret: string) { const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; let bits = ''; for (const char of secret) { bits += alphabet.indexOf(char).toString(2).padStart(5, '0'); } const key = Buffer.from( (bits.match(/.{8}/g) ?? []).map((byte) => Number.parseInt(byte, 2)) ); const counter = Buffer.alloc(8); counter.writeBigUInt64BE(BigInt(Math.floor(Date.now() / 30_000))); const hash = createHmac('sha1', key).update(counter).digest(); const offset = hash[19]! & 15; return ((hash.readUInt32BE(offset) & 0x7fffffff) % 1_000_000) .toString() .padStart(6, '0'); } ``` A code generated near the end of its window can expire while Playwright fills and submits the form. Avoid that timing flake by waiting for the next window when necessary: ```typescript filename="tests/e2e/application.spec.ts" lineNumbers async function stableTotp(secret: string) { const elapsed = Date.now() % 30_000; if (elapsed > 15_000) { await new Promise((resolve) => setTimeout(resolve, 30_500 - elapsed)); } return totp(secret); } ``` The complete scenario should: 1. Sign in and open security settings. 2. Confirm the current password before enrollment. 3. Read the Base32 secret shown by the application. 4. Submit a stable TOTP and verify that two-factor authentication is enabled. 5. Sign out and clear browser cookies. 6. Sign in again with the same credentials. 7. Assert the redirect to the verification route. 8. Submit a fresh TOTP and assert access to the dashboard. Better Auth documents the TOTP verification flow and its accepted time windows in the [two-factor authentication plugin guide](https://better-auth.com/docs/plugins/2fa). Running the enrollment and re-authentication journey in one scenario proves that the stored secret, redirect and session upgrade work together. ## Treat authorization and entitlements as separate boundaries Authentication answers who the user is. It does not prove what the user may do. For a multi-tenant SaaS, add scenarios for at least two different authorization layers: ### Product entitlement The Achromatic AI chat test signs in as the organization owner, opens the chatbot, submits a message and expects `Not enough credits`. The valuable assertion is not that the input accepts text. It is that the server-enforced organization credit balance prevents the operation. Testing the blocked case is important because optimistic UI alone can make an unavailable action appear successful. ### Application administration Organization roles and global application roles are not interchangeable. A separate admin identity verifies the protected application-level surfaces: ```typescript filename="tests/e2e/application.spec.ts" lineNumbers test('administrator can access every admin surface', async ({ page }) => { await signIn(page, 'admin@e2e.local'); for (const [path, heading] of [ ['users', 'Users'], ['organizations', 'Organizations'], ['app-config', 'App Config'] ] as const) { await page.goto(`/dashboard/admin/${path}`); await expect(page.getByRole('heading', { name: heading })).toBeVisible(); } }); ``` Pair this happy-path scenario with lower-level authorization tests for denial cases. A browser suite should cover the highest-risk role transitions without becoming the only place your permission rules are tested. ## Let rate limiting stay real Better Auth applies stricter rules to sensitive endpoints. Its documentation currently lists email sign-in as limited to three requests per ten seconds, separate from the broader default rate limit. That protection can surprise an E2E suite that performs several credential sign-ins from the same local address. Disabling the limiter entirely makes the test environment less representative. The Achromatic suite instead: - Runs authenticated, state-changing scenarios serially - Uses one Playwright worker - Waits for the short protection window to reset before a separate sign-in flow would exceed it See the [Better Auth rate-limit documentation](https://better-auth.com/docs/concepts/rate-limit) before changing these timings. If your configuration uses different endpoint rules or distributed storage, align the test with that actual setup rather than copying a fixed delay. ## Run the production build in CI A development server can hide build-time and production-runtime problems. The shipped Playwright configuration builds and starts Next.js before opening Chromium: ```typescript filename="playwright.config.ts" lineNumbers export default defineConfig({ fullyParallel: false, retries: process.env.CI ? 1 : 0, workers: 1, use: { baseURL: 'http://localhost:3000', trace: 'on-first-retry', video: { mode: 'retain-on-failure', size: { width: 640, height: 480 } } }, webServer: { command: 'npm run build && npm run start', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, timeout: 180_000 } }); ``` Playwright's [`webServer` configuration](https://playwright.dev/docs/test-webserver) manages the application process and waits for its URL. Retaining a trace on the first retry and a video on failure gives CI enough evidence to diagnose redirects, dialogs and timing issues without recording every successful run. ## Keep provider-owned flows in a staging smoke suite The local suite described here does not claim to prove every external integration. Real email delivery, OAuth consent screens and payment-provider redirects depend on systems outside the local application. Separate them by responsibility: - **Local E2E:** credentials, sessions, TOTP, organization access, roles and application-owned entitlements - **Provider contract tests:** payload construction, webhook signatures and application handlers - **Staging smoke tests:** real verification email delivery, password reset links, OAuth callbacks and payment redirects using dedicated test accounts Do not mock the database or Better Auth inside the browser journey you are calling E2E. Mock only the external boundary when the goal is to test your application deterministically, then keep a smaller real-provider smoke suite for integration drift. ## A practical authentication test checklist Before treating the authentication path as release-ready, verify: - Test users, roles and organizations are created deterministically - Mutable TOTP and session state is reset between relevant scenarios - Locators describe visible labels, roles and outcomes - Credential sign-in ends with an authenticated route assertion - Organization navigation proves the intended tenant context - TOTP enrollment is followed by a fresh challenged sign-in - Entitlement limits are asserted at the user-visible boundary - Global admin access is tested separately from organization membership - Rate limits remain enabled and the suite respects their windows - CI runs a production build and retains useful failure artifacts - Saved authentication state and test secrets are excluded from Git - Email, OAuth and payment smoke tests run in an appropriate external environment ## Start with the critical path You do not need hundreds of browser tests to gain confidence in authentication. Begin with one deterministic identity per meaningful role and one scenario per high-risk boundary. Add a test when a failure could cross a tenant, bypass a factor, expose an admin surface or charge for an unavailable feature. The Achromatic Pro Prisma and Drizzle editions ship the same focused Playwright coverage, so the testing model does not change when you choose a different ORM. Explore the complete setup in the [Prisma E2E guide](/docs/starter-kits/pro-nextjs-prisma/tests/e2e) or [Drizzle E2E guide](/docs/starter-kits/pro-nextjs-drizzle/tests/e2e). --- ## Achromatic vs Supastarter: The Better Value for Next.js Teams **URL**: https://www.achromatic.dev/blog/achromatic-vs-supastarter **Description**: Compare Achromatic and Supastarter on price, team licensing, architecture, ORM choice and product scope before choosing a Next.js SaaS starter kit. **Published**: 2026-07-21 **Updated**: 2026-07-21 Achromatic and Supastarter are production-oriented SaaS foundations with authentication, organizations, billing, administration, email, storage, testing and documentation. They differ most in architecture, surrounding product breadth and license price. For a team building a focused Next.js and PostgreSQL product, Achromatic is the better value. Its $180 license includes both current Prisma and Drizzle repositories and covers every developer in the licensed team or organization. Supastarter offers a broader monorepo, more framework editions and more built-in integrations at a higher price. This comparison uses Supastarter's public product, pricing, license, documentation and changelog information checked on July 21, 2026. Confirm current details on the [Supastarter website](https://supastarter.dev/) and [Achromatic pricing page](/pricing) before buying. Achromatic costs $169 less than Supastarter's one-seat Solo plan and $619 less than its five-seat Startup plan. Achromatic includes both ORM editions and covers every developer in one licensed team or organization. Choose Supastarter when its Turborepo architecture, Nuxt or TanStack Start editions, internationalization, passkeys or broader integration surface will replace work your team would otherwise need to do. ## The verdict - **Choose Achromatic for a focused Next.js product.** It provides the core B2B SaaS foundation, both PostgreSQL ORM editions and team-wide repository access for $180. - Choose **Supastarter** when you want a modular monorepo, multiple framework editions, a larger integration surface or its established showcase and support ecosystem. - Supastarter is broader. Achromatic is less expensive and deliberately simpler. The better choice depends on which scope you will actually keep. ## Achromatic vs Supastarter at a glance | Area | Achromatic | Supastarter | | ---------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | Core architecture | One standalone Next.js application | Turborepo monorepo with applications and shared packages | | Framework editions | Next.js | Next.js, Nuxt and TanStack Start | | ORM choice | Separate Prisma and Drizzle repositories included | Prisma and Drizzle supported | | Authentication | Better Auth | Better Auth | | Organizations | Organizations, invitations, roles and member management | Organizations, invitations, roles and configurable multi-tenancy | | Billing | Stripe subscriptions, one-time payments, per-seat billing and prepaid credits | Multiple providers, subscriptions, one-time, usage and seat-based billing | | API | tRPC | Hono with typed clients, OpenAPI and oRPC integration | | Internationalization | Not included as a core product system | Built-in internationalization and translated email templates | | Testing | Vitest and Playwright setup | Playwright, unit tests and GitHub Actions advertised | | Update delivery | Lifetime updates through the private repositories; you integrate Git changes | Lifetime updates through an upstream Git remote; you integrate Git changes | | Client-work terms | Unlimited client End Products; clients do not receive source access | Public license allows client products; pricing reserves client projects for Agency | | Solo price checked July 2026 | $180 one-time | $349 one-time for one developer seat | | Team price checked July 2026 | $180 for the licensed team or organization | $799 for up to five developer seats | | Best fit | Focused Next.js and PostgreSQL teams | Teams wanting a broader framework and integration ecosystem | The table reflects the public products, not every customization either codebase permits. ## Sources for the comparison - Supastarter's [product page](https://supastarter.dev/) documents its architecture, framework editions, authentication, billing, organizations, API, internationalization, testing and integrations. - Its public pricing lists Solo at $349 for one developer, Startup at $799 for up to five developers and Agency at $1,499 for up to ten developers. - The [Supastarter license](https://supastarter.dev/legal/license) allows multiple end products for the buyer or clients and prohibits sharing a license with other individuals or companies. Its pricing copy separately describes Solo as unlimited personal projects and Agency as unlimited client projects, so buyers doing client work should confirm which wording governs their purchase. - The [Supastarter documentation](https://supastarter.dev/docs/nextjs) is the primary source for implementation and setup details. - Its [codebase update guide](https://supastarter.dev/docs/nextjs/codebase/update) documents the upstream Git workflow and explains that integrating updates becomes harder as a product diverges from the starter. - The [Supastarter changelog](https://supastarter.dev/changelog) provides a dated public record of releases across its framework editions. - Achromatic's side is supported by [pricing](/pricing), the [license](/license), [Prisma documentation](/docs/starter-kits/pro-nextjs-prisma) and [Drizzle documentation](/docs/starter-kits/pro-nextjs-drizzle). ## Why Achromatic is the better value ### One license covers the licensed team Achromatic's $180 license covers every developer who belongs to one licensed team or organization. Supastarter publicly lists $349 for one developer seat and $799 for up to five seats. For a five-developer team, Achromatic is $619 below Supastarter's listed Startup price. It also avoids requiring a higher tier when a sixth developer joins the same licensed organization. | Developers with source access | Achromatic | Supastarter tier | Supastarter price | Difference | | ----------------------------- | ---------- | ---------------- | ----------------- | ---------- | | 1 | $180 | Solo | $349 | $169 | | Up to 5 | $180 | Startup | $799 | $619 | | Up to 10 | $180 | Agency | $1,499 | $1,319 | These are the one-time prices published on July 21, 2026. The tiers also differ in project-use language and support, so this table isolates repository access cost rather than claiming that every tier delivers identical services. ### Explicit client-work terms Achromatic's license explicitly allows unlimited personal, internal, commercial and client End Products. A client may use the delivered End Product, while repository access remains limited to developers in the licensed team or organization. Supastarter's public license also says the buyer may create products for multiple clients. Its pricing section, however, advertises unlimited personal projects for Solo, unlimited team projects for Startup and unlimited client projects for Agency. Because those descriptions are not perfectly aligned, agencies and freelancers should ask Supastarter which tier covers their intended client work before checkout. ### Both ORM implementations are included Achromatic provides separate maintained [Prisma](/docs/starter-kits/pro-nextjs-prisma) and [Drizzle](/docs/starter-kits/pro-nextjs-drizzle) repositories under the same license. The surrounding product capabilities remain aligned while each repository follows its ORM's conventions. That is useful when the team has not chosen an ORM, operates several products or wants to evaluate complete implementations instead of small examples. ### A deliberately standalone architecture Each Achromatic edition is one Next.js application. Application code, database access, tests and configuration stay in the same deployment unit. For one web product, this avoids workspace packages and monorepo orchestration. Supastarter's Turborepo structure is stronger when the team wants several applications or reusable package boundaries. It is additional architecture when the product only needs one Next.js application. ### A smaller update-integration surface Both licenses include future updates, but neither product can safely overwrite a customized application. Supastarter's official guide uses an upstream Git remote and warns that updates become harder to integrate as your code diverges. Achromatic updates arrive through the private Prisma and Drizzle repositories and still require you to review, merge or reimplement relevant changes. For a single web product, Achromatic's standalone repository generally leaves fewer workspace boundaries to reconcile than Supastarter's multi-application monorepo. That is an architectural inference, not a promise that every Achromatic update will merge cleanly. Before choosing either product, inspect its public changelog, keep product customizations in focused commits and budget time to test upstream changes. ## Where Supastarter is stronger ### More framework and integration choices Supastarter publicly offers Next.js, Nuxt and TanStack Start editions. Its Next.js product also advertises multiple payment providers, Hono and oRPC APIs, internationalization, passkeys, notifications, background-task integrations and a Fumadocs-based documentation application. Choose that breadth when these systems are launch requirements. Achromatic is intentionally narrower and should not be selected when a Supastarter-specific integration would otherwise need to be built immediately. ### A larger public product ecosystem Supastarter publishes a customer showcase, broader documentation and several license tiers with consulting and priority-support options. Achromatic provides direct support and public documentation but has a smaller surrounding ecosystem. ## Decide from your actual requirements | Your situation | Better fit | Reason | | ------------------------------------------------------------ | ----------- | ---------------------------------------------------------------------- | | One Next.js application with a small or growing team | Achromatic | Standalone architecture and team-wide access keep cost and scope lower | | You want both Prisma and Drizzle implementations | Achromatic | Both current repositories are included | | More than five developers belong to one licensed team | Achromatic | The license is not priced per developer within that organization | | You want the smallest Git surface for one web application | Achromatic | Each ORM edition remains a standalone Next.js repository | | You need Nuxt or TanStack Start | Supastarter | Supastarter publishes dedicated framework editions | | Built-in i18n or passkeys are launch requirements | Supastarter | Those capabilities are advertised in its current product | | You want a modular monorepo and broad integration collection | Supastarter | Its architecture and product scope are designed for that model | ## Verify before purchasing 1. Inspect the [Achromatic live demo](https://demo.achromatic.dev) and [Supastarter demo](https://demo.supastarter.dev). 2. Compare the setup, database, authentication, billing, testing and deployment documentation. 3. Confirm which features are shipped code, optional integrations or documented recipes. 4. Review both licenses for repository access, client work and team sharing. 5. Price the tier that grants the access your actual development team requires. ## Which should you choose? Choose Achromatic when you want a focused Next.js foundation, a standalone repository, both ORM implementations and straightforward team-wide access. For that buyer, the $180 license offers the stronger price-to-capability ratio. Choose Supastarter when its broader framework selection, monorepo, internationalization, passkeys, API stack or integration catalog will remove meaningful work from your roadmap. ## Continue comparing - Read [Achromatic vs MakerKit](/blog/achromatic-vs-makerkit) for another broad B2B ecosystem comparison. - Read [Achromatic vs TurboStarter](/blog/achromatic-vs-turbostarter) for another multi-platform TypeScript comparison. - Read [Achromatic vs ShipFast](/blog/achromatic-vs-shipfast) for a smaller founder-focused alternative. - Use the [Prisma vs Drizzle guide](/blog/prisma-vs-drizzle-orm) after choosing Achromatic. --- ## Achromatic vs TurboStarter: The Better Value for Web-First SaaS **URL**: https://www.achromatic.dev/blog/achromatic-vs-turbostarter **Description**: Compare Achromatic and TurboStarter on pricing, team licensing, architecture, billing, platforms and product scope before choosing a SaaS starter kit. **Published**: 2026-07-21 **Updated**: 2026-07-28 Achromatic and TurboStarter both provide authentication, organizations, billing, administration, email, storage, testing and deployment guidance. Their central difference is product shape: Achromatic is a focused standalone Next.js foundation, while TurboStarter is a multi-platform monorepo spanning web, mobile and browser extensions. For a web-first Next.js SaaS, Achromatic is the better value. Its $180 license includes both current Prisma and Drizzle repositories and covers every developer in the licensed team or organization. TurboStarter provides substantially more cross-platform scope, but charges per individual seat under its current license. This comparison uses TurboStarter's public product, documentation, license and comparison information checked on July 21, 2026, with Achromatic's MCP availability updated on July 28. Confirm current details on the [TurboStarter website](https://www.turbostarter.dev/) and [Achromatic pricing page](/pricing) before buying. Achromatic costs $69 less than TurboStarter's temporary Core sale price and $169 less than its listed standard price. One Achromatic license covers every developer in the licensed team or organization and includes both ORM editions. Choose TurboStarter when mobile, browser extensions, multiple payment providers, passkeys, internationalization or its broader AI tooling and CLI will replace work already present on your roadmap. ## The verdict - **Choose Achromatic for a focused Next.js product.** It offers the core B2B SaaS systems, both PostgreSQL ORM editions and team-wide access at the lower price. - Choose **TurboStarter** when web, Expo mobile and WXT browser-extension clients must share packages from the beginning. - Both are credible foundations. Achromatic wins on focus, repository simplicity and license value. TurboStarter wins on platform breadth and provider choice. ## Achromatic vs TurboStarter at a glance | Area | Achromatic | TurboStarter | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- | | Architecture | One standalone Next.js application | Turborepo workspace with shared packages | | Platforms included | Next.js web application | Next.js web, Expo mobile and WXT browser extension | | ORM | Separate Prisma and Drizzle repositories included | Drizzle | | Authentication | Better Auth | Better Auth with passkeys and anonymous access advertised | | Organizations | Organizations, invitations, roles and member management | Organizations, invitations, roles and shared cross-platform context | | Billing | Stripe subscriptions, one-time payments, per-seat billing and prepaid credits | Multiple providers with subscriptions, one-time, metered, seats and credits | | AI development | Agent instructions and a local read-only MCP server with repository, implementation, documentation and ORM context | Agent rules, skills, commands, MCP and CLI | | Internationalization | Not included as a core product system | Included across platforms | | Standard price checked July 2026 | $180 one-time | $349 one-time for Core | | Temporary sale price checked July 2026 | $180 | $249 | | Team access | Every developer in one licensed team or organization | One purchased license seat per individual, including contractors | | Best fit | Web-first Next.js and PostgreSQL products | Products shipping web, mobile and extension clients | ## Sources for the comparison - TurboStarter's [product and pricing page](https://www.turbostarter.dev/) lists its current platform scope, features, temporary price and $349 standard Core price. - Its [web documentation](https://www.turbostarter.dev/docs/web) documents the monorepo, authentication, organizations and multi-platform approach. - The [TurboStarter EULA](https://www.turbostarter.dev/legal/license) requires one license seat for each person who accesses the software, including employees and contractors. - TurboStarter's own [Achromatic comparison](https://www.turbostarter.dev/compare/turbostarter-vs-achromatic) was reviewed for its current positioning and claims. - Achromatic's current scope is documented through [pricing](/pricing), the [license](/license), [Prisma documentation](/docs/starter-kits/pro-nextjs-prisma), [Drizzle documentation](/docs/starter-kits/pro-nextjs-drizzle) and [changelog](/changelog). ## Correcting outdated Achromatic claims TurboStarter's comparison currently describes Achromatic authentication as “Better Auth or Auth.js” depending on the kit. Achromatic's two current paid repositories both use Better Auth. Legacy Auth.js content is not the current product offer. It also summarizes Achromatic billing as Stripe subscriptions and a customer portal. The current Prisma and Drizzle editions additionally include one-time payments, per-seat billing and prepaid credits, with related administration and webhook flows. These corrections do not diminish TurboStarter's genuine multi-provider advantage. They ensure buyers compare its current product with Achromatic's current product rather than legacy variants. ## Why Achromatic is the better web-first value ### Team access is included at $180 Achromatic's license covers every developer in one licensed team or organization. TurboStarter's EULA requires a separate seat for each individual who accesses the software. For teams, the meaningful comparison is therefore not only $180 versus a $349 standard Core license. It is one $180 organizational license versus the number of TurboStarter seats required for employees and contractors. | Developers with source access | Achromatic | TurboStarter at the listed $349 standard price | Difference | | ----------------------------- | ---------- | ---------------------------------------------- | ---------- | | 1 | $180 | $349 | $169 | | 3 | $180 | $1,047 | $867 | | 5 | $180 | $1,745 | $1,565 | This calculation multiplies TurboStarter's listed standard Core price by the one-seat-per-person requirement in its EULA. It excludes the temporary sale and any volume pricing that may be offered privately or during checkout, so confirm the final quote before buying. TurboStarter also states that seats are assigned to named individuals and cannot be transferred between people. An agency using rotating contractors should therefore confirm how replacement developers are licensed. Achromatic does not price repository access per developer inside the licensed team or organization, although access cannot be extended to unrelated clients, affiliates or organizations. ### Both ORM editions are included Achromatic includes separate maintained [Prisma](/docs/starter-kits/pro-nextjs-prisma) and [Drizzle](/docs/starter-kits/pro-nextjs-drizzle) repositories. Both editions share the same surrounding SaaS capabilities while following their ORM's own conventions. TurboStarter currently centers its database layer on Drizzle. Choose it when that is already your preferred ORM. Choose Achromatic when ORM flexibility or Prisma support matters. ### Less architecture for one deployment target Achromatic deliberately keeps each edition as one Next.js application. A product that only ships a web client does not need to understand workspace package boundaries or coordinate three platform applications. TurboStarter's shared-package monorepo is the stronger architecture when mobile and extension clients are real requirements. It is additional scope when they are only hypothetical future ideas. ## Where TurboStarter is stronger TurboStarter includes web, Expo mobile and WXT browser-extension foundations in its Core offer. It also publicly advertises multiple billing providers, passkeys, internationalization, a CLI, broader agent tooling and cross-platform reuse for authentication, billing and organizations. Both products now ship an MCP server. Achromatic's [Prisma](/docs/starter-kits/pro-nextjs-prisma/codebase/mcp-server) and [Drizzle](/docs/starter-kits/pro-nextjs-drizzle/codebase/mcp-server) guides document its 19-tool, local read-only contract. TurboStarter's surrounding CLI, rules, skills and multi-platform context remain meaningful advantages for its cross-platform roadmap. Achromatic does not claim equivalent mobile or browser-extension applications and should not be chosen when those clients must ship from one shared workspace immediately. ## Decide from the roadmap you have | Your situation | Better fit | Reason | | --------------------------------------------------------- | ------------ | --------------------------------------------------------------------- | | One Next.js product with a small or growing team | Achromatic | Standalone architecture and team-wide access reduce cost and overhead | | Contractors rotate through one licensed delivery team | Achromatic | Access is not sold as non-transferable named seats | | You want both Prisma and Drizzle implementations | Achromatic | Both repositories are included | | Stripe is your intended billing provider | Achromatic | The current kit includes deep Stripe billing and administration flows | | Mobile and browser extensions are committed roadmap items | TurboStarter | All three platform foundations are included | | You require several payment providers behind one API | TurboStarter | Multi-provider billing is a core part of its public offer | | Passkeys and built-in internationalization are required | TurboStarter | Both are advertised as shipped capabilities | ## Verify before purchasing 1. Test the [Achromatic demo](https://demo.achromatic.dev) and [TurboStarter demo](https://demo.turbostarter.dev). 2. Compare the exact authentication, organization, billing, testing and deployment documentation. 3. Decide whether mobile and extension foundations are requirements or unused scope. 4. Calculate the required repository seats for every employee and contractor. 5. Review the applicable licenses before sharing source code. ## Which should you choose? Choose Achromatic when you are building a web-first Next.js SaaS and value a standalone repository, both ORM implementations, deep Stripe workflows and team-wide access for $180. Choose TurboStarter when its mobile app, browser extension, shared monorepo packages, provider flexibility and additional AI tooling justify the higher price and per-person licensing model. ## Continue comparing - Read [Achromatic vs Supastarter](/blog/achromatic-vs-supastarter) for another multi-framework and monorepo comparison. - Read [Achromatic vs MakerKit](/blog/achromatic-vs-makerkit) for a broader B2B starter-kit ecosystem. - Read [Achromatic vs ShipFast](/blog/achromatic-vs-shipfast) for a smaller founder-focused alternative. --- ## Achromatic vs MakerKit: The Better Value for a Focused Next.js SaaS **URL**: https://www.achromatic.dev/blog/achromatic-vs-makerkit **Description**: See why Achromatic is the better-value MakerKit alternative for teams that want both Prisma and Drizzle, a standalone Next.js architecture and a lower one-time price. **Published**: 2026-07-20 **Updated**: 2026-07-28 Achromatic and MakerKit are both production-oriented Next.js SaaS starter kits. For a team building one focused Next.js product, Achromatic is the better value: it includes both Prisma and Drizzle editions, costs less and avoids imposing a monorepo when the product does not need one. MakerKit has a broader ecosystem and longer operating history. That makes it a strong choice for buyers who will use its additional frameworks, community, course and plugins. It does not make it the better purchase for every Next.js team. This comparison uses MakerKit's public product, pricing and license information checked on July 21, 2026, with Achromatic's MCP availability updated on July 28. Products and prices change, so confirm the current details on the [MakerKit website](https://makerkit.dev/nextjs-saas-boilerplate) and [Achromatic pricing page](/pricing) before buying. At $180, Achromatic costs $169 less than MakerKit's standard Pro starting price and includes both current Prisma and Drizzle repositories. One license covers the developers in your licensed team or organization, unlimited End Products and lifetime updates. Choose MakerKit instead when TanStack Start, its monorepo, passkeys, internationalization or larger community will replace work your team would otherwise need to do. ## The verdict - **Achromatic is the better choice for most focused Next.js and PostgreSQL SaaS teams.** You receive both ORM implementations in one license, a simpler standalone architecture and the core B2B product systems for $180. - Choose **MakerKit** when you want a broader product ecosystem, a Turborepo architecture, more stack and framework choices or its established Discord community. - Paying more for additional scope is worthwhile only when your team will use it. A larger feature list is not automatically a better foundation if you plan to remove most of it. ## Achromatic vs MakerKit at a glance | Area | Achromatic | MakerKit | | ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | | Core architecture | One standalone Next.js application | Modular Turborepo monorepo | | Frameworks in the Prisma and Drizzle offer | Next.js | Next.js and TanStack Start | | Database choices | Separate Prisma and Drizzle repositories | Supabase, Prisma and Drizzle product options | | What one license includes | Both current Prisma and Drizzle repositories | The selected stack and license tier | | Authentication | Better Auth | Better Auth for Prisma and Drizzle, Supabase Auth for the Supabase stack | | Organizations | Organizations, invitations, roles and member management | Multi-tenant organizations, invitations and role-based access | | Billing | Stripe subscriptions, one-time payments, per-seat billing and prepaid credits | Stripe billing plus additional options that vary by stack | | Testing | Vitest and Playwright setup in both repositories | Playwright and documented testing workflows | | AI development | Agent instructions and a local read-only MCP server with repository, implementation, documentation and ORM context | Agent instructions and an MCP server | | Standard starting price checked July 2026 | $180 one-time | $349 one-time for a Pro Prisma or Drizzle license before temporary discounts | | Standard team price checked July 2026 | $180 one-time for the licensed team or organization | $649 one-time for a Teams license with up to five collaborators before discounts | | July 21 sale price | $180 | $279.20 Pro or $519.20 Teams with the advertised 20% code ending July 31 | | Client-project licensing | Unlimited client End Products under one organizational license; source stays with the licensed team | Team license required, and each client needs its own license for client work or a dedicated deployment | | Value for a focused Next.js product | Better: two ORM editions and the complete current foundation at the lower price | Better only when its broader ecosystem replaces work you would otherwise buy or build | | Best fit | Teams wanting a focused Next.js and PostgreSQL foundation | Teams wanting a larger ecosystem and modular monorepo | The table describes the public products, not every possible customization. Review each vendor's documentation for the exact implementation behind a checkbox. ### Sources for the comparison The MakerKit details above come from its own current product material: - The official [Prisma](https://makerkit.dev/prisma) and [Drizzle](https://makerkit.dev/drizzle) product pages list each Pro license at a standard $349 for one repository user and each Teams license at $649 for up to five collaborators. Both include unlimited projects and lifetime updates. - Those pages advertised a 20% `SUMMER2026` discount on July 21, reducing Pro to $279.20 and Teams to $519.20 through July 31. The standard prices remain the baseline because temporary offers expire. - MakerKit's [Prisma architecture guide](https://makerkit.dev/courses/nextjs-prisma/architecture-and-technologies) describes its Turborepo monorepo and the reasons for that structure. - Its [MCP server documentation](https://makerkit.dev/docs/nextjs-prisma/installation/mcp-server) documents the shipped server and its current tool coverage. - MakerKit's [product page](https://makerkit.dev/) lists its current authentication, billing, internationalization, framework and community features. - The [MakerKit license](https://makerkit.dev/license) requires a Team license for client work and a separate valid MakerKit license for each client receiving source code or a dedicated client deployment. - Its [agency program](https://makerkit.dev/agency-partnerships) describes the same one-license-per-client model and offers participating agencies client discounts and extra developer seats. Achromatic's side of the table is supported by the [pricing page](/pricing), [license](/license), [Prisma documentation](/docs/starter-kits/pro-nextjs-prisma) and [Drizzle documentation](/docs/starter-kits/pro-nextjs-drizzle). These direct sources make the comparison easier to recheck when either product changes. ## Decide by the product you are building | Your situation | Better fit | Reason | | ----------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------ | | One Next.js web application with a small engineering team | Achromatic | The standalone repository avoids package and workspace overhead | | You have not decided between Prisma and Drizzle | Achromatic | Both maintained editions are included in the same license | | You build several client products with different ORM preferences | Achromatic | One purchase gives you both current PostgreSQL implementations | | Your agency operates separate products for several clients | Achromatic | One organizational license permits unlimited client End Products when source access stays inside the licensed team | | You need TanStack Start or a modular monorepo | MakerKit | Its current Prisma and Drizzle offers include TanStack Start alongside Next.js | | Passkeys or built-in internationalization are launch requirements | MakerKit | MakerKit publicly includes those capabilities today | | A bundled repository MCP server is a purchase criterion | Both | Both now ship one; compare the exposed context, safety boundary and fit for your chosen stack | This is the central tradeoff. Achromatic wins on focus, repository simplicity and price. MakerKit wins on breadth. Choose based on the work your product actually requires, not the number of rows either vendor can place on a landing page. ## Why Achromatic is better for a focused Next.js SaaS ### Both ORM editions are included An Achromatic license includes the current [Prisma](/docs/starter-kits/pro-nextjs-prisma) and [Drizzle](/docs/starter-kits/pro-nextjs-drizzle) repositories. The application capabilities stay aligned while the persistence layer changes. That is objectively more flexible than paying for one selected ORM edition. It matters when you are still choosing an ORM, when different client projects use different database conventions or when you want to compare real implementations instead of adapting a small example. ### A deliberately simple repository shape Achromatic is not a monorepo by design. Each edition is a single Next.js application with its application code, database layer, tests and configuration together. For a product with one web application, this is the simpler architecture. There is less workspace tooling to learn, fewer package boundaries to cross and one deployment unit to operate. MakerKit's Turborepo architecture is a strength for teams that want modular packages or several applications. It is extra architecture if your product only needs one web application. ### Lower entry price without choosing an ORM up front Achromatic is currently $180 for both repositories. MakerKit publicly lists its Prisma and Drizzle Pro editions at $349 before temporary discounts. Achromatic therefore provides both ORM editions for little more than half MakerKit's standard single-edition starting price. Even during MakerKit's advertised July sale, Achromatic remains $99.20 below one discounted Pro edition. When the included architecture fits, Achromatic is plainly the better-value purchase. The price difference is not the whole argument. The Achromatic license also includes lifetime updates, unlimited projects under the license terms and repository access for developers working on the licensed team. Read the [license](/license) before buying so the access model is clear. ### One price for the licensed team Achromatic's $180 license covers every developer who belongs to the licensed team or organization. MakerKit's public pricing separates its one-user Pro tier from a Teams tier listed at $649 for up to five collaborators before temporary discounts. For a team that needs shared repository access, Achromatic is therefore $469 below MakerKit's standard Teams price while also including both ORM editions. This is a like-for-like access comparison, not a comparison between Achromatic team access and MakerKit's individual tier. ### One organizational license for client End Products Achromatic permits the licensed organization to build and operate unlimited client End Products. The client may use the delivered application, but it does not receive the Achromatic source, private repository access or a separate Achromatic license. MakerKit's current terms are different. Client work requires its Team license, and every client receiving source code or a dedicated client deployment must hold a separate valid MakerKit license. Its agency program is designed around that model and may provide partner discounts, but the per-client license requirement remains. For an agency that keeps source access within its own licensed delivery team and operates several separate client products, Achromatic is the more permissive and predictable license. If a client needs the underlying starter source or repository access, neither license lets an unrelated client inherit the agency's access automatically; review the exact transfer arrangement before delivery. ### A focused TypeScript and PostgreSQL stack Achromatic stays close to a familiar application stack: Next.js, React, TypeScript, Better Auth, tRPC, TanStack Query, PostgreSQL, Stripe and shadcn/ui. You choose Prisma or Drizzle without changing the surrounding product architecture. That narrower scope can make the codebase easier to evaluate. It also means Achromatic is not the right choice when you specifically want Supabase-native RLS or a framework other than Next.js. ## Where MakerKit is the stronger fit ### A broader product ecosystem MakerKit offers more product variants and publicly promotes Next.js, TanStack Start and React Router options across its stacks. Its ecosystem also includes a course, a Figma kit, plugins and a free Lite edition. Choose that breadth when those resources replace work your team would otherwise need to do. Do not pay for it only because the list is longer. ### A larger documentation and community surface MakerKit currently advertises an MCP server, agent instructions throughout its codebase and more than 400 documentation pages. That remains a meaningful advantage for teams that value a larger surrounding education and community surface. Achromatic now also ships a local, read-only MCP server in both ORM editions. Its 19 tools expose the current project architecture, searchable components, implementation files, documentation, ORM schema, migrations and database workflows without database, shell, network or source-write access. Read the [Prisma MCP guide](/docs/starter-kits/pro-nextjs-prisma/codebase/mcp-server) or [Drizzle MCP guide](/docs/starter-kits/pro-nextjs-drizzle/codebase/mcp-server) to compare the actual contract. ### A longer operating history and community MakerKit has been developed since 2022 and offers Discord support. Achromatic provides documentation and direct support, but it is the smaller product and community. The tradeoff is straightforward: Achromatic offers a focused codebase and lower price while MakerKit offers a broader ecosystem and longer public track record. ## Compare the code architecture, not only the landing pages Before choosing either kit, answer these questions: 1. Do you want one Next.js application or a modular monorepo? 2. Do you need both Prisma and Drizzle or only one selected stack? 3. Which organization and billing flows match your product without major removal work? 4. Will your team use the additional community, course or plugin resources? 5. Can you verify the migration, testing and deployment workflows in the documentation? ## Verify Achromatic before purchasing You do not need to accept the comparison table on trust: 1. Use the [live demo](https://demo.achromatic.dev) to inspect authentication, organizations, settings, billing surfaces, administration and the AI chat. 2. Read the [Prisma documentation](/docs/starter-kits/pro-nextjs-prisma) and [Drizzle documentation](/docs/starter-kits/pro-nextjs-drizzle) side by side. 3. Compare their database commands, migration workflow, environment variables, testing guidance and deployment checklist. 4. Review [pricing](/pricing), the [FAQ](/faq), [terms](/terms) and [license](/license) before checkout. That evidence is more useful than a generic feature checkbox because it shows how the product expects you to operate the code after purchase. ## Which should you choose? Choose Achromatic if you want a compact Next.js foundation, own your data in PostgreSQL, prefer a standalone repository and value receiving both ORM implementations. For that buyer, Achromatic offers the stronger price-to-capability ratio. Start with the [pricing page](/pricing), inspect the [live demo](https://demo.achromatic.dev) and read both documentation sets before purchasing. Choose MakerKit if its modular monorepo, wider framework selection or community support are important enough to justify the additional scope and price. Its public documentation is the best place to validate those details. The best starter kit is the one whose architecture removes work without forcing your team to undo its assumptions. ## Continue comparing - Read [Achromatic vs Supastarter](/blog/achromatic-vs-supastarter) if you are evaluating another broad multi-framework SaaS foundation. - Read [Achromatic vs TurboStarter](/blog/achromatic-vs-turbostarter) if a multi-platform TypeScript product is also on your shortlist. - Read [Achromatic vs ShipFast](/blog/achromatic-vs-shipfast) if you are also considering a smaller founder-focused launch kit. - Use the [Prisma vs Drizzle guide](/blog/prisma-vs-drizzle-orm) to choose an ORM after deciding that Achromatic fits your product. - Review [what the Achromatic license includes](/pricing) before checkout. --- ## Achromatic vs ShipFast: The Better Starter Kit for B2B SaaS **URL**: https://www.achromatic.dev/blog/achromatic-vs-shipfast **Description**: See why Achromatic is the stronger ShipFast alternative for B2B SaaS with organizations, roles, administration, testing and typed PostgreSQL access. **Published**: 2026-07-20 **Updated**: 2026-07-21 Achromatic and ShipFast both reduce the repetitive work required to launch a Next.js product. For a serious B2B SaaS, Achromatic is the stronger foundation. It includes the organization model, authorization structure, administration, testing and billing depth that a team product needs after the first landing page goes live. ShipFast is positioned around launching an online business quickly and provides several legacy and current code choices. Achromatic is a TypeScript-first SaaS foundation with structured organizations, administration, testing and two PostgreSQL ORM editions. This comparison uses ShipFast's public product, documentation and license information checked on July 21, 2026. Confirm current features and prices on the [ShipFast website](https://shipfa.st) and [Achromatic pricing page](/pricing) before buying. Achromatic costs $180, which is $19 below ShipFast's current promotional Starter price and $119 below its listed standard price. It includes both Prisma and Drizzle repositories, team-wide developer access, organizations, roles, administration, automated tests and deeper billing workflows. Choose ShipFast instead for a compact founder-led product when its launch community, MongoDB or Supabase variants and legacy Next.js choices matter more than built-in B2B structure. ## The verdict - **Achromatic is the better starter kit for B2B SaaS.** It includes organizations, roles, invitations, administration, automated tests and a typed PostgreSQL architecture instead of leaving those systems as post-launch integration work. - Choose **ShipFast** for a smaller founder-led product when community, marketing resources and the fastest path through a minimal launch stack matter more than deeper application structure. - ShipFast is optimized for getting a small online product to market. Achromatic is optimized for building a SaaS application that must support teams, permissions, billing changes and customer operations. ## Achromatic vs ShipFast at a glance | Area | Achromatic | ShipFast | | --------------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | Primary audience | Developers and teams building structured SaaS products | Indie makers launching online products quickly | | Current application stack | Next.js, TypeScript, Better Auth and PostgreSQL | Next.js with JavaScript or TypeScript and multiple router options | | Documented Node.js baseline | Node.js 22.21.1, pinned to the current kit | Node.js 18.17 or newer | | Database choices | Both Prisma and Drizzle repositories included | MongoDB or Supabase options advertised | | Authentication | Better Auth with account and security flows | Google OAuth and magic links advertised | | Team workflows | Organizations, invitations, roles and member management | Organization and role workflows are not listed on the public product page | | Billing | Stripe subscriptions, one-time payments, per-seat billing and prepaid credits | Stripe or Lemon Squeezy | | Administration | Users, organizations, subscriptions and credits | Not advertised as a core product area | | Testing | Vitest and Playwright setup | A test suite is not listed on the public product page | | Community | Direct product support | Large Discord community and public maker leaderboard | | Collaboration rights | Repository access for developers working on the licensed team | Separate personal and team licenses; confirm the applicable checkout tier | | Client-work terms | Unlimited client End Products with source retained by the licensed team | Client delivery and dedicated client deployments are not addressed publicly | | Public maintenance record | Dated changelog with shipped releases and current work | Product page states the kit was last updated five months ago | | Price checked July 2026 | $180 one-time | $199 promotional Starter price, normally listed at $299 | | B2B SaaS readiness | Better: team workflows, admin, deeper billing and tests are included | Better suited to a simpler single-user launch unless you add those systems | | Best fit | B2B SaaS and teams that want explicit application structure | Solo founders prioritizing a minimal launch path and community | “Not listed” does not prove that no private example exists. It means you should verify the capability before relying on it in a purchase decision. ### Sources for the comparison - ShipFast's [product and pricing page](https://shipfa.st/) lists its current prices, database and code variants, authentication, payments, community access and lifetime updates. - Its [public documentation](https://shipfa.st/docs) shows the App Router setup and documents authentication, payments, email, security and deployment workflows. - The same setup guide documents Node.js 18.17 or newer and NextAuth-style environment variables. The [official Node.js release table](https://nodejs.org/en/about/previous-releases) marks Node.js 18 as end-of-life and Node.js 22 as LTS as of July 21, 2026. - The [ShipFast license](https://shipfa.st/license), last updated August 21, 2023, distinguishes a personal license from a team license. The personal license permits unlimited projects for an individual, while sharing code with teammates requires the team license. It does not define client-project delivery or dedicated client deployments. - Achromatic's current scope and access model are documented on its [pricing page](/pricing), [license](/license), [Prisma documentation](/docs/starter-kits/pro-nextjs-prisma) and [Drizzle documentation](/docs/starter-kits/pro-nextjs-drizzle). If more than one developer will use the repository, confirm the exact license attached to the ShipFast checkout before purchasing. “Unlimited projects” and “unlimited teammates” are different permissions. Achromatic explicitly allows repository access for developers working on the same licensed team under its current license terms. ### Get client-work permission in writing Achromatic's current license explicitly permits unlimited personal, commercial and client End Products. A client may use the delivered application, but source access and the underlying Product remain inside the licensed team or organization. ShipFast's public license permits personal and commercial projects and team code sharing, but it does not say how agency client work, source-code delivery or separate client deployments are treated. That absence is not evidence that client work is prohibited. It means an agency should ask ShipFast for written confirmation before relying on one license across client engagements. Achromatic is the clearer choice when your operating model matches its stated boundary: one licensed delivery organization builds client applications without transferring starter source or repository access to unrelated clients. ### Compare visible maintenance, not only lifetime-update promises Both products advertise lifetime updates. Achromatic also publishes a [dated changelog](/changelog) covering shipped releases and work currently in progress. ShipFast's public FAQ stated on July 21, 2026 that its last update was five months earlier. That does not predict either product's future. It does give buyers a concrete maintenance trail to inspect. Review recent changes for dependency upgrades, security fixes and operational improvements, then decide whether the visible cadence matches the foundation your product needs. ### Start from a supported runtime baseline Achromatic's current setup and deployment guides pin Node.js 22.21.1, matching the shipped repositories. ShipFast's public setup guide specifies Node.js 18.17 or newer. Node.js 18 itself reached end-of-life in March 2025, although ShipFast's “or newer” wording allows buyers to run a supported version when the code is compatible. This is not a claim that ShipFast requires an unsupported runtime. It is a reason to verify the exact Node.js version, authentication variables and dependency compatibility immediately after cloning. Achromatic makes the supported baseline explicit across local setup, Docker and deployment guides. ## Decide by the product you are building | Your product | Better fit | Reason | | -------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------ | | B2B SaaS with workspaces, invitations and roles | Achromatic | Those workflows share one organization model across settings and billing | | Per-seat subscriptions or prepaid AI credits | Achromatic | Both billing models and their administration surfaces are included | | A TypeScript product that standardizes on PostgreSQL | Achromatic | Choose the included Prisma or Drizzle repository | | An agency retaining source access for multiple client products | Achromatic | Client End Products are expressly covered by its organizational license | | A small single-user tool or paid content product | ShipFast | Its narrower launch foundation may require less removal | | A founder who values a large maker community and leaderboard | ShipFast | Community and launch distribution are central parts of its public offer | | A project that must retain Pages Router, JavaScript or MongoDB | ShipFast | ShipFast publicly offers those variants while Achromatic deliberately does not | Achromatic is not better because every SaaS needs more features. It is better when the omitted work would otherwise be authorization, tenant ownership, billing state, tests or support tooling. Those are expensive systems to add after customers already depend on the product. ## Why Achromatic is the stronger product ### Multi-tenant product workflows are part of the foundation Achromatic includes organizations, membership, invitations and roles. The surrounding settings, billing and administration screens are designed around the same model. That is different from adding a `teamId` column after launch. A B2B SaaS needs a consistent answer to who owns a resource, who can invite a member, what happens after removal and how billing relates to an organization. If customers buy for a team, Achromatic starts materially further ahead. Those workflows are part of the foundation instead of an architecture migration waiting until after validation. ### PostgreSQL with a choice of Prisma or Drizzle Achromatic includes two aligned repositories. One uses Prisma and the other uses Drizzle. Both use PostgreSQL and keep the same product features. ShipFast publicly offers MongoDB and Supabase variants. Those are reasonable choices, but they solve a different preference. Achromatic is the clearer fit when your team already wants PostgreSQL with a conventional ORM workflow. ### Testing and operations are documented product areas Both Achromatic editions include Vitest and Playwright configuration, environment validation, migration commands and deployment guidance. The documentation covers production checks rather than stopping at the first successful local run. This does not make a customized product automatically safe. It gives your team a defined place to extend tests as the product changes. ### Deeper billing and administration Achromatic includes subscriptions, one-time payments, per-seat billing, prepaid credits, customer billing management and related webhook handling. It also includes administration for users, organizations, subscriptions and credits. That makes Achromatic the more complete choice for products with support and account-management needs. A simple one-price product may not need it. ## What the Achromatic license replaces For a team-oriented product, the $180 license provides both current ORM repositories plus the surrounding implementation for: - Account security, email verification, password reset and two-factor authentication - Organizations, membership, invitations and roles - Stripe subscriptions, one-time purchases, per-seat billing and prepaid credits - User, organization, subscription and credit administration - Transactional email, image storage, structured logging and Sentry integration - Vitest unit tests, Playwright browser tests and production deployment guidance These are foundations, not a finished customer-specific product. You still own your domain model, workflows, authorization rules, copy, design decisions and operational configuration. ## Where ShipFast is the stronger fit ### A larger indie-maker community ShipFast advertises thousands of customers, a Discord community and a revenue leaderboard. That community and the accompanying launch motivation are part of the product's appeal. Achromatic focuses more narrowly on code, documentation and direct support. It does not offer an equivalent maker network. ### More legacy code choices ShipFast advertises JavaScript and TypeScript plus App Router and Pages Router options. Achromatic deliberately supports a narrower current stack: TypeScript and the Next.js App Router. Choose ShipFast if maintaining an older router or using JavaScript is a real requirement. Choose Achromatic if you want fewer variants and current conventions. ### A minimal path for a small product ShipFast's public offer centers on login, payments, email, a blog, SEO and reusable marketing components. That can be enough for a single-user tool or a compact paid product. Achromatic carries more B2B application structure. If you would delete organizations, admin features, role checks and advanced billing, the smaller foundation may be more efficient. ## Compare what you will keep Use a short implementation inventory before buying: | Requirement | Needed at launch? | Achromatic fit | ShipFast fit | Custom work remaining | | ------------------------------------ | ----------------- | -------------- | ------------ | --------------------- | | Team organizations and invitations | | | | | | Role-based authorization | | | | | | Subscription or one-time billing | | | | | | Per-seat billing or prepaid credits | | | | | | Admin support workflows | | | | | | MongoDB, Supabase, Prisma or Drizzle | | | | | | Automated unit and browser tests | | | | | | Community and launch support | | | | | Fill this out based on the current documentation, not a comparison article alone. ## Verify Achromatic before purchasing 1. Open the [live demo](https://demo.achromatic.dev) and walk through the organization, settings, billing and administration surfaces. 2. Choose the [Prisma documentation](/docs/starter-kits/pro-nextjs-prisma) or [Drizzle documentation](/docs/starter-kits/pro-nextjs-drizzle) and inspect setup, migrations, tests and deployment. 3. Confirm the shipped behavior against your launch requirements rather than assuming a named feature covers your use case. 4. Review [pricing](/pricing), the [FAQ](/faq), [terms](/terms) and [license](/license) before checkout. ## Which should you choose? Choose Achromatic when the product needs organizations, roles, typed PostgreSQL access, richer billing and administration. It is less expensive than ShipFast's regular Starter price while including a substantially deeper B2B application foundation. The $180 license includes both the [Prisma](/docs/starter-kits/pro-nextjs-prisma) and [Drizzle](/docs/starter-kits/pro-nextjs-drizzle) editions. Choose ShipFast when you are a solo founder building a simpler product and its community, marketing material or range of older code variants is more valuable than a deeper B2B foundation. The useful question is not which landing page has more checkmarks. It is which codebase leaves less high-risk work between your current product and a reliable launch. ## Continue comparing - Read [Achromatic vs Supastarter](/blog/achromatic-vs-supastarter) if you are also evaluating a broader multi-framework SaaS foundation. - Read [Achromatic vs TurboStarter](/blog/achromatic-vs-turbostarter) if you are evaluating a multi-platform TypeScript starter kit. - Read [Achromatic vs MakerKit](/blog/achromatic-vs-makerkit) if you are also evaluating a broader B2B starter-kit ecosystem. - Use the [Prisma vs Drizzle guide](/blog/prisma-vs-drizzle-orm) to choose between the two repositories included with Achromatic. - Review [what the Achromatic license includes](/pricing) before checkout. --- ## SaaS Boilerplate vs Building From Scratch: The Real 2026 Cost **URL**: https://www.achromatic.dev/blog/saas-boilerplate-vs-building-from-scratch **Description**: Compare a SaaS boilerplate, a custom build and building from scratch using engineering scope, opportunity cost and the work that remains after launch. **Published**: 2026-07-19 Choosing between a SaaS boilerplate and building from scratch is not a question of whether your team is capable. It is a decision about where engineering time creates an advantage. Authentication, billing webhooks, organization membership and transactional email are important. They are rarely the reason a customer buys your product. Your workflow, domain knowledge and distribution are more likely to matter. The real 2026 cost therefore has three parts: 1. The cash you spend on code or services 2. The engineering time required before and after launch 3. The opportunity cost of delaying customer feedback ## The three realistic paths Most teams are not choosing between a finished boilerplate and an empty text file. They are choosing among three paths. | Path | You receive | You still own | Main risk | | ------------------------------------- | --------------------------------------------- | ----------------------------------------------------------- | -------------------------------------------- | | Starter kit | Working SaaS foundation and full source | Product workflow, branding, deployment and validation | The kit may not match your architecture | | Custom implementation on a foundation | Foundation plus agreed product implementation | Product decisions, acceptance and continued operation | Scope can grow beyond the initial engagement | | Build from scratch | Complete architectural freedom | Every integration, edge case, test and operational decision | Foundation work delays differentiated value | There is no universally correct path. The cheapest license can be expensive if you spend weeks removing its assumptions. A custom build can be efficient when it converts a clear specification into a launchable product. Building from scratch can be correct when the infrastructure itself is the product. ## What “from scratch” really includes Starting with Next.js does not mean starting with nothing. You will use open-source packages and hosted services. The work is integrating those pieces into one reliable product. A typical B2B SaaS foundation may need: - email and password authentication - social sign-in and account linking - verification, recovery and session management - organizations, invitations and roles - Stripe Checkout, subscriptions and one-time payments - webhook verification, retries and idempotency - customer billing management and entitlements - transactional email templates and delivery - account, organization and security settings - an administrator view for customer support - environment validation, deployment and monitoring - tests for the failure paths between these systems Each individual feature is understandable. The cost appears at the boundaries. What happens when a webhook arrives twice? Can a removed organization member still access cached data? Does a changed subscription update entitlements before the customer returns to the dashboard? Can support diagnose the account without editing the database manually? ## Calculate engineering cost without invented timelines Marketing comparisons often assign a universal number of weeks to authentication or billing. That is not credible without knowing the team and requirements. Use a requirements-based estimate instead: ```text engineering cost = estimated hours × loaded hourly cost launch cost = engineering cost + tools and infrastructure + review and rework allowance + opportunity cost of delayed validation ``` “Loaded hourly cost” is not only salary. For an employee it can include employment costs, equipment and management. For a founder it is the value of the best alternative use of that time. For an agency it is the rate charged for the work. Build a worksheet before choosing: | Workstream | Required for launch? | Build hours | Starter adaptation hours | Owner | | ------------------------- | -------------------- | ------------: | -----------------------: | ------------ | | Authentication | Yes | Your estimate | Your estimate | Backend | | Organizations and access | Maybe | Your estimate | Your estimate | Backend | | Billing and webhooks | Yes | Your estimate | Your estimate | Backend | | Product data model | Yes | Your estimate | Your estimate | Product team | | Core customer workflow | Yes | Your estimate | Your estimate | Product team | | Admin and support tooling | Maybe | Your estimate | Your estimate | Full stack | | Deployment and monitoring | Yes | Your estimate | Your estimate | Platform | | Documentation and handoff | Yes | Your estimate | Your estimate | Team | Estimate both columns honestly. A starter kit does not reduce the product data model or core workflow to zero. It should reduce work only where it already contains a suitable implementation. ## What Achromatic actually includes Achromatic provides two standalone Next.js 16 repositories with the same SaaS capabilities. One uses Prisma and one uses Drizzle. The current foundation includes: - Better Auth with email and password, Google sign-in and two-factor authentication - organizations, invitations, roles and member management - Stripe subscriptions, one-time payments, per-seat billing and prepaid credits - billing webhooks, paywalls and customer self-service - tRPC with React Query for typed application data - transactional email templates - account and organization settings - administration for users, organizations, subscriptions and credit balances - reusable dashboard and marketing components - testing, documentation and deployment guidance The license provides full source for both current repositories, lifetime updates, unlimited projects under the license terms and access for developers working on the licensed team. Review the [Prisma documentation](/docs/starter-kits/pro-nextjs-prisma), [Drizzle documentation](/docs/starter-kits/pro-nextjs-drizzle), [pricing](/pricing) and [license](/license) before treating any feature as a saved workstream. ## What a starter kit does not buy A boilerplate is a foundation, not a completed company. You still need to define and implement: - the customer problem and product workflow - the domain-specific database model - your onboarding and activation path - brand, copy and interaction design - product-specific integrations - analytics events and success metrics - privacy, compliance and operational policies - deployment configuration for your accounts - sales, distribution and customer support The right expectation is not “launch without development.” It is “stop implementing generic infrastructure before beginning product development.” ## Compare the cash paths ### Buy the source and build the product This path has the smallest initial cash purchase. It works best when you or your team can read the code, configure external services and build the unique workflow. The economic question is simple: ```text break-even hours = license price ÷ loaded hourly cost ``` If the useful code saves more time than the break-even hours plus the time required to learn it, buying is economically rational. Do not count features you plan to delete. ### Hire Achromatic to build on the foundation The [custom SaaS development service](/custom-saas-development) starts at **$8,000 USD**. A starting engagement can include product discovery, technical planning, a Prisma or Drizzle foundation, interface customization, authentication, organizations, billing configuration, one defined product workflow, deployment and source-code handoff. The exact scope and timeline are confirmed before work begins. Complex AI systems, native mobile applications, marketplaces, legacy migrations, custom design systems and compliance-heavy products require separate scoping. This path makes sense when the requirements are clear but the buyer does not want to staff the implementation. It does not make sense when product discovery is still so open-ended that no bounded workflow can be defined. ### Build the foundation internally Internal development avoids a license and gives the team full architectural authorship. Its cost is the time spent specifying, integrating, testing and maintaining the foundation. This is often the right decision when: - authentication, billing or infrastructure is the product - a regulated environment requires controls the kit does not implement - an existing platform already owns these capabilities - the team has mandatory architecture or provider standards - learning the implementation is the purpose of the project ## Include risk in the estimate An hourly comparison misses the cost of failures. Add explicit allowance for high-risk boundaries: ### Authentication Account linking, session invalidation, password recovery and authorization failures affect user access. A working sign-in screen is not a complete authentication implementation. ### Billing Payment state changes asynchronously. Test duplicated events, delayed events, failed renewals, canceled subscriptions and access restoration. The checkout page is the easy part. ### Multi-tenancy Every organization-owned query needs an authorization boundary. Test role changes, removed members, invitation expiry and switching between organizations. ### Operations Someone must investigate failed email, stale billing state and customer access. Admin tools and readable logs reduce support cost after launch. ## A practical decision test Choose a starter kit when most of these statements are true: - the current stack matches your team's preferences - the included features are required by your launch scope - your product advantage sits above the foundation - you can evaluate and maintain full-source TypeScript code - reaching customer feedback sooner is valuable Choose custom development when: - you have a bounded product workflow and budget - you want source ownership without staffing the first implementation - the existing foundation is a strong fit - you can make product decisions and review delivered work Build from scratch when: - the kit's assumptions create more removal work than saved work - the infrastructure is strategically differentiating - mandatory constraints conflict with the available architecture - time-to-market is less important than internal standardization or learning ## The honest conclusion A SaaS boilerplate is worth buying only for code you would otherwise need to implement. Custom development is worth buying only when the scope is clear enough to price and accept. Building from scratch is worth the time only when the control or knowledge gained matters to the product. Write down the required workstreams, estimate both implementation paths and include the cost of delayed feedback. The best decision is the one that directs the most time toward a customer problem rather than the one with the smallest visible invoice. --- ## Vercel Acquires Better Auth: What Next.js SaaS Teams Need to Know **URL**: https://www.achromatic.dev/blog/vercel-acquires-better-auth-nextjs-saas **Description**: Vercel acquired Better Auth on July 7, 2026. Learn what stays open, why agent identity matters and what Next.js SaaS teams should do now. **Published**: 2026-07-14 On July 7, 2026, [Vercel announced its acquisition of Better Auth](https://vercel.com/blog/vercel-acquires-better-auth). Founder Bereket Engida and the core team are joining Vercel while continuing to develop the open-source authentication framework. The announcement is especially relevant to Next.js SaaS teams. Better Auth has become a popular TypeScript option for sessions, social login, two-factor authentication and organizations. It is also the authentication foundation used by the current Achromatic Prisma and Drizzle starter kits. The short version is simple: - Existing Better Auth applications do not need to migrate because of the acquisition. - Better Auth remains free, MIT licensed and framework agnostic. - The project keeps its name, open contribution model and broad framework support. - Vercel and the Better Auth team plan to invest more deeply in identity for AI agents. - Dependency upgrades and security work should still follow release notes and advisories, not company news. Here is what the announcement means in practice. ## What Vercel acquired Vercel acquired the company behind Better Auth, not a closed hosted identity service. At the time of the announcement, Vercel reported that the library had more than 4.7 million weekly npm downloads and over 850 contributors. Both companies emphasized continuity. According to the [Better Auth announcement](https://better-auth.com/blog/better-auth-joins-vercel), the team is joining Vercel to accelerate its work on open-source authentication and secure agent workflows. Vercel says the library will remain: - Free and open source under the MIT license - Portable across frameworks and hosting providers - Led by the existing team - Open to community contributions That matters because portability is one of Better Auth's main advantages. Your application can keep its database, session model and integration code in your own infrastructure. The acquisition announcement does not require you to move hosting providers or replace your current authentication flow. ## Why agent identity is central to the deal Traditional authentication answers questions about people: - Who is this user? - Is their session valid? - Which organization do they belong to? - What can they access? AI agents add another layer. An agent may act for a user across GitHub, Slack, Linear or another service. Giving every agent the same long-lived token as the application creates too much authority and makes targeted revocation difficult. Vercel describes the goal as giving each agent its own identity with scoped and revocable access. The company is connecting this work to products such as [Vercel Connect](https://vercel.com/blog/introducing-vercel-connect), which exchanges application identity for short-lived provider credentials at runtime. This is roadmap direction, not an automatic feature added to existing Better Auth applications. SaaS teams should still design explicit boundaries around every tool an agent can call, every organization it can access and every action it can perform. ## What Better Auth already supports in Next.js Better Auth already fits the Next.js App Router without requiring the acquisition. The official [Next.js integration guide](https://better-auth.com/docs/integrations/next) covers: - Mounting the Better Auth handler in an App Router route - Creating a React client for browser-side authentication - Reading sessions in React Server Components and Server Actions - Setting cookies from Server Actions with the Next.js cookies plugin - Using `proxy.ts` with Next.js 16 One distinction in that guide is easy to miss: checking for a session cookie in `proxy.ts` can be useful for an optimistic redirect, but it is not a secure authorization check. A user can present a cookie that exists without proving that the session is valid or that the user may access a specific tenant. The [Next.js authentication guide](https://nextjs.org/docs/app/guides/authentication) recommends separating three concerns: 1. Authentication verifies the user's identity. 2. Session management tracks their authenticated state. 3. Authorization decides which data and actions they may access. For sensitive operations, validate the session and permissions again at the server-side data boundary. A proxy redirect can improve navigation, but the page, route handler or data access function must still enforce access. ## Organizations remain the SaaS security boundary For a multi-tenant SaaS, a valid user session is only the first check. Every request also needs an organization boundary. Better Auth's [organization plugin](https://better-auth.com/docs/plugins/organization) provides organizations, members, teams, roles and invitations. Those primitives are useful, but the application still decides how organization ownership maps to product data. A reliable request path looks like this: 1. Validate the session on the server. 2. Read the organization identifier from trusted application context. 3. Confirm that the user is an active member of that organization. 4. Check the required role or permission. 5. Scope every database query to the same organization identifier. 6. Return only the fields needed by the current screen or action. An active organization selected in the interface is context, not proof of access. Membership changes, invitation revocation and role updates must take effect at the server boundary. ## What Next.js SaaS teams should do now ### 1. Do not migrate because of the acquisition There is no acquisition-specific migration. Keep your current architecture unless a product requirement, release note or security advisory gives you a reason to change it. ### 2. Treat Better Auth 1.7 as a separate upgrade decision Better Auth 1.7 is currently a release candidate. Its [upgrade guide](https://better-auth.com/docs/guides/1-7-upgrade-guide) covers changes across OAuth, OpenID Connect, SAML, SCIM, two-factor authentication, Stripe and custom adapters. Do not combine a corporate announcement with a production dependency upgrade. Review the guide, generate schema changes and test the complete authentication flow in a staging environment before adopting a release candidate. ### 3. Audit every Better Auth package you install The [June 2026 security update](https://better-auth.com/blog/security-update-june-2026) includes advisories for Better Auth core and scoped packages such as SSO, SCIM and the OAuth provider. Updating only the top-level package may not address a plugin-specific advisory. Check your lockfile, direct dependencies and enabled plugins against the upstream advisory. Apply the fixed version named for each affected package. ### 4. Test behavior, not only types Authentication regressions often appear in state transitions rather than TypeScript errors. Test at least: - Sign-up, sign-in and sign-out - Email verification and password reset - OAuth callbacks and account linking - Session expiration and revocation - Two-factor authentication and recovery codes - Invitation creation, acceptance, expiration and cancellation - Organization switching and membership removal - Role changes and cross-tenant access attempts - Admin impersonation start and stop - Direct access to protected pages and API routes ### 5. Inventory credentials used by AI features If your product has agents that call external services, document which identity each agent uses. Prefer narrowly scoped, short-lived and revocable credentials over a shared token stored indefinitely in an environment variable. The acquisition makes agent identity more visible, but the architectural work remains yours: define the actor, tenant, scope, lifetime and audit trail for each delegated action. ## What this means for Achromatic users The current Achromatic starter kits use Better Auth for email and password authentication, Google OAuth, TOTP two-factor authentication, sessions and organizations. Both Prisma and Drizzle versions keep authorization close to server-side application code and tenant-scoped data access. The Vercel acquisition does not change that setup. Evaluate Better Auth releases on their technical contents, apply security guidance deliberately and test migrations before deploying them. If you are starting a new product, explore the [Achromatic starter kit documentation](/docs/starter-kits), review the [live demo](https://demo.achromatic.dev) or see the [one-time license](/pricing). ## The takeaway Vercel's acquisition gives Better Auth more resources while preserving the open-source and portable model that made it useful to Next.js teams. The most interesting long-term direction is agent identity, where applications need authority that is scoped to a task and revocable without disabling the user. For teams shipping today, the priorities are more familiar: keep dependencies current, enforce authorization at the data boundary, isolate organization data and test every authentication transition that can grant or remove access. --- Sources: - [Vercel acquires Better Auth](https://vercel.com/blog/vercel-acquires-better-auth) - [Better Auth is joining Vercel](https://better-auth.com/blog/better-auth-joins-vercel) - [Better Auth Next.js integration](https://better-auth.com/docs/integrations/next) - [Better Auth organization plugin](https://better-auth.com/docs/plugins/organization) - [Upgrading to Better Auth 1.7](https://better-auth.com/docs/guides/1-7-upgrade-guide) - [Better Auth security update: June 2026](https://better-auth.com/blog/security-update-june-2026) - [Next.js authentication guide](https://nextjs.org/docs/app/guides/authentication) - [Introducing Vercel Connect](https://vercel.com/blog/introducing-vercel-connect) --- ## Self-Host a Next.js SaaS With Docker **URL**: https://www.achromatic.dev/blog/self-host-nextjs-saas-with-docker **Description**: Build a production Docker image for a standalone Next.js SaaS, provide runtime secrets safely and run Prisma or Drizzle migrations without coupling them to the image build. **Published**: 2026-05-20 Docker gives a Next.js SaaS a repeatable runtime that can run on a virtual private server, a container platform or an orchestrator. It is useful when you want control over the server environment, predictable long-running processes or infrastructure that is not tied to one Next.js hosting provider. This guide uses a standalone Next.js repository rather than a monorepo. The examples match the structure used by Achromatic's current Prisma and Drizzle starter kits. ## What the container should do A production application image should: - install dependencies from the lockfile - build the application in a separate stage - contain only the standalone server and required static files at runtime - run as a non-root user - receive secrets at runtime rather than storing them in the image - expose a health-checkable HTTP port Database migrations are a deployment concern. Do not connect to the production database or mutate its schema while building the image. ## Prerequisites You need: - Docker with BuildKit support - a Next.js application that builds successfully - Node.js `22.21.1` for parity with the current Achromatic kits - access to a PostgreSQL database - the environment variables required by your selected integrations Run the normal checks before containerizing the application: ```bash filename="Terminal" lineNumbers npm ci npm run typecheck npm run lint npm run test npm run build ``` Fix an ordinary production build before debugging a Docker build. ## Enable standalone output Next.js can trace the files needed by the production server and place them in `.next/standalone`. Add `output: 'standalone'` to the existing `next.config.ts` object: ```typescript filename="next.config.ts" lineNumbers import type { NextConfig } from 'next'; const nextConfig: NextConfig = { output: 'standalone' // Keep the rest of your existing configuration. }; export default nextConfig; ``` Do not replace the rest of the kit's configuration with this shortened example. Add the property to the existing object so MDX, Content Collections, Sentry and other wrappers remain intact. Next.js documents standalone output as a minimal server deployment option. The generated `server.js` does not copy `public` or `.next/static` automatically, so the Dockerfile copies those directories explicitly. ## Create `.dockerignore` Keep local dependencies, build output, source control metadata and secrets out of the build context: ```text filename=".dockerignore" lineNumbers .git .github .next node_modules coverage playwright-report test-results npm-debug.log* .DS_Store .env .env.* !.env.example ``` The exception keeps the non-secret variable template available if the build process needs its shape. Verify that `.env.example` contains placeholders only. ## Create the multi-stage Dockerfile Add this `Dockerfile` at the repository root: ```dockerfile filename="Dockerfile" lineNumbers FROM node:22.21.1-alpine AS base WORKDIR /app ENV NEXT_TELEMETRY_DISABLED=1 FROM base AS dependencies RUN apk add --no-cache libc6-compat COPY package.json package-lock.json ./ RUN npm ci FROM base AS builder COPY --from=dependencies /app/node_modules ./node_modules COPY . . # Prisma only: generate the client before building. # Remove this line from the Drizzle edition. RUN npm run db:generate RUN npm run build FROM node:22.21.1-alpine AS runner WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 ENV PORT=3000 ENV HOSTNAME=0.0.0.0 RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 nextjs COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static USER nextjs EXPOSE 3000 CMD ["node", "server.js"] ``` The comments mark the only ORM-specific build step. Prisma generates a client from `prisma/schema.prisma`. Drizzle does not require migration generation to build the application, so remove that `RUN npm run db:generate` line from the Drizzle image. Do not use `npm run db:generate || true`. Ignoring a failed generation step can produce an image that builds incompletely and fails later. ## Build the image Build from the repository root: ```bash filename="Terminal" lineNumbers docker build --tag my-saas:local . ``` If application environment validation runs during `npm run build`, provide only the build-time values the application explicitly requires. Do not bake live secrets into the image with `ARG` or `ENV`. Build arguments and image layers are not an appropriate secret store. For a platform that supports BuildKit secret mounts, use those for a value that is genuinely required during compilation. Prefer changing the application so server-only credentials are needed only at runtime. ## Run locally Create a local runtime environment file that is excluded from Git, then run: ```bash filename="Terminal" lineNumbers docker run --rm \ --name my-saas \ --env-file .env.production.local \ --publish 3000:3000 \ my-saas:local ``` Open [http://localhost:3000](http://localhost:3000) and test sign-in, authenticated navigation and any server route that reaches the database. When the database runs on the host machine, `localhost` inside the container refers to the container itself. On Docker Desktop, use `host.docker.internal` in the development connection URL. In production, use the private hostname supplied by the database or container network. ## Run migrations as a release step The application image and database schema must move together, but they should not change in the same build operation. For the Prisma kit, apply committed migrations with: ```bash filename="Terminal" lineNumbers npm run db:migrate ``` This maps to `prisma migrate deploy`. It applies pending migrations and does not create new ones. For the Drizzle kit, generate and review migrations during development: ```bash filename="Terminal" lineNumbers npm run db:generate ``` Commit the generated files in `lib/db/migrations/`, then apply them in the release environment: ```bash filename="Terminal" lineNumbers npm run db:migrate ``` Use one of these production patterns: 1. A CI release job runs migrations once before replacing application containers. 2. An orchestrator runs a one-off migration task using the same release revision. 3. A single-server deployment runs migrations explicitly before restarting the service. Avoid running migrations independently in every horizontally scaled application container. Multiple replicas can start simultaneously and make deployment behavior harder to reason about. ## Provide runtime configuration safely The current kits validate environment variables. Use `.env.example` as the inventory, then store real values in the platform's secret manager. Typical server-only values include: - `DATABASE_URL` - `BETTER_AUTH_SECRET` - Stripe secret and webhook credentials - Resend credentials - monitoring credentials Variables prefixed with `NEXT_PUBLIC_` are included in browser-facing bundles when referenced by client code. Never put a database password, Stripe secret key or authentication secret behind that prefix. Different platforms inject secrets differently. Docker Compose can use an uncommitted env file for a private server. Cloud Run, ECS and managed container platforms provide dedicated secret integrations. Kubernetes commonly uses Secrets mounted as environment variables or files. ## Add a health check A container platform needs a route that confirms the HTTP process is ready. If the application already has a status endpoint, configure the platform to request it. Otherwise, add a minimal route that does not disclose configuration: ```typescript filename="app/api/health/route.ts" lineNumbers import { NextResponse } from 'next/server'; export function GET(): NextResponse { return NextResponse.json({ status: 'ok' }); } ``` An HTTP-only check confirms the application process is serving requests. A deeper readiness check may verify required dependencies, but it should use a short timeout and avoid causing meaningful database load. ## Put a proxy in front of the container On a virtual private server, terminate TLS with a reverse proxy such as Caddy, Traefik or Nginx. The proxy should: - obtain and renew the HTTPS certificate - forward the original host and protocol headers - redirect HTTP to HTTPS - impose sensible request-size and timeout limits - send traffic only to healthy application instances Keep port `3000` private. Expose only the proxy's ports `80` and `443` to the internet. ## Production checklist Before sending customer traffic to the container: - build and scan the final image in CI - pin the Node image to the version used by the repository - run as a non-root user - inject secrets at runtime - apply committed database migrations once - configure HTTPS and the public application URL - update OAuth callback URLs and Stripe webhook destinations - verify email delivery from the production domain - configure logs, error monitoring and backups - test shutdown and rollback behavior ## Common failures ### `server.js` is missing Confirm `output: 'standalone'` is present in the existing Next.js configuration and that the builder completed `npm run build`. ### Static assets return 404 Confirm both `public` and `.next/static` are copied into the runner stage. The standalone server does not copy them for you. ### The database is unreachable Check the hostname from inside the container, the database firewall and TLS requirements. Do not use `localhost` unless PostgreSQL runs in the same container, which is not recommended for production. ### The build asks for secrets Review the environment validation and any build-time data fetching. Keep server credentials at runtime where possible. Never solve the problem by committing an environment file or placing a live secret in the Dockerfile. ### A deployment starts before migrations finish Make the release pipeline wait for the one-off migration command to succeed before directing traffic to the new application revision. ## Where to deploy The same image can run on a VPS, Fly.io, Railway, Render, AWS ECS, Google Cloud Run, Azure Container Apps or a Kubernetes cluster. The image is portable, but the operational responsibilities are not. Compare managed TLS, health checks, secret storage, rollback support, persistent logs and database networking before choosing the cheapest compute price. For kit-specific details, continue with the [Prisma Docker documentation](/docs/starter-kits/pro-nextjs-prisma/deployment/docker) or [Drizzle Docker documentation](/docs/starter-kits/pro-nextjs-drizzle/deployment/docker). For a wider deployment decision, read the [production deployment guide](/blog/deploy-nextjs-saas-production). --- ## CVE-2026-23864 - React Server Components DoS Vulnerabilities **URL**: https://www.achromatic.dev/blog/cve-2026-23864-react-server-components **Description**: Multiple denial of service vulnerabilities discovered in React Server Components. All Achromatic starter kits updated to patched versions. **Published**: 2026-01-28 A new high-severity vulnerability has been disclosed affecting React Server Components. **CVE-2026-23864** addresses multiple denial of service attack vectors that can crash servers, cause out-of-memory exceptions, or trigger excessive CPU usage. We've updated **all Achromatic starter kits** to the latest patched versions. ## Vulnerability overview CVE-2026-23864 covers multiple denial of service vulnerabilities triggered by specially crafted HTTP requests to Server Function endpoints. Depending on the affected code path and application configuration, attacks could lead to: - Server crashes - Out-of-memory exceptions - Excessive CPU usage **CVSS Score: 7.5** (High Severity) These vulnerabilities **do not allow Remote Code Execution**. However, denial of service attacks can still cause significant downtime and impact your users. ## Affected versions The vulnerabilities impact these React packages across versions 19.0.x, 19.1.x, and 19.2.x: - `react-server-dom-parcel` - `react-server-dom-webpack` - `react-server-dom-turbopack` **Next.js versions affected:** 13.x, 14.x, 15.x, and 16.x Other frameworks using React Server Components are also affected, including Vite, Parcel, React Router, RedwoodSDK, and Waku. ## Fixed versions Update to one of these patched versions: **React:** - 19.0.4 - 19.1.5 - 19.2.4 **Next.js:** - 15.0.8, 15.1.12, 15.2.9, 15.3.9, 15.4.11, 15.5.10 - 15.6.0-canary.61 - 16.0.11, 16.1.5 - 16.2.0-canary.9 ## What we've done All [Achromatic starter kits](/docs/starter-kits) have been updated to the latest patched versions. ## What you should do ### New projects Clone any of our starter kits. They're already running the patched versions. ### Existing projects Update your dependencies immediately: ```bash filename="Terminal" lineNumbers pnpm install next@latest react@latest react-dom@latest ``` Or use the official codemod: ```bash filename="Terminal" lineNumbers npx @next/codemod@canary upgrade latest ``` Verify your React packages are at version **19.0.4**, **19.1.5**, or **19.2.4** or higher. ### Vercel-hosted projects Vercel has deployed Web Application Firewall rules to automatically protect hosted projects. However, you should still upgrade to patched versions as soon as possible. ## Credits The vulnerability was responsibly disclosed by researchers from: - Winfunc Research - GMO Flatt Security - Tencent Security YUNDING LAB ## Resources - [Vercel Changelog: Summary of CVE-2026-23864](https://vercel.com/changelog/summary-of-cve-2026-23864) ## Related - [React DoS & Source Code Exposure](/blog/react-dos-source-code-exposure) - Previous React Server Components vulnerabilities - [React2Shell Security Patch - Next.js 16.0.7](/blog/nextjs-16-react2shell) - The original critical RCE vulnerability --- **Looking for a secure foundation for your SaaS?** Our [starter kits](/pricing) are always kept up-to-date with the latest security patches. Stay secure! --- ## Introducing shadcn-modal-manager **URL**: https://www.achromatic.dev/blog/shadcn-modal-manager **Description**: We open sourced shadcn-modal-manager - a lightweight, type-safe modal manager for shadcn/ui built with pure React and zero dependencies. **Published**: 2026-01-23 We're excited to open source **shadcn-modal-manager** - a simple yet robust modal manager for shadcn/ui. ## The problem Managing modals in React typically involves a lot of boilerplate: ```tsx filename="typical-modal-pattern.tsx" lineNumbers const [isOpen, setIsOpen] = useState(false); const [modalData, setModalData] = useState(null); // Scattered across your component {/* Modal content using modalData */} ``` This pattern becomes unwieldy when you have multiple modals, need to open modals from deeply nested components, or want to trigger modals from outside React (like event handlers or utility functions). ## The solution shadcn-modal-manager provides a context-driven system that lets you manage modals globally through hooks: - Open any modal from anywhere in your app - Pass typed data to modals - No prop drilling or state lifting - Works with shadcn's Dialog and Drawer components ## Features - **Lightweight** - Built with pure React, no external dependencies - **Type-safe** - Fully typed with TypeScript - **shadcn/ui compatible** - Works seamlessly with Dialog and Drawer components - **Global state** - Manage multiple modals through centralized context - **React 16+** - Supports React 16 and later versions ## Installation ```bash filename="Terminal" lineNumbers npm install shadcn-modal-manager ``` ## Why we built this While building our [starter kits](/docs/starter-kits), we found ourselves repeatedly implementing modal management patterns. Confirmation dialogs, edit forms, detail views - they all needed a clean way to be triggered from various places in the app. Rather than copy-pasting the same context setup across projects, we extracted it into a reusable package. ## Open source shadcn-modal-manager is MIT licensed and available on GitHub: - [GitHub Repository](https://github.com/achromaticlabs/shadcn-modal-manager) - [npm Package](https://www.npmjs.com/package/shadcn-modal-manager) Contributions are welcome! --- **Building a SaaS with shadcn/ui?** Check out our [starter kits](/pricing) - they come with modal management patterns built in. --- ## Agent Skills Support: npm for AI Coding Agents **URL**: https://www.achromatic.dev/blog/agent-skills **Description**: Achromatic now supports Vercel Agent Skills, bringing 10+ years of React and Next.js optimization patterns to AI coding assistants like Claude Code, Cursor, and Codex. **Published**: 2026-01-21 We've added support for **Vercel Agent Skills** in Achromatic Pro. This means AI coding assistants like Claude Code, Cursor, and Codex now have access to 10+ years of React and Next.js optimization knowledge when working with your codebase. ## What Are Agent Skills? [Agent Skills](https://github.com/vercel-labs/agent-skills) is a new open-source project from Vercel Labs that works like npm, but for AI coding agents. Instead of installing packages for your app, you install skills for your AI assistant. Skills are packaged instructions and scripts that extend what AI coding agents can do. When you install a skill, your AI assistant automatically gains new capabilities without any prompting required. ## Why This Matters When you use AI coding assistants with Achromatic, they now follow battle-tested patterns from Vercel Engineering: - **Avoid waterfalls** - Proper data fetching patterns - **Optimize bundles** - Tree shaking and code splitting - **Server-side performance** - Efficient RSC usage - **Accessibility compliance** - WCAG best practices - **One-click deploys** - Deploy directly from your AI agent ## Available Skills ### React Best Practices The `react-best-practices` skill encodes 40+ optimization rules across 8 categories: | Category | What It Covers | | --------------------- | ----------------------------------------------- | | Waterfall Elimination | Parallel data fetching, avoiding request chains | | Bundle Optimization | Tree shaking, dynamic imports, code splitting | | Server Performance | RSC patterns, streaming, caching | | Rendering | Avoiding unnecessary re-renders, memo usage | | State Management | Proper state colocation, context usage | | Error Handling | Error boundaries, fallback UIs | | TypeScript | Type safety patterns, generics | | Testing | Component testing best practices | When your AI assistant writes code, it automatically applies these rules. ### Web Design Guidelines The `web-design-guidelines` skill audits UI code against 100+ best practice rules: - **Accessibility** - ARIA attributes, semantic HTML, screen reader support - **Focus states** - Keyboard navigation, visible focus indicators - **Forms** - Label associations, validation patterns, error states - **Animation** - Respecting reduced motion preferences - **Typography** - Readability, responsive text sizing - **Images** - Alt text, lazy loading, responsive images - **Performance** - Layout shifts, render blocking resources - **Internationalization** - RTL support, locale handling ### Vercel Deploy Claimable The `vercel-deploy-claimable` skill enables instant deployments: - Auto-detects 40+ frameworks from your `package.json` - Creates preview deployments with shareable URLs - Generates "claimable" links for transferring projects to any Vercel account Ask your AI assistant "deploy this to Vercel" and it handles everything. ## Installation Add agent skills to your Achromatic project: ```bash npx add-skill vercel-labs/agent-skills ``` This installs all three skills. Your AI coding agent will automatically discover and use them. ## Using Skills with Achromatic Once installed, skills work automatically. Here are some examples: **Optimizing a component:** ```text "Review this component for performance issues" → AI applies react-best-practices rules → Suggests memo, proper key usage, data fetching improvements ``` **Checking accessibility:** ```text "Audit the settings page for accessibility" → AI applies web-design-guidelines rules → Reports missing labels, focus issues, ARIA problems ``` **Deploying changes:** ```text "Deploy the current branch to preview" → AI runs vercel-deploy-claimable script → Returns preview URL and claimable link ``` ## Compatible AI Agents Agent Skills work with all major AI coding assistants: - **Claude Code** - Anthropic's CLI coding agent - **Cursor** - AI-powered code editor - **Codex** - OpenAI's coding model - **Opencode** - Open-source coding agent - **Windsurf** - Codeium's AI IDE ## Why Achromatic + Agent Skills Achromatic already follows best practices, but agent skills take it further: 1. **Consistent patterns** - AI suggestions match Achromatic's architecture 2. **Faster iteration** - Skip the back-and-forth about code style 3. **Better code review** - AI catches issues before they ship 4. **Easy deploys** - Ship previews without leaving your editor ## Getting Started 1. Clone your Achromatic starter kit 2. Install agent skills: ```bash npx add-skill vercel-labs/agent-skills ``` 3. Open in your AI-powered editor (Cursor, VS Code with Claude, etc.) 4. Start building with AI that understands React and Next.js best practices --- Agent Skills represent the next evolution of AI-assisted development. Combined with Achromatic's production-ready foundation, you get the best of both worlds: clean architecture and AI that knows how to keep it that way. Check out [vercel-labs/agent-skills](https://github.com/vercel-labs/agent-skills) on GitHub for the full documentation. Sources: - [vercel-labs/agent-skills on GitHub](https://github.com/vercel-labs/agent-skills) - [Vercel Releases Agent Skills - MarkTechPost](https://www.marktechpost.com/2026/01/18/vercel-releases-agent-skills-a-package-manager-for-ai-coding-agents-with-10-years-of-react-and-next-js-optimisation-rules/) - [AI SDK 6 - Vercel](https://vercel.com/blog/ai-sdk-6) --- ## Migrate from Lovable, v0 or Replit to a Next.js Codebase **URL**: https://www.achromatic.dev/blog/migrate-from-lovable-v0-replit **Description**: Outgrown your AI-generated prototype? Learn how to plan a transition from Lovable, v0, Bolt, or Replit to a maintainable Next.js codebase. **Published**: 2026-01-17 **Updated**: 2026-07-19 You built something amazing with Lovable, v0, Bolt, or Replit. The prototype is working, users are signing up, and suddenly you realize: this thing might actually become a real business. But now you're hitting walls. The monthly bills are adding up. The generated code is becoming harder to modify. You need features that do not fit the platform's defaults. And you're starting to assess how portable your code, data, and deployment really are. This guide explains how to assess that transition, preserve the parts that already work, and move incrementally to a production-ready codebase. ## Why Teams Outgrow AI Code Generators AI code generation platforms are incredible for getting started. They lower the barrier to building software and let you validate ideas in hours instead of weeks. But they come with trade-offs that become painful as you scale: ### The Recurring Cost Problem AI development platforms commonly combine subscription tiers with usage limits or usage-based charges. Before migrating, export your invoices and measure the actual cost of development, hosting, storage, bandwidth, and third-party services for your application. Pricing changes frequently, so use each provider's current pricing page rather than a static comparison table. A source-code starter kit changes where the implementation lives, but it does not eliminate hosting, database, email, observability, or other third-party costs. ### Code Quality Concerns AI-generated code often has issues that don't surface until you scale: - **Inconsistent patterns** - Different parts of the app follow different conventions - **Missing error handling** - Happy path works, edge cases crash - **Security gaps** - Authentication and authorization that looks right but isn't - **Performance problems** - No caching, redundant queries, unnecessary re-renders - **Technical debt** - Quick fixes that become permanent problems Teams can spend weeks debugging issues that stem from generated code making assumptions that don't hold in production. ### Limited Customization These platforms excel at common patterns but struggle with: - Complex multi-tenant architectures - Custom billing logic (usage-based, per-seat, hybrid) - Advanced authentication (SSO, SAML, organization-level permissions) - Integration with specific third-party services - Performance optimization for your specific use case When you need something the platform doesn't support well, you're stuck. ### Vendor Lock-in What happens if: - The platform raises prices significantly? - They discontinue features you depend on? - They get acquired and change direction? - You need to bring on developers who don't know the platform? These scenarios become risks when your application still depends on platform-specific hosting, integrations, or deployment workflows. Check what you can export, which services remain proprietary, and how you would operate the application independently. ## The Migration Path Moving from an AI code generator to a production codebase isn't as daunting as it might seem. Here's how we approach it: ### Step 1: Audit What You Have Before migrating, understand what you're working with: - **Features inventory** - List every feature and user flow - **Data model** - Document your database schema and relationships - **Integrations** - Note all third-party services (Stripe, auth providers, etc.) - **Custom logic** - Identify business rules that are specific to your product ### Step 2: Choose the Right Foundation Don't start from scratch. Use a production-ready starter kit that includes: - Authentication (email and password, OAuth, MFA) - Billing integration (Stripe subscriptions, one-time payments, credits) - Multi-tenancy (organizations, teams, role-based access) - Database setup (migrations, seeding, type-safe queries) - UI components and dashboard pages - Deployment configuration (Vercel, Railway, Docker) This gives you a connected foundation without regenerating each common SaaS flow independently. ### Step 3: Migrate Incrementally Don't try to rebuild everything at once: 1. **Set up the new codebase** with your starter kit 2. **Migrate authentication** first - this is foundational 3. **Move your data model** and seed with production data 4. **Rebuild features one at a time**, starting with the most critical 5. **Test thoroughly** before switching users over 6. **Run in parallel** until you're confident in the new system ### Step 4: Improve as You Go Migration is an opportunity to fix issues in the original code: - Add proper error handling and loading states - Implement caching for frequently accessed data - Set up monitoring and error tracking - Add comprehensive input validation - Write tests for critical paths ## Why Achromatic for Your Migration The current Achromatic starter kits can provide the SaaS foundation while you rebuild the product-specific parts of your application. ### One-Time License, Lifetime Access One purchase grants an ongoing license to use and modify the source code for unlimited end products, subject to the [Achromatic License](/license). The product is licensed, not sold, and third-party infrastructure costs remain separate. The license includes access to updates that Achromatic makes available for the current kits. ### Production-Grade Architecture Choose the [Pro Prisma](/docs/starter-kits/pro-nextjs-prisma) or [Pro Drizzle](/docs/starter-kits/pro-nextjs-drizzle) repository. Each is a straightforward single-repository Next.js 16 application with Better Auth, Stripe billing, organizations, dashboard pages, email, observability, and deployment guides. Review the documentation for the exact implementation and decide which parts fit your migration. ### AI Context Files The current starter kits include project-specific context for coding assistants: - `CLAUDE.md` and `AGENTS.md` files that help AI understand the codebase - Cursor rules for consistent code generation - Documented patterns that assistants can follow - Strict TypeScript types that make generated changes easier to review ### Current Product Adoption More than 850 Achromatic licenses have been sold since the first release in September 2024. That number represents licenses sold, not 850 verified companies or migration projects. ## Build a Verifiable Migration Plan Migration outcomes depend on the application, so establish a baseline instead of relying on generic success stories: 1. Record current infrastructure, platform, and third-party costs. 2. Export the repository and identify platform-specific dependencies. 3. Measure build time, page performance, error rates, and critical user flows. 4. Map every feature and data migration to an owner and acceptance test. 5. Run the old and new systems in parallel before switching production traffic. 6. Compare the same measurements after migration and document the result. This gives you evidence for the decision and a rollback point if the new implementation does not meet the agreed targets. ## We're Here to Help If you're evaluating a Lovable, v0, Bolt, or Replit migration, [contact support](/contact) with your current stack and required features. We can clarify what the current kits include and what you would need to implement. Custom migration work is not included with the standard license unless agreed separately. ## Getting Started Ready to make the move? Here's what to do: 1. **[Check out our starter kits](/docs/starter-kits)** - Find the one that matches your stack 2. **[See the live demo](https://demo.achromatic.dev)** - Experience what you're getting 3. **[Contact us](/contact)** - For complex migrations or custom requirements Choose a cutover date only after the new implementation meets the acceptance criteria you recorded during the audit. --- Building on a solid foundation isn't just about the code - it's about having the confidence to scale without fear. That's what a production-ready starter kit gives you. --- ## How to Implement Metered Billing with Stripe in Next.js **URL**: https://www.achromatic.dev/blog/metered-billing-stripe-nextjs **Description**: Learn how to implement usage-based metered billing with Stripe in your Next.js SaaS. Covers metered subscriptions, usage reporting, and real-time tracking. **Published**: 2026-01-08 Metered billing (also called usage-based billing) charges customers based on how much they use your service rather than a flat subscription fee. It's perfect for APIs, AI services, cloud storage, or any product where usage varies significantly between customers. In this guide, we'll implement metered billing with Stripe in a Next.js application. ## What is Metered Billing? Unlike traditional subscriptions where customers pay a fixed monthly fee, metered billing charges based on actual usage: | Billing Model | Example | Best For | | ------------- | -------------------------------- | -------------------- | | **Flat Rate** | $29/month | Predictable services | | **Tiered** | $29 for 1000 units, $49 for 5000 | Growing usage | | **Metered** | $0.01 per API call | Variable usage | | **Hybrid** | $29/month + $0.001 per call | Base + overages | ## Setting Up Stripe for Metered Billing ### Step 1: Create a Metered Price in Stripe First, create a product with a metered price in Stripe Dashboard or via API: ```typescript // scripts/create-metered-price.ts import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); async function createMeteredProduct() { // Create the product const product = await stripe.products.create({ name: 'API Usage', description: 'Pay-per-use API access' }); // Create a metered price const price = await stripe.prices.create({ product: product.id, currency: 'usd', recurring: { interval: 'month', usage_type: 'metered', // Key setting! aggregate_usage: 'sum' // Sum all usage in billing period }, billing_scheme: 'per_unit', unit_amount: 1 // $0.01 per unit (in cents) // Or use tiered pricing: // billing_scheme: 'tiered', // tiers_mode: 'graduated', // tiers: [ // { up_to: 1000, unit_amount: 0 }, // First 1000 free // { up_to: 10000, unit_amount: 1 }, // $0.01 each // { up_to: 'inf', unit_amount: 0.5 } // $0.005 each after // ] }); console.log('Product ID:', product.id); console.log('Price ID:', price.id); } createMeteredProduct(); ``` ### Step 2: Subscribe Customer to Metered Plan When a customer subscribes, create a subscription with the metered price: ```typescript // actions/billing.ts 'use server'; import { auth } from '@/auth'; import { db } from '@/lib/db'; import { stripe } from '@/lib/stripe'; export async function createMeteredSubscription() { const session = await auth(); if (!session?.user?.id) { throw new Error('Unauthorized'); } const user = await db.user.findUnique({ where: { id: session.user.id }, select: { stripeCustomerId: true } }); if (!user?.stripeCustomerId) { throw new Error('No Stripe customer'); } // Create subscription with metered price const subscription = await stripe.subscriptions.create({ customer: user.stripeCustomerId, items: [ { price: process.env.STRIPE_METERED_PRICE_ID! // No quantity needed for metered prices } ] // Optionally add a base fee // add_invoice_items: [{ // price: 'price_base_monthly_fee' // }] }); // Store subscription info await db.user.update({ where: { id: session.user.id }, data: { stripeSubscriptionId: subscription.id, // Store the subscription item ID - needed for usage reporting stripeSubscriptionItemId: subscription.items.data[0].id } }); return { subscriptionId: subscription.id }; } ``` ## Reporting Usage to Stripe The key to metered billing is reporting usage to Stripe. You have two approaches: ### Approach 1: Report Usage in Real-Time Report each API call as it happens: ```typescript // lib/usage.ts import { db } from '@/lib/db'; import { stripe } from '@/lib/stripe'; export async function reportUsage( userId: string, quantity: number, action: string ) { const user = await db.user.findUnique({ where: { id: userId }, select: { stripeSubscriptionItemId: true } }); if (!user?.stripeSubscriptionItemId) { throw new Error('No active subscription'); } // Report usage to Stripe const usageRecord = await stripe.subscriptionItems.createUsageRecord( user.stripeSubscriptionItemId, { quantity, timestamp: Math.floor(Date.now() / 1000), action: 'increment' // Add to existing usage } ); // Also log locally for your own analytics await db.usageLog.create({ data: { userId, quantity, action, stripeUsageRecordId: usageRecord.id } }); return usageRecord; } ``` Use it in your API routes: ```typescript // app/api/ai/generate/route.ts import { NextResponse } from 'next/server'; import { auth } from '@/auth'; import { reportUsage } from '@/lib/usage'; export async function POST(request: Request) { const session = await auth(); if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } try { // Process the AI generation const result = await generateWithAI(request); // Report 1 unit of usage await reportUsage(session.user.id, 1, 'ai_generation'); return NextResponse.json(result); } catch (error) { return NextResponse.json({ error: 'Failed' }, { status: 500 }); } } ``` ### Approach 2: Batch Usage Reporting For high-volume APIs, batch usage reports: ```typescript // lib/usage-batch.ts import { db } from '@/lib/db'; import { stripe } from '@/lib/stripe'; // Track usage in memory or Redis const usageBuffer = new Map(); export function trackUsage(subscriptionItemId: string, quantity: number) { const current = usageBuffer.get(subscriptionItemId) || 0; usageBuffer.set(subscriptionItemId, current + quantity); } // Flush to Stripe periodically (e.g., every minute via cron) export async function flushUsageToStripe() { const entries = Array.from(usageBuffer.entries()); usageBuffer.clear(); const results = await Promise.allSettled( entries.map(([subscriptionItemId, quantity]) => stripe.subscriptionItems.createUsageRecord(subscriptionItemId, { quantity, timestamp: Math.floor(Date.now() / 1000), action: 'increment' }) ) ); // Log any failures results.forEach((result, index) => { if (result.status === 'rejected') { console.error( `Failed to report usage for ${entries[index][0]}:`, result.reason ); // Re-add to buffer for retry const [id, qty] = entries[index]; trackUsage(id, qty); } }); } ``` Set up a cron job to flush usage: ```typescript // app/api/cron/flush-usage/route.ts import { NextResponse } from 'next/server'; import { flushUsageToStripe } from '@/lib/usage-batch'; export async function GET(request: Request) { // Verify cron secret const authHeader = request.headers.get('authorization'); if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } await flushUsageToStripe(); return NextResponse.json({ success: true }); } ``` ## Displaying Usage to Customers Show customers their current usage: ```typescript // lib/usage.ts export async function getCurrentUsage(userId: string) { const user = await db.user.findUnique({ where: { id: userId }, select: { stripeSubscriptionItemId: true, stripeSubscriptionId: true } }); if (!user?.stripeSubscriptionId) { return null; } // Get current billing period usage from Stripe const subscription = await stripe.subscriptions.retrieve( user.stripeSubscriptionId ); const usageSummary = await stripe.subscriptionItems.listUsageRecordSummaries( user.stripeSubscriptionItemId!, { limit: 1 } ); const currentPeriodUsage = usageSummary.data[0]?.total_usage || 0; return { usage: currentPeriodUsage, periodStart: new Date(subscription.current_period_start * 1000), periodEnd: new Date(subscription.current_period_end * 1000), estimatedCost: currentPeriodUsage * 0.01 // $0.01 per unit }; } ``` Create a usage dashboard component: ```tsx // components/usage-dashboard.tsx import { getCurrentUsage } from '@/lib/usage'; import { formatCurrency, formatDate } from '@/lib/utils'; export async function UsageDashboard({ userId }: { userId: string }) { const usage = await getCurrentUsage(userId); if (!usage) { return

No active subscription

; } return (

Current Usage

{usage.usage.toLocaleString()}

API calls

Estimated Cost

{formatCurrency(usage.estimatedCost)}

this period

Billing Period

{formatDate(usage.periodStart)} - {formatDate(usage.periodEnd)}

); } ``` ## Setting Usage Limits Prevent surprise bills by implementing usage limits: ```typescript // lib/usage-limits.ts import { db } from '@/lib/db'; const USAGE_LIMITS = { free: 100, starter: 10000, pro: 100000, enterprise: Infinity }; export async function checkUsageLimit(userId: string): Promise<{ allowed: boolean; current: number; limit: number; remaining: number; }> { const user = await db.user.findUnique({ where: { id: userId }, select: { plan: true, stripeSubscriptionItemId: true } }); const limit = USAGE_LIMITS[user?.plan || 'free']; const usage = await getCurrentUsage(userId); const current = usage?.usage || 0; return { allowed: current < limit, current, limit, remaining: Math.max(0, limit - current) }; } // Use in API routes export async function POST(request: Request) { const session = await auth(); const { allowed, remaining } = await checkUsageLimit(session.user.id); if (!allowed) { return NextResponse.json( { error: 'Usage limit exceeded', remaining }, { status: 429 } ); } // Process request... } ``` ## Handling Webhooks for Metered Billing Handle invoice events for metered subscriptions: ```typescript // app/api/webhooks/stripe/route.ts case 'invoice.created': { const invoice = event.data.object as Stripe.Invoice; // For metered billing, the invoice is created at period end // with calculated usage charges console.log('Invoice created:', invoice.id); console.log('Total:', invoice.total); break; } case 'invoice.finalized': { const invoice = event.data.object as Stripe.Invoice; // Send usage summary email to customer await sendUsageSummaryEmail(invoice); break; } case 'invoice.payment_failed': { const invoice = event.data.object as Stripe.Invoice; // Handle failed payment (maybe pause API access) await handleFailedPayment(invoice); break; } ``` ## Best Practices ### 1. Always Set Usage Alerts Let customers know when they're approaching limits: ```typescript // Check usage and send alerts export async function checkUsageAlerts(userId: string) { const { current, limit } = await checkUsageLimit(userId); const percentage = (current / limit) * 100; if (percentage >= 90 && !(await hasAlertBeenSent(userId, 90))) { await sendUsageAlert(userId, '90% of usage limit reached'); } else if (percentage >= 75 && !(await hasAlertBeenSent(userId, 75))) { await sendUsageAlert(userId, '75% of usage limit reached'); } } ``` ### 2. Provide Usage Estimates Help customers predict costs: ```typescript export function estimateMonthlyCost(dailyUsage: number, pricePerUnit: number) { const estimatedMonthlyUsage = dailyUsage * 30; return estimatedMonthlyUsage * pricePerUnit; } ``` ### 3. Offer Committed Use Discounts Reward customers who commit to minimum usage: ```typescript const VOLUME_DISCOUNTS = [ { threshold: 100000, discount: 0.1 }, // 10% off above 100k { threshold: 500000, discount: 0.2 }, // 20% off above 500k { threshold: 1000000, discount: 0.3 } // 30% off above 1M ]; ``` ## Conclusion Metered billing with Stripe involves: 1. **Creating metered prices** with `usage_type: 'metered'` 2. **Reporting usage** via `createUsageRecord` 3. **Displaying usage** to customers in real-time 4. **Setting limits** to prevent surprise bills 5. **Handling webhooks** for invoice events This model works great for APIs, AI services, and any product where usage varies significantly between customers. --- _Want metered billing without building it yourself? [Achromatic](/) includes pre-built metered billing with usage tracking, limits, and customer dashboards out of the box._ --- ## New Achromatic Starter Kits Are Here **URL**: https://www.achromatic.dev/blog/achromatic-pro-release **Description**: Next.js 16, React 19, Better Auth, tRPC with Prisma or Drizzle ORM. Ship your SaaS faster than ever. **Published**: 2026-01-01 ![Achromatic Pro Landing Page](/screens/landing-dark.webp) We're excited to announce **Achromatic Pro**, our most complete Next.js SaaS starter kit. Built on Next.js 16 and React 19, it comes in two flavors: Prisma and Drizzle ORM. ## What's Inside Achromatic Pro includes everything you need to launch a production-ready SaaS: - **Authentication** with Better Auth (email/password, social login, MFA) - **Multi-tenancy** with organizations, invitations and role-based access - **Stripe billing** with subscriptions, customer portal and usage-based credits - **Admin panel** for managing users, organizations and billing - **AI chatbot** powered by Vercel AI SDK - **Marketing pages**, blog, docs and changelog ## Modern Stack ![Dashboard](/screens/dashboard-dark.webp) The starter kit uses the latest stable versions: - **Next.js 16** with App Router and React Server Components - **React 19** with Server Actions via next-safe-action - **tRPC** for end-to-end type-safe APIs - **Better Auth** for flexible, secure authentication - **Prisma** or **Drizzle** ORM - you choose - **Tailwind CSS** and **shadcn/ui** components ## Authentication Done Right ![Sign In](/screens/auth-dark.webp) Full authentication flows out of the box: - Email/password with verification and password reset - Google social sign-in with support for additional Better Auth providers - Multi-factor authentication with TOTP - Session management across devices - Account linking for multiple providers ## Multi-Tenancy Built-In ![Organizations](/screens/home-dark.webp) Support for multiple organizations per user: - Create and switch between organizations - Invite members via email - Assign roles (owner, admin, member) - Transfer ownership when needed ## Billing That Works ![Subscription Management](/screens/organization-subscription-dark.webp) Stripe integration ready for production: - Subscription plans with tiered pricing - Per-seat or per-organization billing - Usage-based credits for AI features - Customer portal for self-service management - Webhook handling for real-time updates ## Admin Panel ![Admin Panel](/screens/admin-panel-dark.webp) Manage your entire platform: - View and manage all users - Impersonate users for debugging - Ban or suspend accounts - Manually sync billing data - App configuration settings ## AI-Ready ![AI Chatbot](/screens/ai-chatbot-dark.webp) Integrated with Vercel AI SDK: - Streaming chat responses - Tool calling and function execution - Multiple model support - Credit-based usage tracking ## Two Versions, Same Quality Choose based on your ORM preference: **Prisma Version** - Visual schema with Prisma Studio - Auto-generated migrations - Industry-standard tooling **Drizzle Version** - Lightweight SQL-like syntax - Minimal runtime overhead - Maximum performance Both versions have feature parity and follow the same architecture. ## Marketing Pages Included ![Blog](/screens/blog-dark.webp) Everything you need to sell your product: - Landing page with feature sections - Pricing page with plan comparison - Blog with MDX support - Documentation site - Changelog page - Contact form ## Get Started Ready to ship your SaaS faster? - [Prisma Documentation](/docs/starter-kits/pro-nextjs-prisma) - [Drizzle Documentation](/docs/starter-kits/pro-nextjs-drizzle) - [See Pricing](/pricing) --- ## React DoS & Source Code Exposure - Starter Kits Updated **URL**: https://www.achromatic.dev/blog/react-dos-source-code-exposure **Description**: Two new React Server Components vulnerabilities discovered. All Achromatic starter kits updated to patched versions. **Published**: 2025-12-12 Just days after the critical React2Shell vulnerability, security researchers have discovered **two additional vulnerabilities** in React Server Components while probing the original patches. We've updated **all Achromatic starter kits** to the latest patched versions. ## What's new? The React team disclosed two new vulnerabilities on December 11th: - **Denial of Service (High Severity)**: [CVE-2025-55184](https://www.cve.org/CVERecord?id=CVE-2025-55184) and [CVE-2025-67779](https://www.cve.org/CVERecord?id=CVE-2025-67779) - CVSS 7.5 - **Source Code Exposure (Medium Severity)**: [CVE-2025-55183](https://www.cve.org/CVERecord?id=CVE-2025-55183) - CVSS 5.3 **These do not allow Remote Code Execution** like React2Shell. However, they can still cause significant harm to your applications. ## Denial of Service (CVE-2025-55184 & CVE-2025-67779) A malicious HTTP request can be crafted and sent to any Server Functions endpoint that, when deserialized by React, causes an **infinite loop** that hangs the server process and consumes CPU. Even if your app does not implement any React Server Function endpoints, it may still be vulnerable if it supports React Server Components. ## Source Code Exposure (CVE-2025-55183) A malicious HTTP request sent to a vulnerable Server Function may unsafely **return the source code** of any Server Function. This can leak secrets that are hardcoded in source code. ```tsx filename="server-function.ts" lineNumbers 'use server'; export async function serverFunction(name) { const conn = db.createConnection('SECRET KEY'); // Could be leaked! const user = await conn.createUser(name); return { id: user.id, message: `Hello, ${name}!` }; } ``` Runtime secrets like `process.env.SECRET` are **not affected** - only secrets hardcoded in your source code. ## Previous patches are incomplete If you updated to React 19.0.2, 19.1.3, or 19.2.2 after the React2Shell disclosure, **you need to update again**. Those patches were incomplete and still vulnerable to these new attacks. The safe versions are **19.0.3**, **19.1.4**, and **19.2.3**. ## What we've done All [Achromatic starter kits](/docs/starter-kits) have been updated to the latest patched versions: ## What you should do ### New projects Clone any of our starter kits. They're already running the patched versions. ### Existing projects Update your dependencies immediately: ```bash filename="Terminal" lineNumbers pnpm install next@latest react@latest react-dom@latest ``` Or use the official codemod: ```bash filename="Terminal" lineNumbers npx @next/codemod@canary upgrade latest ``` Verify your React packages are at version **19.0.3**, **19.1.4**, or **19.2.3** or higher. ## Why follow-up CVEs happen When a critical vulnerability is disclosed, security researchers scrutinize adjacent code paths looking for variant exploit techniques. This is common across the industry - after [Log4Shell](https://nvd.nist.gov/vuln/detail/cve-2021-44228), multiple additional CVEs were reported as the community probed the original fix. Additional disclosures can be frustrating, but they're a sign of a healthy security response cycle. ## Resources - [React Blog: Denial of Service and Source Code Exposure](https://react.dev/blog/2025/12/11/denial-of-service-and-source-code-exposure-in-react-server-components) - [CVE-2025-55183 (Source Code Exposure)](https://www.cve.org/CVERecord?id=CVE-2025-55183) - [CVE-2025-55184 (DoS)](https://www.cve.org/CVERecord?id=CVE-2025-55184) - [CVE-2025-67779 (DoS)](https://www.cve.org/CVERecord?id=CVE-2025-67779) ## Related - [React2Shell Security Patch - Next.js 16.0.7](/blog/nextjs-16-react2shell) - Read about the original critical vulnerability --- **Looking for a secure foundation for your SaaS?** Our [starter kits](/pricing) are always kept up-to-date with the latest security patches. Stay secure! --- ## All Starter Kits Upgraded to Next.js 16.0.7 - React2Shell Security Patch **URL**: https://www.achromatic.dev/blog/nextjs-16-react2shell **Description**: We've upgraded all Achromatic starter kits to Next.js 16.0.7 to address the critical React2Shell vulnerability (CVE-2025-55182). Your projects are now protected against this maximum-severity security flaw. **Published**: 2025-12-06 A critical vulnerability called **React2Shell** (CVE-2025-55182 and CVE-2025-66478) was disclosed this week. It affects React Server Components and Next.js applications, allowing attackers to achieve remote code execution without authentication. We've upgraded **all Achromatic starter kits to Next.js 16.0.7**. ## What is React2Shell? React2Shell affects: - **React Server Components (RSC)** - **React Server Functions** - **Next.js applications using the App Router** Attackers can send specially crafted requests to vulnerable servers and gain remote code execution. Security researchers reported near 100% success rates in exploitation attempts, and active exploitation has already been observed in the wild. ## Why it matters - **No authentication required** - Attackers can exploit this without logging in - **Default configurations affected** - Most standard setups are vulnerable - **Active exploitation** - Security firms observed opportunistic attacks - **Remote code execution** - Attackers can gain full control of your web server ## What we've done All Achromatic starter kits have been updated to **Next.js 16.0.7**, which includes the security patches for both CVE-2025-55182 and CVE-2025-66478: ## What you should do ### New projects Simply clone any of our starter kits. They're already running the patched version of Next.js. ### Existing projects Update your dependencies: ```bash filename="Terminal" lineNumbers pnpm install next@latest react@latest react-dom@latest ``` Or use the official codemod: ```bash filename="Terminal" lineNumbers npx @next/codemod@canary upgrade latest ``` Verify your Next.js version is **16.0.7 or higher** after the upgrade. ## Resources - [Next.js Security Advisory](https://nextjs.org/blog/CVE-2025-66478) - [JFrog: React2Shell Detection and Mitigation Guide](https://jfrog.com/blog/2025-55182-and-2025-66478-react2shell-all-you-need-to-know/) - [Dynatrace: CVE-2025-55182 Analysis](https://www.dynatrace.com/news/blog/cve-2025-55182-react2shell-critical-vulnerability-what-it-is-and-what-to-do/) ## Update (December 12, 2025) Two additional vulnerabilities were discovered. Read our follow-up post: [React DoS and Source Code Exposure Vulnerabilities](/blog/react-dos-source-code-exposure). --- **Starting a new project?** Our [starter kits](/docs/starter-kits) are always kept up-to-date with the latest security patches. [Get lifetime access](/pricing). Stay secure! --- ## Vibe Coding: The Future of Software Development is Here **URL**: https://www.achromatic.dev/blog/vibe-coding **Description**: Vibe coding is revolutionizing how developers build software. Learn what vibe coding is, how AI-powered development works, and why traditional coding is being transformed forever. **Published**: 2025-06-15 The way we build software is fundamentally changing. A new paradigm called **vibe coding** has emerged, and it's reshaping how developers approach their craft. Instead of writing every line of code manually, developers now describe what they want to build and let AI handle the implementation details. ## What is Vibe Coding? Vibe coding is a development approach where you communicate your intent to an AI assistant using natural language, and the AI generates the code for you. The term was coined by Andrej Karpathy, former Director of AI at Tesla, who described it as: > "You fully give in to the vibes, embrace exponentials, and forget that the code even exists." In practice, vibe coding means: - **Describing features** instead of implementing them line by line - **Reviewing and guiding** AI-generated code rather than writing from scratch - **Focusing on architecture** and user experience while AI handles boilerplate - **Iterating rapidly** through conversation rather than keyboard shortcuts ## How Vibe Coding Works A typical vibe coding session looks something like this: ```text filename="Conversation" lineNumbers Developer: "Add a dark mode toggle to the settings page that persists the user's preference to localStorage and syncs across tabs" AI: *generates complete implementation with React context, localStorage sync, BroadcastChannel for cross-tab sync, and proper TypeScript types* Developer: "Make it animate smoothly and add a system preference option" AI: *updates the code with CSS transitions and prefers-color-scheme detection* ``` The developer stays in control of the direction while the AI handles the execution. It's like having a senior developer pair programming with you who types incredibly fast. ## Why Vibe Coding is Taking Over ### 1. Speed What used to take hours now takes minutes. Building a complete authentication flow, setting up a database schema, or creating a complex UI component happens in the time it takes to describe what you want. ### 2. Lower Barrier to Entry Developers who might struggle with syntax or framework-specific patterns can now build production-quality software by understanding concepts rather than memorizing APIs. ### 3. Better Code Quality AI assistants have been trained on millions of codebases. They naturally apply best practices, handle edge cases, and follow established patterns that individual developers might miss. ### 4. Focus on What Matters Instead of spending mental energy on implementation details, developers can focus on: - User experience design - System architecture - Business logic - Code review and testing ## The Vibe Coding Stack Modern vibe coding typically involves: | Tool | Purpose | | ----------------------------------------- | ---------------------------------- | | Claude, GPT-4, or similar | Primary AI coding assistant | | Cursor, Windsurf, or VS Code + extensions | AI-enhanced IDE | | Voice input (optional) | Hands-free code generation | | Git + AI commit messages | Version control with AI assistance | ## Vibe Coding Best Practices ### Be Specific About Context ```text filename="Prompting" lineNumbers // Less effective "Add a button" // More effective "Add a primary button in the header that opens a modal for creating new projects. Use the existing Button component from our design system and follow the same pattern as the 'New Team' button on the teams page." ``` ### Review Everything Vibe coding doesn't mean blindly accepting AI output. The best vibe coders: - Read through generated code carefully - Test edge cases the AI might have missed - Refactor when the AI's approach doesn't fit the codebase - Ask the AI to explain complex sections ### Build in Iterations Instead of asking for a complete feature at once: 1. Start with the basic structure 2. Add functionality incrementally 3. Refine styling and UX 4. Handle error states and edge cases 5. Add tests ### Know When to Code Manually Vibe coding excels at: - Boilerplate and CRUD operations - UI components and styling - Standard patterns and integrations - Documentation and comments Manual coding is still better for: - Complex algorithms requiring deep thought - Performance-critical sections - Security-sensitive code - Highly domain-specific logic ## Vibe Coding with Next.js and React The React ecosystem is particularly well-suited for vibe coding because: 1. **Component-based architecture** - Easy to describe isolated pieces 2. **Strong conventions** - AI knows common patterns 3. **TypeScript support** - AI generates type-safe code 4. **Rich ecosystem** - AI knows popular libraries like shadcn/ui, Tailwind, Prisma Here's an example of vibe coding a feature for a SaaS application: ```text filename="Prompt" lineNumbers Developer: "Create a usage dashboard that shows the user's API calls this month with a progress bar toward their plan limit. Include a chart showing daily usage for the past 30 days. Use recharts for the chart and our existing Card components." ``` The AI would generate: ```tsx filename="components/usage-dashboard.tsx" lineNumbers import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; interface UsageDashboardProps { currentUsage: number; planLimit: number; dailyUsage: Array<{ date: string; calls: number }>; } export function UsageDashboard({ currentUsage, planLimit, dailyUsage }: UsageDashboardProps) { const usagePercentage = (currentUsage / planLimit) * 100; return (
API Usage This Month
{currentUsage.toLocaleString()} calls {planLimit.toLocaleString()} limit

{(planLimit - currentUsage).toLocaleString()} calls remaining

Daily Usage (30 Days)
); } ``` This would have taken 20-30 minutes to write manually. With vibe coding, it's done in under a minute. ## The Future of Development Vibe coding isn't replacing developers - it's augmenting them. The developers who embrace this paradigm shift will: - Ship faster than ever before - Take on more ambitious projects - Focus on high-level problem solving - Build better products with less burnout The ones who resist will find themselves outpaced by individuals and small teams who leverage AI effectively. ## Getting Started with Vibe Coding If you want to start vibe coding today: 1. **Choose an AI-enhanced IDE** - Cursor and Windsurf are popular choices 2. **Start small** - Use AI for individual components before full features 3. **Learn to prompt effectively** - Be specific, provide context, iterate 4. **Review rigorously** - AI makes mistakes; catch them before production 5. **Use a solid foundation** - Start with a production-ready starter kit that gives AI good patterns to follow ## Conclusion Vibe coding represents the biggest shift in software development since the move from assembly to high-level languages. It doesn't eliminate the need for skilled developers - it amplifies what skilled developers can accomplish. The developers who master vibe coding will build in days what used to take weeks. They'll ship products that would have required entire teams. And they'll do it while focusing on the creative, strategic work that humans do best. The vibe is here. Time to embrace it. --- Ready to build your next SaaS with modern development practices? Check out our [Next.js starter kits](/docs/starter-kits) that provide the perfect foundation for vibe coding - clean architecture, TypeScript throughout, and patterns that AI assistants understand and extend beautifully. --- ## Billing Overhaul **URL**: https://www.achromatic.dev/blog/billing-overhaul **Description**: The billing system got completely overhauled, now supporting diverse plans, including lifetime and metered options, with flexible pricing models. **Published**: 2025-06-07 ## Schema The billing configuration is composed of the following entities: - **Product:** Defines the offering (e.g. Starter, Pro, Enterprise, etc.) with 1-n plans. - **Plan:** Defines the payment plan (e.g. Pro Monthly, Pro Yearly. etc) with 1-n prices. - **Price:** Defines the type, interval, model, amount and currency. Following enums define a price more granulary: - **PriceType:** Can be `recurring` or `one-time`. - **PriceInterval:** Can be `month`, `year` or `undefined`. - **PriceModel:** Can be `flat`, `per_seat` or `metered`. Multiple prices are required if you want to combine multiple strategies, so more line items will be generate on the invoice. ## Tactic Change We've shifted our strategy for handling billing data: - **Previous Tactic**: Store as little as possible and query everything directly from the billing provider. - **New Tactic**: Store as much billing data as possible in our own database. This change is driven by the realization that billing providers can impose aggressive rate limits as user amounts increase. ## Database Changes To support this new tactic, we've made several important database schema changes: - **Organization**: Now stores a **billing customer** (including billing email and billing address). Previous `tiers` have been removed. - **Added Subscription**: A new table to manage active subscriptions. - **Added SubscriptionItem**: Details the individual items within a subscription. - **Added Order**: A new table for one-time purchases. - **Added OrderItem**: Details the individual items within an order. ## Example Configuration Below is an example of how a `billingConfig` might be structured, showcasing `Free`, `Pro`, `Lifetime` and `Enterprise` products with their respective plans and prices: ```typescript filename="packages/billing/src/config.ts" lineNumbers export const billingConfig = createBillingConfig({ products: [ { id: 'free', name: 'Free', description: 'Start for free.', label: 'Get started', isFree: true, features: [Feature.AICustomerScoring, Feature.SmartEmailAnalysis], // Even if it is free, keep the plans and prices to display the interval and currency correctly plans: [ { id: 'plan-free-month', displayIntervals: [PriceInterval.Month], prices: [ { id: 'price-free-month-id', // a placebo ID is fine here type: PriceType.Recurring, model: PriceModel.Flat, interval: PriceInterval.Month, cost: 0, currency } ] }, { id: 'plan-free-year', displayIntervals: [PriceInterval.Year], prices: [ { id: 'price-free-year-id', // a placebo ID is fine here interval: PriceInterval.Year, type: PriceType.Recurring, model: PriceModel.Flat, cost: 0, currency } ] } ] }, { id: 'pro', name: 'Pro', description: 'Best for most teams.', label: 'Get started', recommended: true, features: [ Feature.AICustomerScoring, Feature.SmartEmailAnalysis, Feature.SentimentAnalysis, Feature.LeadPredictions ], plans: [ { id: 'plan-pro-month', displayIntervals: [PriceInterval.Month], trialDays: 7, prices: [ { id: keys().NEXT_PUBLIC_BILLING_PRICE_PRO_MONTH_ID || 'price-pro-month-id', // keep for marketing pages, so you only need to specify id in dashboard interval: PriceInterval.Month, type: PriceType.Recurring, model: PriceModel.Flat, cost: 24, currency } ] }, { id: 'plan-pro-year', displayIntervals: [PriceInterval.Year], trialDays: 7, prices: [ { id: keys().NEXT_PUBLIC_BILLING_PRICE_PRO_YEAR_ID || 'price-pro-year-id', // keep for marketing pages, so you only need to specify id in dashboard interval: PriceInterval.Year, type: PriceType.Recurring, model: PriceModel.Flat, cost: 199, currency } ] } ] }, { id: 'lifetime', name: 'Lifetime', description: 'Buy once. Use forever.', label: 'Get started', features: [ Feature.AICustomerScoring, Feature.SmartEmailAnalysis, Feature.SentimentAnalysis, Feature.LeadPredictions ], plans: [ { id: 'lifetime', displayIntervals: [PriceInterval.Month, PriceInterval.Year], prices: [ { id: keys().NEXT_PUBLIC_BILLING_PRICE_LIFETIME_ID || 'price-lifetime-id', // keep for marketing pages, so you only need to specify id in dashboard type: PriceType.OneTime, model: PriceModel.Flat, // only flat is supported for PriceType.OneTime interval: undefined, cost: 699, currency } ] } ] }, { id: 'enterprise', name: 'Enterprise', description: 'Best for tailored requirements.', label: 'Contact us', isEnterprise: true, features: [ Feature.AICustomerScoring, Feature.SmartEmailAnalysis, Feature.SentimentAnalysis, Feature.LeadPredictions, Feature.DataStorage, Feature.ExtendedSupport ], // The idea is to keep the product and prices and use an admin panel to update the customer to enterprise. // For enterprise you can have multiple products, you just need to set hidden: true on the other enterprise products. plans: [ { id: 'plan-enterprise-month', displayIntervals: [PriceInterval.Month], prices: [ { id: keys().NEXT_PUBLIC_BILLING_PRICE_ENTERPRISE_MONTH_ID || 'price-enterprise-month-id', // keep for marketing pages, so you only need to specify id in dashboard interval: PriceInterval.Month, type: PriceType.Recurring, model: PriceModel.Flat, cost: 39, currency } ] }, { id: 'plan-enterprise-year', displayIntervals: [PriceInterval.Year], prices: [ { id: keys().NEXT_PUBLIC_BILLING_PRICE_ENTERPRISE_YEAR_ID || 'price-enterprise-year-id', // keep for marketing pages, so you only need to specify id in dashboard interval: PriceInterval.Year, type: PriceType.Recurring, model: PriceModel.Flat, cost: 399, currency } ] } ] } ] }); ``` ## Related Documentation For full implementation details, check out the billing documentation in our starter kits: - [Pro Prisma Starter Kit - Billing](/docs/starter-kits/pro-nextjs-prisma/billing/overview) - [Pro Drizzle Starter Kit - Billing](/docs/starter-kits/pro-nextjs-drizzle/billing/overview) ## Related Articles - [Implementing Stripe Billing in Next.js](/blog/stripe-billing-nextjs) - Step-by-step guide to integrating Stripe with Server Actions and webhooks - [Multi-Tenant Architecture in Next.js](/blog/multi-tenant-architecture-nextjs) - How to structure billing for multi-organization SaaS The current implementation is maintained in both the [Pro Prisma billing guide](/docs/starter-kits/pro-nextjs-prisma/billing/overview) and [Pro Drizzle billing guide](/docs/starter-kits/pro-nextjs-drizzle/billing/overview). --- Ready to implement flexible billing in your SaaS? [Get started with Achromatic](/pricing). --- ## What is a SaaS Starter Kit? The Complete Guide for 2026 **URL**: https://www.achromatic.dev/blog/what-is-saas-starter-kit **Description**: Learn what a SaaS starter kit is, why developers use one, and how to evaluate the code, maintenance, and features before buying. **Published**: 2025-05-15 **Updated**: 2026-07-19 If you're planning to build a SaaS (Software as a Service) application, you've probably come across the term "starter kit" or "boilerplate." But what exactly is a SaaS starter kit, and should you use one for your next project? In this guide, we'll break down what SaaS starter kits are, why they exist, and how they can reduce repeated foundation work. ## What is a SaaS Starter Kit? A **SaaS starter kit** is a pre-built codebase that includes all the foundational features needed to launch a subscription-based software product. Instead of building everything from scratch, you get a working application with authentication, billing, database integration, and other essential features already implemented. Think of it like buying a house with the foundation, plumbing, and electrical already installed. You still need to customize the interior and make it your own, but you're not starting from bare land. ### What's Typically Included? A comprehensive SaaS starter kit usually includes: - **Authentication**: Email/password login, OAuth providers (Google, GitHub), magic links, and session management - **Billing Integration**: Stripe subscriptions, one-time payments, webhooks, and customer portal - **Database Setup**: ORM configuration, schema design, migrations, and connection pooling - **Multi-tenancy**: Organization/workspace management, team invites, role-based permissions - **UI Components**: Dashboard layouts, forms, tables, modals, and navigation - **API Layer**: REST or tRPC endpoints with proper error handling - **Email System**: Transactional emails for verification, invites, and notifications - **Developer Experience**: TypeScript, ESLint, Prettier, and testing setup ## Starter Kit vs Boilerplate vs Template These terms are often used interchangeably, but there are subtle differences: | Term | Definition | Customization Level | | --------------- | ----------------------------------------------- | ------------------------ | | **Template** | Basic starting point with minimal functionality | High - mostly UI/layout | | **Boilerplate** | Reusable code patterns with some features | Medium - structural code | | **Starter Kit** | Full-featured foundation with business logic | Lower - feature-complete | A **template** might give you a landing page design. A **boilerplate** adds authentication scaffolding. A **starter kit** gives you a working product with billing, teams, and dashboards ready to customize. In practice, most developers use these terms interchangeably. What matters is understanding what you're getting. ## Why Use a SaaS Starter Kit? ### 1. Time Savings Authentication, billing, multi-tenancy, email, and deployment each involve implementation, integration, testing, and ongoing maintenance. A starter kit can remove repeated setup work, but the actual time saved depends on how closely its architecture matches your product. Estimate the difference using your own requirements: 1. List every foundation feature you would otherwise build. 2. Include testing, documentation, security review, and maintenance work. 3. Identify which starter-kit flows are complete and which require customization. 4. Compare that work with the time needed to learn and adapt the kit. ### 2. Battle-Tested Patterns Starter kits encode best practices learned from building multiple production applications: - Secure authentication flows - Proper webhook handling - Efficient database queries - Accessible UI components - Error boundaries and logging You're not just getting code—you're getting **accumulated expertise**. ### 3. Cost Efficiency Compare the license price with the engineering time needed to implement, test, document, and maintain the same flows. Also include migration work, third-party service fees, and the cost of removing features that do not fit your product. A starter kit is cost-effective only when its useful implementation work exceeds the effort required to adapt it. ### 4. Reduced Technical Debt When you build from scratch under time pressure, you take shortcuts. These shortcuts become technical debt that slows you down later. A well-maintained starter kit has: - Consistent code patterns - Proper TypeScript types - Documented architecture - Regular security updates ## When Should You NOT Use a Starter Kit? Starter kits aren't always the right choice: 1. **Learning Projects**: If your goal is to learn how authentication or billing works, build it yourself 2. **Highly Unique Requirements**: If your product is fundamentally different from a standard SaaS 3. **Existing Codebase**: If you're adding features to an established product 4. **Team Unfamiliarity**: If your team doesn't know the starter kit's tech stack ## What to Look for in a SaaS Starter Kit Not all starter kits are equal. Here's what separates good from great: ### Must-Have Features - **Modern Tech Stack**: Next.js 16, React 19, TypeScript, and Node.js 22.21.1 - **Maintained Codebase**: Regular updates, active development - **Documentation**: Clear setup guides, architecture explanations - **Authentication**: Multiple providers, secure sessions - **Billing Integration**: Stripe with webhooks, customer portal - **Type Safety**: End-to-end TypeScript, no `any` types ### Nice-to-Have Features - **Multi-tenancy**: Organizations, team management, permissions - **Email Templates**: Pre-designed transactional emails - **Admin Panel**: User management, analytics - **Testing Setup**: Unit tests, integration tests, E2E - **CI/CD**: GitHub Actions, deployment configs - **AI-Coding Ready**: CLAUDE.md, Cursor rules ### Red Flags to Avoid - No recent commits (abandoned project) - Missing TypeScript or loose type definitions - No documentation or outdated docs - Outdated dependencies with security vulnerabilities - No billing integration or only basic payment links - Locked to a single hosting provider ## How to Evaluate a Starter Kit Before purchasing, do your due diligence: 1. **Check the Demo**: Does it work? Is it fast? 2. **Read the Docs**: Are they comprehensive? 3. **Review the Code**: Is it clean? Well-organized? 4. **Check Dependencies**: Are they up-to-date? 5. **Test the Support**: Ask a question before buying 6. **Look for Updates**: When was the last commit? ## The Build vs Buy Decision Every founder faces this question: should I build my foundation or buy it? **Build if:** - You have 6+ months of runway before needing revenue - Your team has deep expertise in all required areas - Your product requires non-standard architecture - You're building for a highly regulated industry **Buy if:** - You need to ship quickly and validate your idea - You want to focus on your unique value proposition - You're a solo founder or small team - You've built SaaS before and know the patterns ## Getting the Most from a Starter Kit If you decide to use a starter kit, here's how to maximize its value: ### Week 1: Learn the Codebase - Read all documentation - Understand the folder structure - Trace a complete user flow (signup → dashboard → billing) - Run the test suite ### Week 2: Customize for Your Product - Replace branding and copy - Modify the database schema for your domain - Remove features you don't need - Add your unique functionality ### Week 3+: Build Your Features - Focus entirely on what makes your product unique - Use the existing patterns as guides - Contribute improvements back (if open source) ## Conclusion A SaaS starter kit is a pre-built foundation that handles authentication, billing, and infrastructure so you can focus on product-specific features. A well-matched kit can reduce development time and help you validate an idea sooner. The key is choosing a well-maintained starter kit with a modern tech stack, comprehensive documentation, and the features you actually need. Don't pay for complexity you won't use, but don't skimp on foundations you'll need later. Whether you're a solo founder building your first SaaS or a team launching a new product, the right starter kit can be the difference between shipping in weeks versus shipping in months. --- _Building a SaaS and want to skip the boilerplate? The current Achromatic [Pro Prisma](/docs/starter-kits/pro-nextjs-prisma) and [Pro Drizzle](/docs/starter-kits/pro-nextjs-drizzle) kits use Next.js 16, React 19, Better Auth, and Stripe billing._ --- ## Building a SaaS Dashboard with React Server Components **URL**: https://www.achromatic.dev/blog/saas-dashboard-react-server-components **Description**: Learn how to build fast, data-rich SaaS dashboards using React Server Components in Next.js. Covers streaming, suspense boundaries, parallel data fetching, and real-world patterns. **Published**: 2025-04-20 React Server Components (RSC) have revolutionized how we build data-heavy applications. For SaaS dashboards—which typically fetch data from multiple sources and display complex analytics—RSC provides massive performance improvements while simplifying our code. In this guide, we'll build a production-ready SaaS dashboard using React Server Components, covering streaming, suspense boundaries, parallel data fetching, and caching strategies. ## Why Server Components for Dashboards? SaaS dashboards have unique challenges: - **Multiple data sources:** Users, subscriptions, analytics, activity logs - **Heavy computations:** Aggregations, charts, statistics - **Personalized content:** Role-based views, tenant-specific data - **Real-time updates:** Activity feeds, notifications Server Components solve these elegantly: | Traditional Approach | Server Components | | ----------------------------------- | ---------------------------------- | | Fetch data client-side | Fetch on server, stream to client | | Bundle chart libraries | Keep heavy deps server-only | | Waterfalls (fetch → render → fetch) | Parallel fetching with streaming | | Expose API endpoints | Direct database access | | Loading spinners everywhere | Instant shell, progressive loading | ## Project Structure Here's our dashboard structure: ```text filename="project-structure.txt" lineNumbers app/ ├── dashboard/ │ ├── layout.tsx # Dashboard layout with sidebar │ ├── page.tsx # Main dashboard (overview) │ ├── loading.tsx # Loading skeleton │ ├── analytics/ │ │ └── page.tsx # Analytics page │ ├── customers/ │ │ └── page.tsx # Customer list │ └── settings/ │ └── page.tsx # Settings ├── components/ │ ├── dashboard/ │ │ ├── stats-cards.tsx # KPI cards │ │ ├── revenue-chart.tsx # Revenue chart │ │ ├── recent-activity.tsx # Activity feed │ │ └── top-customers.tsx # Top customers table │ └── ui/ │ └── ... # UI components └── lib/ ├── data/ │ ├── analytics.ts # Analytics queries │ ├── customers.ts # Customer queries │ └── activity.ts # Activity queries └── db.ts # Database client ``` ## The Dashboard Layout Start with a layout that provides the navigation shell: ```tsx filename="app/dashboard/layout.tsx" lineNumbers import { redirect } from 'next/navigation'; import { Header } from '@/components/dashboard/header'; import { Sidebar } from '@/components/dashboard/sidebar'; import { auth } from '@/lib/auth'; export default async function DashboardLayout({ children }: { children: React.ReactNode; }) { const session = await auth(); if (!session?.user) { redirect('/login'); } return (
{children}
); } ``` ## Building the Overview Dashboard The main dashboard page orchestrates multiple data components: ```tsx filename="app/dashboard/page.tsx" lineNumbers import { Suspense } from 'react'; import { RecentActivity, RecentActivitySkeleton } from '@/components/dashboard/recent-activity'; import { RevenueChart, RevenueChartSkeleton } from '@/components/dashboard/revenue-chart'; import { StatsCards, StatsCardsSkeleton } from '@/components/dashboard/stats-cards'; import { TopCustomers, TopCustomersSkeleton } from '@/components/dashboard/top-customers'; export default function DashboardPage() { return (

Dashboard

Welcome back! Here's what's happening with your business.

{/* Stats Cards - Load first (most important) */} }> {/* Main content grid */}
{/* Revenue Chart - Takes more space */}
}>
{/* Recent Activity - Sidebar */}
}>
{/* Top Customers Table */} }>
); } ``` **Key insight:** Each `` boundary creates an independent loading stream. The page shell renders immediately, and each component streams in as its data resolves. ## Stats Cards Component The stats cards show key metrics at a glance: ```tsx filename="components/dashboard/stats-cards.tsx" lineNumbers import { Activity, CreditCard, DollarSign, Users } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { getStats } from '@/lib/data/analytics'; export async function StatsCards() { const stats = await getStats(); return (
Total Revenue
${stats.totalRevenue.toLocaleString()}

= 0 ? 'text-green-600' : 'text-red-600' } > {stats.revenueChange >= 0 ? '+' : ''} {stats.revenueChange}% {' '} from last month

Subscriptions
+{stats.newSubscriptions}

= 0 ? 'text-green-600' : 'text-red-600' } > {stats.subscriptionChange >= 0 ? '+' : ''} {stats.subscriptionChange}% {' '} from last month

Sales
+{stats.totalSales}

= 0 ? 'text-green-600' : 'text-red-600' } > {stats.salesChange >= 0 ? '+' : ''} {stats.salesChange}% {' '} from last month

Active Now
+{stats.activeUsers}

{stats.activeUsersChange} since last hour

); } export function StatsCardsSkeleton() { return (
{Array.from({ length: 4 }).map((_, i) => (
))}
); } ``` ## Data Fetching Layer Keep your data fetching clean with dedicated functions: ```tsx filename="lib/data/analytics.ts" lineNumbers import { cache } from 'react'; import { auth } from '@/lib/auth'; import { prisma } from '@/lib/db'; // Use React's cache() for request deduplication export const getStats = cache(async () => { const session = await auth(); if (!session?.user?.organizationId) { throw new Error('Unauthorized'); } const organizationId = session.user.organizationId; const now = new Date(); const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); const startOfLastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); // Parallel queries for better performance const [ currentRevenue, lastMonthRevenue, currentSubscriptions, lastMonthSubscriptions, currentSales, lastMonthSales, activeUsers ] = await Promise.all([ // Current month revenue prisma.payment.aggregate({ where: { organizationId, createdAt: { gte: startOfMonth }, status: 'succeeded' }, _sum: { amount: true } }), // Last month revenue prisma.payment.aggregate({ where: { organizationId, createdAt: { gte: startOfLastMonth, lt: startOfMonth }, status: 'succeeded' }, _sum: { amount: true } }), // Current subscriptions prisma.subscription.count({ where: { organizationId, createdAt: { gte: startOfMonth } } }), // Last month subscriptions prisma.subscription.count({ where: { organizationId, createdAt: { gte: startOfLastMonth, lt: startOfMonth } } }), // Current sales prisma.order.count({ where: { organizationId, createdAt: { gte: startOfMonth } } }), // Last month sales prisma.order.count({ where: { organizationId, createdAt: { gte: startOfLastMonth, lt: startOfMonth } } }), // Active users (last hour) prisma.session.count({ where: { organizationId, lastActive: { gte: new Date(Date.now() - 60 * 60 * 1000) } } }) ]); const totalRevenue = (currentRevenue._sum.amount || 0) / 100; const lastRevenue = (lastMonthRevenue._sum.amount || 0) / 100; const revenueChange = lastRevenue > 0 ? Math.round(((totalRevenue - lastRevenue) / lastRevenue) * 100) : 0; const subscriptionChange = lastMonthSubscriptions > 0 ? Math.round( ((currentSubscriptions - lastMonthSubscriptions) / lastMonthSubscriptions) * 100 ) : 0; const salesChange = lastMonthSales > 0 ? Math.round(((currentSales - lastMonthSales) / lastMonthSales) * 100) : 0; return { totalRevenue, revenueChange, newSubscriptions: currentSubscriptions, subscriptionChange, totalSales: currentSales, salesChange, activeUsers, activeUsersChange: `+${Math.floor(Math.random() * 50)}` // Would be calculated }; }); ``` ## Revenue Chart with Server-Only Dependencies Charts can be heavy. Keep chart libraries server-side: ```tsx filename="components/dashboard/revenue-chart.tsx" lineNumbers import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { getRevenueData } from '@/lib/data/analytics'; // Client component just for the chart rendering import { RevenueChartClient } from './revenue-chart-client'; export async function RevenueChart() { const data = await getRevenueData(); return ( Revenue Overview Monthly revenue for the current year ); } export function RevenueChartSkeleton() { return (
); } ``` ```tsx filename="components/dashboard/revenue-chart-client.tsx" lineNumbers 'use client'; import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; interface RevenueData { month: string; revenue: number; subscriptions: number; } export function RevenueChartClient({ data }: { data: RevenueData[] }) { return ( `$${value.toLocaleString()}`} /> [ `$${value.toLocaleString()}`, 'Revenue' ]} /> ); } ``` ## Recent Activity with Streaming Activity feeds benefit from streaming—show them progressively: ```tsx filename="components/dashboard/recent-activity.tsx" lineNumbers import { formatDistanceToNow } from 'date-fns'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { getRecentActivity } from '@/lib/data/activity'; export async function RecentActivity() { const activities = await getRecentActivity(); return ( Recent Activity
{activities.map((activity) => (
{activity.user.name?.slice(0, 2).toUpperCase()}

{activity.user.name}{' '} {activity.action} {' '} {activity.target}

{formatDistanceToNow(new Date(activity.createdAt), { addSuffix: true })}

))}
); } export function RecentActivitySkeleton() { return (
{Array.from({ length: 5 }).map((_, i) => (
))}
); } ``` ## Parallel Data Fetching Pattern For pages needing multiple independent data sources, use parallel fetching: ```tsx filename="app/dashboard/analytics/page.tsx" lineNumbers import { Suspense } from 'react'; // These functions can be called in parallel because they're independent async function fetchPageViews() { // Simulated delay for demo await new Promise((r) => setTimeout(r, 1000)); return { total: 125000, change: 12.5 }; } async function fetchConversionRate() { await new Promise((r) => setTimeout(r, 1500)); return { rate: 3.2, change: 0.5 }; } async function fetchBounceRate() { await new Promise((r) => setTimeout(r, 800)); return { rate: 42, change: -2.1 }; } // Components that fetch their own data async function PageViewsCard() { const data = await fetchPageViews(); return ( Page Views {data.total.toLocaleString()} ); } async function ConversionCard() { const data = await fetchConversionRate(); return ( Conversion Rate {data.rate}% ); } async function BounceRateCard() { const data = await fetchBounceRate(); return ( Bounce Rate {data.rate}% ); } // All cards load in parallel, not sequentially! export default function AnalyticsPage() { return (
}> }> }>
); } ``` ## Loading States Done Right Create a cohesive loading experience with `loading.tsx`: ```tsx filename="app/dashboard/loading.tsx" lineNumbers import { RecentActivitySkeleton } from '@/components/dashboard/recent-activity'; import { RevenueChartSkeleton } from '@/components/dashboard/revenue-chart'; import { StatsCardsSkeleton } from '@/components/dashboard/stats-cards'; import { TopCustomersSkeleton } from '@/components/dashboard/top-customers'; export default function DashboardLoading() { return (
); } ``` ## Performance Tips ### 1. Use React's `cache()` for Deduplication ```tsx filename="lib/data/users.ts" lineNumbers import { cache } from 'react'; // This function will only run once per request, even if called multiple times export const getUser = cache(async (userId: string) => { return prisma.user.findUnique({ where: { id: userId } }); }); ``` ### 2. Preload Data for Faster Navigation ```tsx filename="lib/data/preload.ts" lineNumbers // In your layout or parent component import { preloadDashboardData } from '@/lib/data/preload'; import { getRecentActivity } from './activity'; import { getStats } from './analytics'; export function preloadDashboardData() { void getStats(); void getRecentActivity(); } export default function Layout({ children }) { preloadDashboardData(); // Start fetching before render return <>{children}; } ``` ### 3. Revalidate Strategically ```tsx filename="app/actions/orders.ts" lineNumbers // Or use on-demand revalidation import { revalidatePath } from 'next/cache'; // For data that changes frequently (revalidate every 60 seconds) export const revalidate = 60; export async function createOrder(data: OrderData) { await prisma.order.create({ data }); revalidatePath('/dashboard'); // Refresh dashboard data } ``` ## Conclusion React Server Components transform how we build SaaS dashboards: 1. **Faster initial loads:** The shell renders instantly while data streams in 2. **Simpler code:** No API routes needed, fetch directly in components 3. **Better DX:** Collocate data fetching with components 4. **Smaller bundles:** Keep heavy dependencies server-side 5. **Progressive enhancement:** Each section loads independently The key patterns to remember: - Wrap async components in `` for streaming - Use `cache()` for request deduplication - Fetch data in parallel when possible - Keep heavy dependencies (charts, date libraries) server-side - Create matching skeleton components for smooth loading states ## Related Articles - [Multi-Tenant Architecture in Next.js](/blog/multi-tenant-architecture-nextjs) - Build organization-scoped dashboards with data isolation - [Prisma vs Drizzle ORM](/blog/prisma-vs-drizzle-orm) - Choose the right database layer for your dashboard - [Implementing Stripe Billing in Next.js](/blog/stripe-billing-nextjs) - Add billing widgets to your dashboard --- Ready to build your SaaS dashboard? Our starter kits come with a complete dashboard implementation: - [Prisma Kit](/docs/starter-kits/monorepo-next-prisma-authjs) - Full dashboard with analytics, billing, and team management - [Drizzle Kit](/docs/starter-kits/monorepo-next-drizzle-authjs) - Lightweight dashboard with streaming and Server Components Check out our [live demo](https://demo.achromatic.dev) to see Server Components in action, or visit our [pricing page](/pricing) to get started. --- ## SaaS Security Best Practices for Next.js Applications **URL**: https://www.achromatic.dev/blog/saas-security-best-practices **Description**: Essential security practices for building secure SaaS applications in Next.js. Covers authentication, authorization, data protection, API security, and common vulnerabilities. **Published**: 2025-04-18 **Updated**: 2026-07-19 Security isn't an afterthought—it's a foundation. For SaaS applications handling customer data, a single vulnerability can destroy trust and potentially your business. This guide covers essential security practices for Next.js SaaS applications, from authentication to deployment. ## Authentication Security ### 1. Use Established Authentication Libraries Never roll your own authentication. Use battle-tested libraries: ```typescript // auth.ts import { betterAuth } from 'better-auth'; export const auth = betterAuth({ emailAndPassword: { enabled: true, minPasswordLength: 12 }, socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET! } } }); ``` ### 2. Implement Secure Session Management ```typescript // auth.ts import { betterAuth } from 'better-auth'; export const auth = betterAuth({ trustedOrigins: [process.env.NEXT_PUBLIC_APP_URL!], session: { expiresIn: 60 * 60 * 24 * 30, updateAge: 60 * 60 * 24 } }); ``` ### 3. Rate Limit Authentication Endpoints Protect against brute force attacks: ```typescript // auth.ts import { betterAuth } from 'better-auth'; export const auth = betterAuth({ rateLimit: { enabled: true, window: 60, max: 10 } }); ``` Better Auth applies stricter built-in limits to sensitive endpoints. Its default in-memory storage is not shared reliably across serverless instances, so configure database, secondary, or custom storage for distributed production deployments. ### 4. Secure Password Storage If handling passwords directly, use proper hashing: ```typescript import { hash, verify } from '@node-rs/argon2'; // Hashing with Argon2id (recommended over bcrypt) export async function hashPassword(password: string): Promise { return hash(password, { memoryCost: 19456, timeCost: 2, outputLen: 32, parallelism: 1 }); } export async function verifyPassword( password: string, hashedPassword: string ): Promise { return verify(hashedPassword, password); } ``` ## Authorization & Access Control ### 1. Always Verify Permissions Server-Side Never trust client-side authorization checks: ```typescript // BAD: Client-side check only if (user.role === 'admin') { return ; } // GOOD: Server-side verification export async function AdminPage() { const session = await auth(); // Verify from database, not session const user = await db.user.findUnique({ where: { id: session?.user?.id }, select: { role: true } }); if (user?.role !== 'ADMIN') { redirect('/unauthorized'); } return ; } ``` ### 2. Implement Resource-Level Authorization Check ownership for every resource access: ```typescript // actions/documents.ts export async function deleteDocument(documentId: string) { const session = await auth(); if (!session?.user?.id) { throw new Error('Unauthorized'); } // Verify ownership before action const document = await db.document.findFirst({ where: { id: documentId, userId: session.user.id // Critical: ownership check } }); if (!document) { throw new Error('Document not found'); // Same error as not found } await db.document.delete({ where: { id: documentId } }); } ``` ### 3. Use RBAC for Complex Permissions ```typescript // lib/permissions.ts type Permission = | 'document:create' | 'document:read' | 'document:update' | 'document:delete' | 'user:manage'; const rolePermissions: Record = { admin: [ 'document:create', 'document:read', 'document:update', 'document:delete', 'user:manage' ], editor: ['document:create', 'document:read', 'document:update'], viewer: ['document:read'] }; export function checkPermission(role: string, permission: Permission): boolean { return rolePermissions[role]?.includes(permission) ?? false; } // Usage in server action export async function updateDocument(id: string, data: UpdateData) { const session = await auth(); const membership = await getMembership(session?.user?.id); if (!checkPermission(membership.role, 'document:update')) { throw new Error('Insufficient permissions'); } // Proceed with update } ``` ## Input Validation & Sanitization ### 1. Validate All Input with Zod ```typescript import { z } from 'zod'; const createUserSchema = z.object({ email: z.string().email().max(255), name: z.string().min(1).max(100), // Prevent common injection patterns website: z .string() .url() .optional() .refine((url) => !url || !url.includes('javascript:'), { message: 'Invalid URL protocol' }) }); export async function createUser(formData: FormData) { const validated = createUserSchema.safeParse({ email: formData.get('email'), name: formData.get('name'), website: formData.get('website') }); if (!validated.success) { return { error: validated.error.flatten() }; } // Safe to use validated.data } ``` ### 2. Sanitize HTML Content If allowing rich text, sanitize it: ```typescript import DOMPurify from 'isomorphic-dompurify'; export function sanitizeHtml(dirty: string): string { return DOMPurify.sanitize(dirty, { ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'], ALLOWED_ATTR: ['href', 'target'], ALLOW_DATA_ATTR: false }); } ``` ### 3. Parameterize Database Queries Prisma and Drizzle protect against SQL injection by default, but be careful with raw queries: ```typescript // BAD: String concatenation const users = await db.$queryRaw` SELECT * FROM users WHERE email = '${email}' `; // GOOD: Parameterized query const users = await db.$queryRaw` SELECT * FROM users WHERE email = ${email} `; ``` ## API Security ### 1. Implement CORS Properly ```typescript // next.config.js module.exports = { async headers() { return [ { source: '/api/:path*', headers: [ { key: 'Access-Control-Allow-Origin', value: process.env.ALLOWED_ORIGIN || 'https://yourdomain.com' }, { key: 'Access-Control-Allow-Methods', value: 'GET, POST, PUT, DELETE, OPTIONS' }, { key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' } ] } ]; } }; ``` ### 2. Validate API Request Origins ```typescript // app/api/sensitive/route.ts import { headers } from 'next/headers'; export async function POST(request: Request) { const requestHeaders = await headers(); const origin = requestHeaders.get('origin'); const allowedOrigins = [ 'https://yourdomain.com', 'https://app.yourdomain.com' ]; if (!origin || !allowedOrigins.includes(origin)) { return Response.json({ error: 'Forbidden' }, { status: 403 }); } // Process request } ``` ### 3. Use CSRF Protection Server Actions in Next.js have built-in CSRF protection, but for custom APIs: ```typescript // lib/csrf.ts import { randomBytes } from 'crypto'; import { cookies } from 'next/headers'; export async function generateCsrfToken(): Promise { const token = randomBytes(32).toString('hex'); const cookieStore = await cookies(); cookieStore.set('csrf-token', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict' }); return token; } export async function validateCsrfToken(token: string): Promise { const cookieStore = await cookies(); const storedToken = cookieStore.get('csrf-token')?.value; return token === storedToken; } ``` ## Data Protection ### 1. Encrypt Sensitive Data at Rest ```typescript import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY!; // 32 bytes const ALGORITHM = 'aes-256-gcm'; export function encrypt(text: string): string { const iv = randomBytes(16); const cipher = createCipheriv( ALGORITHM, Buffer.from(ENCRYPTION_KEY, 'hex'), iv ); let encrypted = cipher.update(text, 'utf8', 'hex'); encrypted += cipher.final('hex'); const authTag = cipher.getAuthTag(); return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`; } export function decrypt(encryptedData: string): string { const [ivHex, authTagHex, encrypted] = encryptedData.split(':'); const decipher = createDecipheriv( ALGORITHM, Buffer.from(ENCRYPTION_KEY, 'hex'), Buffer.from(ivHex, 'hex') ); decipher.setAuthTag(Buffer.from(authTagHex, 'hex')); let decrypted = decipher.update(encrypted, 'hex', 'utf8'); decrypted += decipher.final('utf8'); return decrypted; } ``` ### 2. Never Log Sensitive Data ```typescript // Even better: Use structured logging with redaction import pino from 'pino'; // BAD console.log('User login:', { email, password }); // GOOD console.log('User login:', { email, password: '[REDACTED]' }); const logger = pino({ redact: ['password', 'token', 'apiKey', '*.password', '*.token'] }); ``` ### 3. Implement Data Retention Policies ```typescript // cron/cleanup.ts export async function cleanupOldData() { const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // Delete old audit logs await db.auditLog.deleteMany({ where: { createdAt: { lt: thirtyDaysAgo } } }); // Anonymize deleted user data await db.user.updateMany({ where: { deletedAt: { lt: thirtyDaysAgo } }, data: { email: db.raw("CONCAT(id, '@deleted.local')"), name: 'Deleted User' } }); } ``` ## Security Headers ### Configure Security Headers ```typescript // next.config.js const securityHeaders = [ { key: 'X-DNS-Prefetch-Control', value: 'on' }, { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' }, { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, { key: 'Content-Security-Policy', value: ContentSecurityPolicy.replace(/\s{2,}/g, ' ').trim() } ]; const ContentSecurityPolicy = ` default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data: https:; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests; `; module.exports = { async headers() { return [ { source: '/:path*', headers: securityHeaders } ]; } }; ``` ## Environment Variable Security ### 1. Never Expose Server Secrets to Client ```typescript // DANGEROUS: Accessible on client NEXT_PUBLIC_DATABASE_URL=... // Never do this! // SAFE: Server-only DATABASE_URL=... STRIPE_SECRET_KEY=... ``` ### 2. Validate Environment Variables ```typescript // env.ts import { z } from 'zod'; const envSchema = z.object({ DATABASE_URL: z.string().url(), AUTH_SECRET: z.string().min(32), STRIPE_SECRET_KEY: z.string().startsWith('sk_'), STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_') }); export const env = envSchema.parse(process.env); ``` ## Security Monitoring ### 1. Log Security Events ```typescript // lib/audit.ts export async function logSecurityEvent(event: { type: 'login' | 'logout' | 'permission_denied' | 'suspicious_activity'; userId?: string; ip: string; userAgent: string; details: Record; }) { await db.securityLog.create({ data: { type: event.type, userId: event.userId, ip: event.ip, userAgent: event.userAgent, details: event.details, timestamp: new Date() } }); // Alert on suspicious activity if (event.type === 'suspicious_activity') { await sendAlertToSecurityTeam(event); } } ``` ### 2. Implement Anomaly Detection ```typescript // Check for suspicious patterns export async function detectAnomalies(userId: string, ip: string) { const recentLogins = await db.securityLog.count({ where: { userId, type: 'login', timestamp: { gte: new Date(Date.now() - 60 * 60 * 1000) } // Last hour } }); if (recentLogins > 10) { await logSecurityEvent({ type: 'suspicious_activity', userId, ip, userAgent: '', details: { reason: 'excessive_logins', count: recentLogins } }); } } ``` ## Security Checklist Before deploying, verify: - [ ] Authentication uses an established, maintained library such as Better Auth - [ ] All routes verify authentication server-side - [ ] Resource access checks ownership - [ ] Input validation on all user data - [ ] SQL injection prevented (parameterized queries) - [ ] XSS prevented (output encoding, CSP) - [ ] CSRF protection enabled - [ ] Security headers configured - [ ] Sensitive data encrypted at rest - [ ] Environment variables validated - [ ] Rate limiting on auth endpoints - [ ] Security logging implemented - [ ] Dependencies up to date ## Conclusion Security in SaaS isn't optional—it's the foundation of customer trust. The practices in this guide provide defense in depth: 1. **Authentication**: Use established libraries, secure sessions 2. **Authorization**: Server-side checks, ownership verification 3. **Input Validation**: Zod schemas, sanitization 4. **API Security**: CORS, CSRF, rate limiting 5. **Data Protection**: Encryption, retention policies 6. **Monitoring**: Audit logs, anomaly detection Security is a continuous process. Regularly audit your code, keep dependencies updated, and stay informed about new vulnerabilities. --- _Want a maintained foundation? [Achromatic](/) includes Better Auth and documented server-side authorization patterns. Add product-specific rate limits and security controls for your own threat model before launch._ --- ## Deploying Your Next.js SaaS to Production: Complete Guide **URL**: https://www.achromatic.dev/blog/deploy-nextjs-saas-production **Description**: A comprehensive guide to deploying Next.js SaaS applications to production. Covers Vercel, Railway, AWS, database setup, environment variables, monitoring, and production checklist. **Published**: 2025-04-12 You've built your SaaS application. Now it's time to deploy it to production where real users can access it. This guide covers everything from choosing a hosting provider to monitoring your live application. ## Choosing a Hosting Provider ### Vercel: The Default Choice Vercel created Next.js and offers the most optimized deployment experience: **Pros:** - Zero-config deployment - Edge network with 40+ regions - Automatic preview deployments - Built-in analytics and monitoring - Best-in-class caching **Cons:** - Can get expensive at scale - Some advanced features require Pro plan - Vendor lock-in for some features **Best for:** Most SaaS applications, especially those starting out. ### Railway: Simple and Affordable Railway offers a straightforward deployment experience with predictable pricing: **Pros:** - Simple pricing ($5/month + usage) - Great developer experience - Built-in PostgreSQL and Redis - No cold starts **Cons:** - Smaller edge network than Vercel - Fewer built-in analytics tools - Less documentation **Best for:** Cost-conscious teams, applications with high compute needs. ### AWS (Amplify or ECS) AWS offers the most control and scalability: **Pros:** - Maximum control and customization - Enterprise-grade security - Global infrastructure - Cost-effective at scale **Cons:** - Steep learning curve - Complex configuration - More operational overhead **Best for:** Enterprise applications, teams with DevOps expertise. ### Comparison Table | Feature | Vercel | Railway | AWS Amplify | | ------------------ | -------- | -------- | ----------- | | **Setup Time** | 5 min | 10 min | 30+ min | | **Minimum Cost** | $0 | $5/mo | $0 | | **Auto Scaling** | Yes | Yes | Yes | | **Edge Functions** | Yes | No | Yes | | **Database** | Separate | Built-in | Separate | | **Custom Domains** | Yes | Yes | Yes | ## Pre-Deployment Checklist Before deploying, ensure you've completed these steps: ### 1. Environment Variables Create a `.env.production` template and verify all variables: ```bash # Database DATABASE_URL=postgresql://... # Authentication AUTH_SECRET=min-32-characters AUTH_URL=https://yourdomain.com # Stripe STRIPE_SECRET_KEY=sk_live_... STRIPE_WEBHOOK_SECRET=whsec_... NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_... # Email RESEND_API_KEY=re_... # Monitoring SENTRY_DSN=https://... ``` ### 2. Build Verification Test your production build locally: ```bash # Build the application pnpm build # Start in production mode pnpm start # Verify all pages load correctly # Test authentication flow # Test Stripe checkout ``` ### 3. Database Migration Ensure migrations are ready: ```bash # Generate migration from schema changes pnpm prisma migrate dev # Deploy migrations to production pnpm prisma migrate deploy ``` ## Deploying to Vercel ### Step 1: Connect Repository 1. Go to [vercel.com](https://vercel.com) and sign in 2. Click "Add New Project" 3. Import your GitHub repository 4. Vercel auto-detects Next.js settings ### Step 2: Configure Environment Variables Add your production environment variables: ```bash # In Vercel Dashboard → Settings → Environment Variables DATABASE_URL=postgresql://... AUTH_SECRET=your-production-secret STRIPE_SECRET_KEY=sk_live_... ``` ### Step 3: Configure Build Settings For most Next.js apps, defaults work. For monorepos: ```json // vercel.json { "buildCommand": "pnpm build", "outputDirectory": ".next", "installCommand": "pnpm install" } ``` ### Step 4: Deploy Push to your main branch to trigger deployment: ```bash git push origin main ``` ### Step 5: Configure Domain 1. Go to Project Settings → Domains 2. Add your custom domain 3. Update DNS records as instructed 4. Wait for SSL certificate provisioning ## Deploying to Railway ### Step 1: Create Project ```bash # Install Railway CLI npm install -g @railway/cli # Login railway login # Create new project railway init ``` ### Step 2: Add Services ```bash # Add PostgreSQL railway add postgresql # Link to your project railway link ``` ### Step 3: Configure Environment ```bash # Set environment variables railway variables set DATABASE_URL=${{Postgres.DATABASE_URL}} railway variables set AUTH_SECRET=your-secret railway variables set STRIPE_SECRET_KEY=sk_live_... ``` ### Step 4: Deploy ```bash # Deploy current directory railway up ``` ### Step 5: Add Custom Domain 1. Go to Railway Dashboard → Settings → Domains 2. Add your domain 3. Configure DNS CNAME record ## Database Setup ### Using Neon (Serverless Postgres) Neon offers serverless PostgreSQL optimized for Next.js: ```bash # 1. Create database at neon.tech # 2. Get connection string # 3. Add to environment variables DATABASE_URL=postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/neondb?sslmode=require ``` Configure connection pooling: ```typescript // lib/db.ts import { PrismaClient } from '@prisma/client'; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; export const db = globalForPrisma.prisma ?? new PrismaClient({ log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'] }); if (process.env.NODE_ENV !== 'production') { globalForPrisma.prisma = db; } ``` ### Using Supabase Supabase provides PostgreSQL with additional features: ```bash # Connection for Prisma DATABASE_URL=postgresql://postgres:[PASSWORD]@db.[PROJECT].supabase.co:5432/postgres # Connection pooling (recommended) DATABASE_URL=postgresql://postgres:[PASSWORD]@db.[PROJECT].supabase.co:6543/postgres?pgbouncer=true ``` ## Setting Up CI/CD ### GitHub Actions Pipeline Create a comprehensive CI/CD pipeline: ```yaml # .github/workflows/ci.yml name: CI/CD on: push: branches: [main] pull_request: branches: [main] env: DATABASE_URL: ${{ secrets.DATABASE_URL }} jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v2 with: version: 8 - uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm' - run: pnpm install - run: pnpm lint - run: pnpm typecheck test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v2 with: version: 8 - uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm' - run: pnpm install - run: pnpm test build: runs-on: ubuntu-latest needs: [lint, test] steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v2 with: version: 8 - uses: actions/setup-node@v4 with: node-version: 20 cache: 'pnpm' - run: pnpm install - run: pnpm build ``` ## Monitoring and Observability ### Setting Up Sentry Add error tracking with Sentry: ```bash pnpm add @sentry/nextjs npx @sentry/wizard@latest -i nextjs ``` Configure Sentry: ```typescript // sentry.client.config.ts import * as Sentry from '@sentry/nextjs'; Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, tracesSampleRate: 0.1, replaysSessionSampleRate: 0.1, replaysOnErrorSampleRate: 1.0, integrations: [ Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }) ] }); ``` ### Setting Up Logging Use structured logging with Pino: ```typescript // lib/logger.ts import pino from 'pino'; export const logger = pino({ level: process.env.LOG_LEVEL || 'info', transport: process.env.NODE_ENV === 'development' ? { target: 'pino-pretty' } : undefined, redact: ['password', 'token', 'authorization'] }); // Usage logger.info({ userId, action: 'login' }, 'User logged in'); logger.error({ error, userId }, 'Payment failed'); ``` ### Uptime Monitoring Set up uptime monitoring with services like: - **Better Uptime**: Great free tier, incident management - **Checkly**: Synthetic monitoring, API checks - **Vercel Analytics**: Built-in if using Vercel ## Production Checklist ### Security - [ ] HTTPS enforced on all routes - [ ] Security headers configured (CSP, HSTS, etc.) - [ ] API rate limiting enabled - [ ] Sensitive data encrypted - [ ] Environment variables secured - [ ] Database connections use SSL ### Performance - [ ] Images optimized (WebP, proper sizing) - [ ] Fonts preloaded - [ ] Critical CSS inlined - [ ] Bundle size analyzed - [ ] Caching headers configured - [ ] Edge functions for latency-sensitive routes ### Reliability - [ ] Error tracking configured (Sentry) - [ ] Logging set up - [ ] Database backups automated - [ ] Uptime monitoring active - [ ] Alerting configured - [ ] Rollback strategy defined ### Compliance - [ ] Privacy policy in place - [ ] Terms of service published - [ ] Cookie consent implemented - [ ] Data retention policy defined - [ ] GDPR/CCPA compliance if applicable ## Post-Deployment ### Monitor First 24 Hours Watch for: - Error rates in Sentry - Performance metrics in hosting dashboard - Database connection issues - Failed authentication attempts - Stripe webhook failures ### Set Up Alerts Configure alerts for: - Error rate spikes - Response time degradation - Database connection failures - Payment failures - SSL certificate expiry ### Document Runbooks Create runbooks for common incidents: ```markdown ## High Error Rate 1. Check Sentry for error details 2. Review recent deployments 3. Check database connectivity 4. Verify third-party API status 5. Rollback if necessary: `vercel rollback` ## Database Connection Issues 1. Check connection pool usage 2. Verify DATABASE_URL is correct 3. Check database provider status 4. Scale connection pool if needed ``` ## Scaling Considerations As your SaaS grows, consider: ### Database - Add read replicas for read-heavy workloads - Implement connection pooling (PgBouncer) - Consider caching layer (Redis) ### Compute - Enable auto-scaling - Use edge functions for global latency - Consider dedicated instances for predictable workloads ### CDN - Cache static assets aggressively - Use image CDN for user-uploaded content - Implement stale-while-revalidate patterns ## Conclusion Deploying a Next.js SaaS to production involves: 1. **Choosing the right platform** based on your needs 2. **Proper configuration** of environment and build settings 3. **Database setup** with connection pooling 4. **CI/CD pipelines** for reliable deployments 5. **Monitoring** for visibility into production 6. **Ongoing maintenance** and scaling Start with Vercel or Railway for simplicity, and migrate to more complex setups as your needs grow. The most important thing is shipping—you can always optimize later. --- _Building a SaaS? [Achromatic](/) comes with deployment guides for Vercel, Railway, and Docker, plus built-in Sentry integration and production-ready configurations._ --- ## Next.js vs Other Frameworks for SaaS: A Practical Comparison **URL**: https://www.achromatic.dev/blog/nextjs-vs-other-frameworks-saas **Description**: Comparing Next.js with Remix, SvelteKit, Nuxt, and Rails for building SaaS applications. Learn which framework best fits your project based on real-world factors. **Published**: 2025-04-10 Choosing the right framework for your SaaS application is one of the most consequential technical decisions you'll make. It affects your development speed, hiring pool, performance, and long-term maintainability. In this comparison, we'll evaluate **Next.js** against its main competitors—**Remix**, **SvelteKit**, **Nuxt**, and **Ruby on Rails**—specifically for building SaaS applications. ## The Contenders | Framework | Language | Released | GitHub Stars | | ----------------- | --------------------- | -------- | ------------ | | **Next.js** | JavaScript/TypeScript | 2016 | 120k+ | | **Remix** | JavaScript/TypeScript | 2021 | 28k+ | | **SvelteKit** | JavaScript/TypeScript | 2020 | 18k+ | | **Nuxt** | JavaScript/TypeScript | 2016 | 52k+ | | **Ruby on Rails** | Ruby | 2004 | 55k+ | ## Next.js: The Industry Standard Next.js has become the default choice for React developers building production applications. Here's why: ### Strengths **1. React Server Components (RSC)** Next.js pioneered RSC in production, enabling server-first architecture that dramatically improves performance: ```tsx // Server Component - runs on server, zero client JS async function DashboardStats() { const stats = await db.stats.findMany(); return ; } // Client Component - only when needed ('use client'); function InteractiveChart({ data }) { const [filter, setFilter] = useState('all'); return ( ); } ``` **2. Massive Ecosystem** - 10,000+ npm packages built specifically for Next.js - Largest community of any React framework - First-class support from Vercel, but deploys anywhere - Extensive documentation and tutorials **3. App Router Architecture** The App Router provides intuitive file-based routing with powerful features: ``` app/ ├── layout.tsx # Root layout ├── page.tsx # Homepage ├── dashboard/ │ ├── layout.tsx # Dashboard layout with sidebar │ ├── page.tsx # /dashboard │ ├── settings/ │ │ └── page.tsx # /dashboard/settings │ └── [teamId]/ │ └── page.tsx # /dashboard/acme-corp ``` **4. Built-in Optimizations** - Automatic image optimization - Font optimization - Script loading strategies - Prefetching and caching ### Weaknesses - Learning curve for RSC mental model - Can feel complex for simple applications - Vercel-optimized (though works elsewhere) - Frequent major changes between versions ### Best For - Teams already using React - Applications requiring SEO and performance - Projects that may scale significantly - SaaS with complex, data-heavy dashboards --- ## Remix: Web Standards First Remix takes a "use the platform" approach, embracing web standards over framework-specific patterns. ### Strengths **1. Progressive Enhancement** Forms work without JavaScript, then enhance when JS loads: ```tsx export async function action({ request }: ActionFunctionArgs) { const formData = await request.formData(); const email = formData.get('email'); await subscribe(email); return redirect('/thank-you'); } export default function Newsletter() { return (
); } ``` **2. Nested Routes with Data Loading** Each route segment loads its own data in parallel: ```tsx // routes/dashboard.tsx - loads user data export async function loader({ request }: LoaderFunctionArgs) { const user = await getUser(request); return json({ user }); } // routes/dashboard.settings.tsx - loads settings (parallel) export async function loader({ request }: LoaderFunctionArgs) { const settings = await getSettings(request); return json({ settings }); } ``` **3. Error Boundaries Per Route** Errors are isolated to route segments, not the entire app. ### Weaknesses - Smaller ecosystem than Next.js - Fewer hosting-specific optimizations - Less momentum after Shopify acquisition - RSC support still evolving ### Best For - Teams valuing web standards - Applications requiring robust offline support - Projects where progressive enhancement matters - Smaller teams who want less "magic" --- ## SvelteKit: Performance Pioneer SvelteKit compiles your code away, producing minimal JavaScript bundles. ### Strengths **1. Compile-Time Magic** Svelte converts components to vanilla JavaScript at build time: ```svelte ``` This produces ~2KB instead of 40KB+ for equivalent React code. **2. Built-in State Management** No need for Redux or Zustand—stores are built in: ```typescript // stores/user.ts import { writable } from 'svelte/store'; export const user = writable(null); // Component import { user } from '$lib/stores/user'; $: console.log($user); // Reactive subscription ``` **3. Excellent DX** Less boilerplate, more intuitive syntax, faster HMR. ### Weaknesses - Smallest ecosystem of the bunch - Harder to hire Svelte developers - Fewer SaaS-specific resources - TypeScript support improving but not as mature ### Best For - Performance-critical applications - Small teams who can learn Svelte - Projects where bundle size is crucial - Developers who prefer less abstraction --- ## Nuxt: The Vue.js Answer If your team prefers Vue.js, Nuxt is the obvious choice. ### Strengths **1. Auto-Imports** Components, composables, and utilities are auto-imported: ```vue ``` **2. Modules Ecosystem** One-line integrations for common needs: ```typescript // nuxt.config.ts export default defineNuxtConfig({ modules: ['@nuxtjs/tailwindcss', '@sidebase/nuxt-auth', '@pinia/nuxt'] }); ``` **3. Vue 3 Composition API** Modern, type-safe patterns throughout: ```typescript const { data, pending, error } = await useAsyncData('users', () => $fetch('/api/users') ); ``` ### Weaknesses - Vue has smaller market share than React - Fewer enterprise-level resources - Some modules lag behind Next.js equivalents - Less community momentum recently ### Best For - Teams already using Vue.js - Developers who prefer Vue's template syntax - Projects needing strong TypeScript support - Laravel/PHP shops adding a frontend --- ## Ruby on Rails: The Proven Veteran Rails isn't JavaScript, but it's still powering major SaaS companies (Shopify, GitHub, Basecamp). ### Strengths **1. Convention Over Configuration** Rails makes decisions for you, reducing bikeshedding: ```ruby # One command creates model, migration, controller, views rails generate scaffold User name:string email:string # Database migration runs automatically rails db:migrate ``` **2. Mature Ecosystem** 20 years of gems (packages) for every SaaS need: ```ruby # Gemfile gem 'devise' # Authentication gem 'pundit' # Authorization gem 'stripe' # Billing gem 'sidekiq' # Background jobs gem 'actionmailer' # Emails ``` **3. Hotwire for Interactivity** Modern, reactive UIs without heavy JavaScript: ```erb <%= turbo_stream_from "notifications" %>
<%= render @notifications %>
``` ### Weaknesses - Not JavaScript (different hiring pool) - Performance requires more optimization - Modern frontend integration can be clunky - Ruby language is less popular now ### Best For - Rapid prototyping and MVPs - Teams with Ruby experience - Applications where backend complexity outweighs frontend - Companies prioritizing developer productivity over performance --- ## Head-to-Head Comparison | Factor | Next.js | Remix | SvelteKit | Nuxt | Rails | | ---------------------- | --------- | --------- | --------- | --------- | ------ | | **Performance** | Excellent | Excellent | Best | Good | Good | | **Bundle Size** | Medium | Small | Smallest | Medium | N/A | | **Ecosystem** | Largest | Growing | Small | Large | Mature | | **Hiring Pool** | Largest | Medium | Small | Medium | Medium | | **Learning Curve** | Medium | Low | Low | Low | Medium | | **TypeScript** | Excellent | Excellent | Good | Excellent | N/A | | **SaaS Resources** | Most | Some | Few | Some | Many | | **Deployment Options** | Many | Many | Many | Many | Fewer | ## Making the Decision Choose **Next.js** if: - You're building a React-based SaaS - You need the largest ecosystem and community - SEO and performance are priorities - You want the most starter kit options Choose **Remix** if: - Progressive enhancement matters - You prefer web standards over abstraction - You want simpler mental models - Your team values "use the platform" Choose **SvelteKit** if: - Bundle size is critical - Your team can invest in learning Svelte - You want the best developer experience - Performance is the top priority Choose **Nuxt** if: - Your team already knows Vue.js - You want Vue's template syntax - Auto-imports appeal to you - You're integrating with a PHP backend Choose **Rails** if: - You have Ruby expertise - You're building an MVP quickly - Backend complexity dominates - You prefer convention over configuration ## Our Recommendation For most SaaS applications in 2025, **Next.js is the safest choice**. Here's why: 1. **Largest talent pool**: Most React developers can use Next.js immediately 2. **Best ecosystem**: More auth libraries, billing integrations, and UI kits 3. **Proven at scale**: Powers major SaaS products (Notion, Loom, Hulu) 4. **Flexible deployment**: Vercel, AWS, Railway, or self-hosted 5. **Future-proof**: Server Components are the direction React is heading That said, any of these frameworks can build a successful SaaS. The best framework is the one your team can ship with. --- _Ready to build your SaaS with Next.js? [Achromatic](/) gives you authentication, billing, and multi-tenancy out of the box—so you can focus on what makes your product unique._ --- ## Integrating Stripe with Auth.js in Next.js: Legacy Guide **URL**: https://www.achromatic.dev/blog/stripe-authjs-integration **Description**: A legacy reference for connecting Stripe billing with Auth.js in an existing Next.js application. Current Achromatic starter kits use Better Auth. **Published**: 2025-03-25 **Updated**: 2026-07-19 Connecting authentication with billing is one of the most critical integrations in a SaaS application. Users need to sign up, subscribe to a plan, and access features based on their subscription status. In this guide, we'll build a complete integration between **Auth.js** (NextAuth.js v5) and **Stripe** in Next.js, covering customer creation, subscription management, and feature gating. > **Legacy guide:** This article is preserved for teams maintaining an existing Auth.js application. The current Achromatic Pro Prisma and Pro Drizzle starter kits use Better Auth. For a new project, start with the [current authentication documentation](/docs/starter-kits/pro-nextjs-prisma/authentication). ## Architecture Overview Here's how the integration flows: ``` User Signup → Create Stripe Customer → Store Customer ID ↓ User Subscribes → Stripe Checkout → Webhook Updates DB ↓ User Accesses App → Check Subscription → Grant/Deny Features ``` ## Prerequisites Install the required packages: ```bash pnpm add next-auth@beta @auth/prisma-adapter stripe @stripe/stripe-js ``` Set up your environment variables: ```bash # .env.local AUTH_SECRET=your-auth-secret # Stripe STRIPE_SECRET_KEY=sk_test_... STRIPE_WEBHOOK_SECRET=whsec_... NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... ``` ## Step 1: Database Schema Extend your user model to store Stripe information: ```prisma // prisma/schema.prisma model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? // Stripe fields stripeCustomerId String? @unique stripeSubscriptionId String? stripePriceId String? stripeCurrentPeriodEnd DateTime? accounts Account[] sessions Session[] @@map("users") } model Account { id String @id @default(cuid()) userId String type String provider String providerAccountId String refresh_token String? access_token String? expires_at Int? token_type String? scope String? id_token String? session_state String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, providerAccountId]) @@map("accounts") } model Session { id String @id @default(cuid()) sessionToken String @unique userId String expires DateTime user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("sessions") } ``` Run the migration: ```bash pnpm prisma migrate dev ``` ## Step 2: Stripe Client Setup Create a singleton Stripe instance: ```typescript // lib/stripe.ts import Stripe from 'stripe'; let stripeClient: Stripe | null = null; export function getStripe(): Stripe { if (stripeClient) return stripeClient; const secretKey = process.env.STRIPE_SECRET_KEY; if (!secretKey) { throw new Error('STRIPE_SECRET_KEY is not set'); } stripeClient = new Stripe(secretKey); return stripeClient; } ``` ## Step 3: Create Stripe Customer on Signup Hook into Auth.js events to create a Stripe customer when a user signs up: ```typescript // auth.ts import { PrismaAdapter } from '@auth/prisma-adapter'; import NextAuth from 'next-auth'; import GitHub from 'next-auth/providers/github'; import Google from 'next-auth/providers/google'; import { db } from '@/lib/db'; import { getStripe } from '@/lib/stripe'; export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: PrismaAdapter(db), providers: [GitHub, Google], events: { createUser: async ({ user }) => { // Create Stripe customer when user signs up if (user.email) { const stripe = getStripe(); const customer = await stripe.customers.create({ email: user.email, name: user.name ?? undefined, metadata: { userId: user.id } }); // Save Stripe customer ID to database await db.user.update({ where: { id: user.id }, data: { stripeCustomerId: customer.id } }); } } }, callbacks: { session: async ({ session, user }) => { if (session.user) { session.user.id = user.id; // Include subscription status in session const dbUser = await db.user.findUnique({ where: { id: user.id }, select: { stripeSubscriptionId: true, stripePriceId: true, stripeCurrentPeriodEnd: true } }); if (dbUser) { session.user.subscriptionId = dbUser.stripeSubscriptionId; session.user.priceId = dbUser.stripePriceId; session.user.subscriptionEnd = dbUser.stripeCurrentPeriodEnd; } } return session; } } }); ``` Extend the session types: ```typescript // types/next-auth.d.ts import { DefaultSession } from 'next-auth'; declare module 'next-auth' { interface Session { user: { id: string; subscriptionId?: string | null; priceId?: string | null; subscriptionEnd?: Date | null; } & DefaultSession['user']; } } ``` ## Step 4: Checkout Session Creation Create a server action to generate Stripe Checkout sessions: ```typescript // actions/stripe.ts 'use server'; import { redirect } from 'next/navigation'; import { auth } from '@/auth'; import { db } from '@/lib/db'; import { getStripe } from '@/lib/stripe'; import { absoluteUrl } from '@/lib/utils'; export async function createCheckoutSession(priceId: string) { const session = await auth(); if (!session?.user?.id) { redirect('/login'); } const stripe = getStripe(); // Get or create Stripe customer const user = await db.user.findUnique({ where: { id: session.user.id }, select: { stripeCustomerId: true, email: true } }); if (!user) { throw new Error('User not found'); } let customerId = user.stripeCustomerId; // Create customer if doesn't exist (edge case) if (!customerId && user.email) { const customer = await stripe.customers.create({ email: user.email, metadata: { userId: session.user.id } }); customerId = customer.id; await db.user.update({ where: { id: session.user.id }, data: { stripeCustomerId: customerId } }); } // Create checkout session const checkoutSession = await stripe.checkout.sessions.create({ customer: customerId!, mode: 'subscription', payment_method_types: ['card'], line_items: [ { price: priceId, quantity: 1 } ], success_url: absoluteUrl('/dashboard?success=true'), cancel_url: absoluteUrl('/pricing?canceled=true'), metadata: { userId: session.user.id } }); redirect(checkoutSession.url!); } export async function createBillingPortalSession() { const session = await auth(); if (!session?.user?.id) { redirect('/login'); } const user = await db.user.findUnique({ where: { id: session.user.id }, select: { stripeCustomerId: true } }); if (!user?.stripeCustomerId) { throw new Error('No billing account found'); } const stripe = getStripe(); const portalSession = await stripe.billingPortal.sessions.create({ customer: user.stripeCustomerId, return_url: absoluteUrl('/dashboard/billing') }); redirect(portalSession.url); } ``` ## Step 5: Webhook Handler Process Stripe webhooks to sync subscription status: ```typescript // app/api/webhooks/stripe/route.ts import { headers } from 'next/headers'; import { NextResponse } from 'next/server'; import Stripe from 'stripe'; import { db } from '@/lib/db'; import { getStripe } from '@/lib/stripe'; export async function POST(request: Request) { const body = await request.text(); const requestHeaders = await headers(); const signature = requestHeaders.get('stripe-signature'); if (!signature) { return NextResponse.json({ error: 'Missing signature' }, { status: 400 }); } const stripe = getStripe(); let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( body, signature, process.env.STRIPE_WEBHOOK_SECRET! ); } catch (error) { console.error('Webhook signature verification failed:', error); return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }); } try { switch (event.type) { case 'checkout.session.completed': { const session = event.data.object as Stripe.Checkout.Session; await handleCheckoutCompleted(session); break; } case 'customer.subscription.updated': case 'customer.subscription.created': { const subscription = event.data.object as Stripe.Subscription; await handleSubscriptionChange(subscription); break; } case 'customer.subscription.deleted': { const subscription = event.data.object as Stripe.Subscription; await handleSubscriptionDeleted(subscription); break; } case 'invoice.payment_failed': { const invoice = event.data.object as Stripe.Invoice; await handlePaymentFailed(invoice); break; } } return NextResponse.json({ received: true }); } catch (error) { console.error('Webhook handler error:', error); return NextResponse.json( { error: 'Webhook handler failed' }, { status: 500 } ); } } async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { const userId = session.metadata?.userId; const subscriptionId = session.subscription as string; if (!userId || !subscriptionId) { console.error('Missing userId or subscriptionId'); return; } const stripe = getStripe(); // Fetch full subscription details const subscription = await stripe.subscriptions.retrieve(subscriptionId); await db.user.update({ where: { id: userId }, data: { stripeSubscriptionId: subscription.id, stripePriceId: subscription.items.data[0]?.price.id, stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000) } }); } async function handleSubscriptionChange(subscription: Stripe.Subscription) { const customerId = subscription.customer as string; const user = await db.user.findUnique({ where: { stripeCustomerId: customerId } }); if (!user) { console.error('User not found for customer:', customerId); return; } await db.user.update({ where: { id: user.id }, data: { stripeSubscriptionId: subscription.id, stripePriceId: subscription.items.data[0]?.price.id, stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000) } }); } async function handleSubscriptionDeleted(subscription: Stripe.Subscription) { const customerId = subscription.customer as string; await db.user.updateMany({ where: { stripeCustomerId: customerId }, data: { stripeSubscriptionId: null, stripePriceId: null, stripeCurrentPeriodEnd: null } }); } async function handlePaymentFailed(invoice: Stripe.Invoice) { // Optionally send email notification const customerId = invoice.customer as string; console.log('Payment failed for customer:', customerId); } ``` ## Step 6: Subscription Status Helper Create utilities to check subscription status: ```typescript // lib/subscription.ts import { auth } from '@/auth'; import { db } from '@/lib/db'; export type SubscriptionPlan = 'free' | 'pro' | 'enterprise'; const PLAN_PRICE_IDS: Record = { price_pro_monthly: 'pro', price_pro_yearly: 'pro', price_enterprise_monthly: 'enterprise', price_enterprise_yearly: 'enterprise' }; export async function getSubscription() { const session = await auth(); if (!session?.user?.id) { return { plan: 'free' as SubscriptionPlan, isActive: false }; } const user = await db.user.findUnique({ where: { id: session.user.id }, select: { stripePriceId: true, stripeCurrentPeriodEnd: true } }); if (!user?.stripePriceId || !user?.stripeCurrentPeriodEnd) { return { plan: 'free' as SubscriptionPlan, isActive: false }; } const isActive = user.stripeCurrentPeriodEnd > new Date(); const plan = PLAN_PRICE_IDS[user.stripePriceId] || 'free'; return { plan, isActive }; } export async function requireSubscription(minimumPlan: SubscriptionPlan) { const { plan, isActive } = await getSubscription(); const planHierarchy: SubscriptionPlan[] = ['free', 'pro', 'enterprise']; const currentIndex = planHierarchy.indexOf(plan); const requiredIndex = planHierarchy.indexOf(minimumPlan); if (!isActive || currentIndex < requiredIndex) { throw new Error('Subscription required'); } return { plan, isActive }; } ``` ## Step 7: Feature Gating Components Create components to gate features based on subscription: ```tsx // components/subscription-gate.tsx import { getSubscription, SubscriptionPlan } from '@/lib/subscription'; import { UpgradePrompt } from './upgrade-prompt'; type Props = { requiredPlan: SubscriptionPlan; children: React.ReactNode; fallback?: React.ReactNode; }; export async function SubscriptionGate({ requiredPlan, children, fallback }: Props) { const { plan, isActive } = await getSubscription(); const planHierarchy: SubscriptionPlan[] = ['free', 'pro', 'enterprise']; const hasAccess = isActive && planHierarchy.indexOf(plan) >= planHierarchy.indexOf(requiredPlan); if (!hasAccess) { return fallback ?? ; } return <>{children}; } ``` Usage in pages: ```tsx // app/dashboard/analytics/page.tsx import { SubscriptionGate } from '@/components/subscription-gate'; import { AnalyticsDashboard } from './analytics-dashboard'; export default function AnalyticsPage() { return ( ); } ``` ## Step 8: Pricing Page with Checkout Build a pricing page that triggers checkout: ```tsx // app/pricing/page.tsx import { createCheckoutSession } from '@/actions/stripe'; import { auth } from '@/auth'; import { Button } from '@/components/ui/button'; import { getSubscription } from '@/lib/subscription'; const plans = [ { name: 'Pro', priceId: 'price_pro_monthly', price: '$29', features: ['Feature 1', 'Feature 2', 'Feature 3'] }, { name: 'Enterprise', priceId: 'price_enterprise_monthly', price: '$99', features: ['All Pro features', 'Feature 4', 'Feature 5'] } ]; export default async function PricingPage() { const session = await auth(); const { plan: currentPlan } = await getSubscription(); return (
{plans.map((plan) => (

{plan.name}

{plan.price} /month

    {plan.features.map((feature) => (
  • ✓ {feature}
  • ))}
{ 'use server'; await createCheckoutSession(plan.priceId); }} className="mt-6" >
))}
); } ``` ## Testing the Integration 1. **Test webhook locally** with Stripe CLI: ```bash stripe listen --forward-to localhost:3000/api/webhooks/stripe ``` 2. **Use test cards**: - Success: `4242 4242 4242 4242` - Decline: `4000 0000 0000 0002` - Requires auth: `4000 0027 6000 3184` 3. **Verify data flow**: - Sign up → Check Stripe Dashboard for new customer - Subscribe → Check database for subscription fields - Cancel → Verify webhook clears subscription data ## Common Pitfalls 1. **Webhook reliability**: Always use webhook secret verification 2. **Customer ID storage**: Create customer on signup, not checkout 3. **Stale session data**: Refresh subscription status on each request 4. **Missing metadata**: Always pass userId in checkout metadata ## Conclusion Integrating Auth.js with Stripe requires coordinating multiple systems: - User creation triggers Stripe customer creation - Checkout sessions link to authenticated users - Webhooks sync subscription status to your database - Session callbacks expose subscription data to the frontend With this foundation, you can build sophisticated billing features like team subscriptions, usage-based billing, and subscription upgrades. --- _Maintaining an Auth.js application? Use this article as a migration reference. For new projects, the current [Pro Prisma](/docs/starter-kits/pro-nextjs-prisma) and [Pro Drizzle](/docs/starter-kits/pro-nextjs-drizzle) kits use Better Auth and include Stripe billing implementations._ --- ## Analytics **URL**: https://www.achromatic.dev/blog/analytics **Description**: We're excited to introduce our new analytics package, designed to help you track user interactions and gain valuable insights into user behavior. Useful for both marketing and dashboard apps. **Published**: 2025-03-22 The starter kits now include an analytics package to help you track user interactions effortlessly. With built-in support for multiple providers, you can choose the one that fits your needs best. ## Features - **User Identification**: Associate actions with specific users for better tracking. - **Event Tracking**: Capture important user interactions and behaviors. - **Automatic Page Views**: Seamless tracking of Next.js route changes without extra code. ## Provider You can select your preferred analytics provider by uncommenting it in `packages/analytics/provider/index.ts`: ```typescript filename="packages/analytics/provider/index.ts" lineNumbers export { default as AnalyticsProvider } from './console'; // export { default as AnalyticsProvider } from './google-analytics'; // export { default as AnalyticsProvider } from './posthog'; // export { default as AnalyticsProvider } from './umami'; ``` ### Console (Default) Logs events and page views to the console. Ideal for debugging during development. ### Google Analytics Industry-standard analytics tool for tracking page views and events. ```ini filename=".env" lineNumbers NEXT_PUBLIC_ANALYTICS_GA_MEASUREMENT_ID= NEXT_PUBLIC_ANALYTICS_GA_DISABLE_LOCALHOST_TRACKING=false NEXT_PUBLIC_ANALYTICS_GA_DISABLE_PAGE_VIEWS_TRACKING=false ``` ### PostHog A cost-effective, developer-friendly analytics platform with powerful event tracking capabilities. ```ini filename=".env" lineNumbers NEXT_PUBLIC_ANALYTICS_POSTHOG_KEY= NEXT_PUBLIC_ANALYTICS_POSTHOG_HOST=https://us.i.posthog.com ``` ### Umami A lightweight, privacy-focused alternative to Google Analytics. ```ini filename=".env" lineNumbers NEXT_PUBLIC_ANALYTICS_UMAMI_HOST=https://cloud.umami.is/script.js NEXT_PUBLIC_ANALYTICS_UMAMI_WEBSITE_ID= NEXT_PUBLIC_ANALYTICS_UMAMI_DISABLE_LOCALHOST_TRACKING=false ``` ## Usage User Identification: ```tsx filename="client-component.tsx" lineNumbers import { useAnalytics } from '@workspace/analytics/hooks/use-analytics'; const analytics = useAnalytics(); const onClick = () => { analytics.identify('anonymous'); // or user.id }; ``` Event Tracking: ```tsx filename="client-component.tsx" lineNumbers import { useAnalytics } from '@workspace/analytics/hooks/use-analytics'; const analytics = useAnalytics(); const onClick = () => { analytics.trackEvent('buttonClicked', { button: 'addContact' }); }; ``` Or combined: ```tsx filename="client-component.tsx" lineNumbers import { useAnalytics } from '@workspace/analytics/hooks/use-analytics'; const analytics = useAnalytics(); const onClick = () => { analytics.identify('anonymous'); // or user.id analytics.trackEvent('buttonClicked', { button: 'addContact' }); }; ``` ## Page Views Route changes are automatically tracked in the `AnalyticsProvider` (no additional code required). --- For full implementation details, check out the analytics documentation in our starter kits: - [Prisma Starter Kit](/docs/starter-kits/monorepo-next-prisma-authjs) - [Drizzle Starter Kit](/docs/starter-kits/monorepo-next-drizzle-authjs) Ready to add analytics to your SaaS? [Get started with Achromatic](/pricing). --- ## Monitoring **URL**: https://www.achromatic.dev/blog/monitoring **Description**: We integrated monitoring into the monorepo with configurable providers capturing Next.js metrics and errors via instrumentation.ts and global-errors.tsx, with built-in support for manual tracking. **Published**: 2025-03-15 We've introduced monitoring to the monorepo with support for two providers: - **Console** (default) - **Sentry** This integration utilizes `instrumentation.ts` to capture additional Next.js metrics, including requests and errors. Sentry automatically instruments React Server Components (RSC), API routes, and server actions for seamless tracking. ## Switching Monitoring Providers ### Console (Default) To use the console provider, ensure the following configuration in `packages/monitoring/provider/index.ts`: ```typescript filename="packages/monitoring/provider/index.ts" lineNumbers export { default as MonitoringProvider } from './console'; // export { default as MonitoringProvider } from './sentry'; ``` ### Sentry 1. Create a [Sentry](https://sentry.io/welcome/) account. 2. Update `packages/monitoring/provider/index.ts`: ```typescript filename="packages/monitoring/provider/index.ts" lineNumbers // export { default as MonitoringProvider } from './console'; export { default as MonitoringProvider } from './sentry'; ``` 3. Configure the following environment variables in `apps/dashboard/.env`: ```ini filename="apps/dashboard/.env" lineNumbers MONITORING_SENTRY_ORG='your-org-name' MONITORING_SENTRY_PROJECT='your-project' MONITORING_SENTRY_AUTH_TOKEN= NEXT_PUBLIC_MONITORING_SENTRY_DSN= ``` ## Manual Event & Error Tracking ### Client-Side Capture an event: ```typescript filename="client-component.tsx" lineNumbers 'use client'; import { useMonitoring } from '@workspace/monitoring/hooks/use-monitoring'; const provider = useMonitoring(); provider.captureEvent('my-event'); ``` Capture an error in a try-catch block: ````typescript filename="client-component.tsx" lineNumbers 'use client'; import { useMonitoring } from '@workspace/monitoring/hooks/use-monitoring'; const provider = useMonitoring(); try { // Some ``` } catch (e) { provider.captureError(e); } ```` ### Server-Side Track an event: ```typescript filename="server-monitoring.ts" lineNumbers import { MonitoringProvider } from '@workspace/monitoring/provider'; MonitoringProvider.captureEvent('my-event'); ``` Track an error: ```typescript filename="server-monitoring.ts" lineNumbers import { MonitoringProvider } from '@workspace/monitoring/provider'; try { // Some code } catch (e) { MonitoringProvider.captureError(e); } ``` ## Related Documentation For full implementation details, check out the monitoring documentation in our starter kits: - [Pro Prisma Starter Kit](/docs/starter-kits/pro-nextjs-prisma) - [Pro Drizzle Starter Kit](/docs/starter-kits/pro-nextjs-drizzle) ## Current implementation - [Pro Prisma observability guide](/docs/starter-kits/pro-nextjs-prisma/observability/overview) - [Pro Drizzle observability guide](/docs/starter-kits/pro-nextjs-drizzle/observability/overview) --- Ready to add monitoring to your SaaS? [Get started with Achromatic](/pricing). --- ## How to Implement Multi-Tenancy in Next.js: A Step-by-Step Guide **URL**: https://www.achromatic.dev/blog/multi-tenancy-implementation-guide **Description**: A practical guide to implementing multi-tenant architecture in Next.js. Learn database strategies, middleware patterns, and security considerations with working code examples. **Published**: 2025-03-05 Multi-tenancy is what transforms a single-user application into a scalable B2B SaaS. It allows multiple organizations (tenants) to use your application while keeping their data completely isolated. In this step-by-step guide, we'll implement multi-tenancy in a Next.js application from scratch, covering database design, middleware, and security. ## Understanding Multi-Tenancy Models Before writing code, you need to choose a tenancy model: ### 1. Shared Database, Shared Schema All tenants share tables with a `tenant_id` column: ```sql -- Single users table for all tenants CREATE TABLE users ( id UUID PRIMARY KEY, tenant_id UUID NOT NULL REFERENCES tenants(id), email VARCHAR(255) NOT NULL, name VARCHAR(255) ); -- Every query includes tenant filter SELECT * FROM users WHERE tenant_id = 'abc-123'; ``` **Pros**: Simple, cost-effective, easy migrations **Cons**: Query discipline required, potential for data leaks ### 2. Shared Database, Separate Schemas Each tenant gets their own PostgreSQL schema: ```sql -- Tenant-specific schema CREATE SCHEMA tenant_abc123; CREATE TABLE tenant_abc123.users (...); -- Different tenant CREATE SCHEMA tenant_xyz789; CREATE TABLE tenant_xyz789.users (...); ``` **Pros**: Better isolation, same database instance **Cons**: Complex migrations, schema management overhead ### 3. Separate Databases Each tenant gets a completely isolated database: ``` postgres://host/tenant_abc123 postgres://host/tenant_xyz789 ``` **Pros**: Maximum isolation, independent scaling **Cons**: Expensive, complex deployment For most SaaS applications, **shared database with shared schema** provides the best balance of simplicity, cost, and security. That's what we'll implement. ## Step 1: Database Schema Design Let's design a multi-tenant schema using Prisma: ```prisma // prisma/schema.prisma model Organization { id String @id @default(cuid()) name String slug String @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Relations memberships Membership[] invitations Invitation[] projects Project[] @@map("organizations") } model User { id String @id @default(cuid()) email String @unique name String? image String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Relations memberships Membership[] invitations Invitation[] @relation("InvitedBy") @@map("users") } model Membership { id String @id @default(cuid()) role Role @default(MEMBER) createdAt DateTime @default(now()) // Relations user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId String organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organizationId String @@unique([userId, organizationId]) @@map("memberships") } model Project { id String @id @default(cuid()) name String description String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Tenant isolation organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organizationId String @@map("projects") } enum Role { OWNER ADMIN MEMBER } ``` Key design decisions: - **Users exist independently** of organizations (can belong to multiple) - **Membership** is the join table with role information - **All tenant resources** have an `organizationId` foreign key ## Step 2: Organization Context Provider Create a React context to track the current organization: ```tsx // contexts/organization-context.tsx 'use client'; import { createContext, useContext, useEffect, useState } from 'react'; import { useParams } from 'next/navigation'; type Organization = { id: string; name: string; slug: string; role: 'OWNER' | 'ADMIN' | 'MEMBER'; }; type OrganizationContextType = { organization: Organization | null; setOrganization: (org: Organization | null) => void; isLoading: boolean; }; const OrganizationContext = createContext( undefined ); export function OrganizationProvider({ children, initialOrganization }: { children: React.ReactNode; initialOrganization: Organization | null; }) { const [organization, setOrganization] = useState( initialOrganization ); const [isLoading, setIsLoading] = useState(false); return ( {children} ); } export function useOrganization() { const context = useContext(OrganizationContext); if (context === undefined) { throw new Error('useOrganization must be used within OrganizationProvider'); } return context; } ``` ## Step 3: Tenant-Aware Data Access Layer Create a data access layer that enforces tenant isolation: ```typescript // lib/dal/projects.ts import { getCurrentOrganization } from '@/lib/auth'; import { db } from '@/lib/db'; export async function getProjects() { const org = await getCurrentOrganization(); if (!org) { throw new Error('No organization selected'); } return db.project.findMany({ where: { organizationId: org.id // Always filter by tenant }, orderBy: { createdAt: 'desc' } }); } export async function getProject(id: string) { const org = await getCurrentOrganization(); if (!org) { throw new Error('No organization selected'); } const project = await db.project.findFirst({ where: { id, organizationId: org.id // Prevent accessing other tenants' data } }); if (!project) { throw new Error('Project not found'); } return project; } export async function createProject(data: { name: string; description?: string; }) { const org = await getCurrentOrganization(); if (!org) { throw new Error('No organization selected'); } return db.project.create({ data: { ...data, organizationId: org.id // Always set tenant } }); } export async function deleteProject(id: string) { const org = await getCurrentOrganization(); if (!org) { throw new Error('No organization selected'); } // Verify ownership before deletion const project = await db.project.findFirst({ where: { id, organizationId: org.id } }); if (!project) { throw new Error('Project not found'); } return db.project.delete({ where: { id } }); } ``` ## Step 4: Middleware for Tenant Resolution Create middleware to resolve the tenant from the URL or session: ```typescript // middleware.ts import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import { getToken } from 'next-auth/jwt'; export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; // Skip middleware for public routes if ( pathname.startsWith('/api/auth') || pathname.startsWith('/_next') || pathname === '/login' || pathname === '/signup' ) { return NextResponse.next(); } // Check authentication const token = await getToken({ req: request }); if (!token) { return NextResponse.redirect(new URL('/login', request.url)); } // Check if accessing organization routes if (pathname.startsWith('/dashboard/')) { const orgSlug = pathname.split('/')[2]; if (orgSlug) { // Verify user has access to this organization // In production, you'd cache this check const hasAccess = await verifyOrganizationAccess( token.sub as string, orgSlug ); if (!hasAccess) { return NextResponse.redirect(new URL('/dashboard', request.url)); } // Add organization to headers for server components const response = NextResponse.next(); response.headers.set('x-organization-slug', orgSlug); return response; } } return NextResponse.next(); } async function verifyOrganizationAccess( userId: string, orgSlug: string ): Promise { // Implement your verification logic // This should be cached/optimized in production return true; } export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] }; ``` ## Step 5: Organization Switching UI Build a component for switching between organizations: ```tsx // components/organization-switcher.tsx 'use client'; import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { useOrganization } from '@/contexts/organization-context'; import { Check, ChevronsUpDown, PlusCircle } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from '@/components/ui/command'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; type Organization = { id: string; name: string; slug: string; role: string; }; export function OrganizationSwitcher({ organizations }: { organizations: Organization[]; }) { const [open, setOpen] = useState(false); const router = useRouter(); const { organization, setOrganization } = useOrganization(); const handleSelect = (org: Organization) => { setOrganization(org); setOpen(false); router.push(`/dashboard/${org.slug}`); }; return ( No organization found. {organizations.map((org) => ( handleSelect(org)} > {org.name} ))} { setOpen(false); router.push('/dashboard/new'); }} > Create Organization ); } ``` ## Step 6: Server Actions with Tenant Isolation Create secure server actions that enforce tenant boundaries: ```typescript // actions/projects.ts 'use server'; import { revalidatePath } from 'next/cache'; import { z } from 'zod'; import { getCurrentOrganization, getCurrentUser } from '@/lib/auth'; import { db } from '@/lib/db'; const createProjectSchema = z.object({ name: z.string().min(1).max(100), description: z.string().max(500).optional() }); export async function createProjectAction(formData: FormData) { const user = await getCurrentUser(); const org = await getCurrentOrganization(); if (!user || !org) { return { error: 'Unauthorized' }; } // Check user has permission to create projects if (org.role === 'MEMBER') { return { error: 'Insufficient permissions' }; } const validated = createProjectSchema.safeParse({ name: formData.get('name'), description: formData.get('description') }); if (!validated.success) { return { error: 'Invalid input' }; } try { const project = await db.project.create({ data: { name: validated.data.name, description: validated.data.description, organizationId: org.id // Always set from session, never from client } }); revalidatePath(`/dashboard/${org.slug}/projects`); return { success: true, project }; } catch (error) { return { error: 'Failed to create project' }; } } export async function deleteProjectAction(projectId: string) { const user = await getCurrentUser(); const org = await getCurrentOrganization(); if (!user || !org) { return { error: 'Unauthorized' }; } // Verify the project belongs to current organization const project = await db.project.findFirst({ where: { id: projectId, organizationId: org.id // Critical: prevents cross-tenant access } }); if (!project) { return { error: 'Project not found' }; } // Check permissions if (org.role !== 'OWNER' && org.role !== 'ADMIN') { return { error: 'Insufficient permissions' }; } await db.project.delete({ where: { id: projectId } }); revalidatePath(`/dashboard/${org.slug}/projects`); return { success: true }; } ``` ## Step 7: Role-Based Access Control Implement RBAC for fine-grained permissions: ```typescript // lib/permissions.ts type Permission = | 'project:create' | 'project:read' | 'project:update' | 'project:delete' | 'member:invite' | 'member:remove' | 'billing:manage' | 'organization:settings'; const rolePermissions: Record = { OWNER: [ 'project:create', 'project:read', 'project:update', 'project:delete', 'member:invite', 'member:remove', 'billing:manage', 'organization:settings' ], ADMIN: [ 'project:create', 'project:read', 'project:update', 'project:delete', 'member:invite', 'member:remove' ], MEMBER: ['project:read', 'project:update'] }; export function hasPermission(role: string, permission: Permission): boolean { return rolePermissions[role]?.includes(permission) ?? false; } export function requirePermission(role: string, permission: Permission) { if (!hasPermission(role, permission)) { throw new Error(`Missing permission: ${permission}`); } } ``` ## Security Considerations ### 1. Never Trust Client Input ```typescript // BAD: organizationId from client await db.project.create({ data: { name: data.name, organizationId: data.organizationId // Attacker can set any ID! } }); // GOOD: organizationId from server session const org = await getCurrentOrganization(); await db.project.create({ data: { name: data.name, organizationId: org.id // Always from authenticated session } }); ``` ### 2. Always Filter Queries ```typescript // BAD: No tenant filter const project = await db.project.findUnique({ where: { id: projectId } }); // GOOD: Always include tenant filter const project = await db.project.findFirst({ where: { id: projectId, organizationId: org.id } }); ``` ### 3. Use Database-Level Policies For PostgreSQL, add row-level security as a safety net: ```sql -- Enable RLS ALTER TABLE projects ENABLE ROW LEVEL SECURITY; -- Policy: Users can only see their organization's projects CREATE POLICY tenant_isolation ON projects FOR ALL USING (organization_id = current_setting('app.current_organization_id')::uuid); ``` ## Testing Multi-Tenancy Write tests that verify tenant isolation: ```typescript // tests/multi-tenancy.test.ts import { describe, expect, it } from 'vitest'; describe('Multi-tenancy isolation', () => { it('should not allow cross-tenant project access', async () => { // Create two organizations const org1 = await createOrganization('Org 1'); const org2 = await createOrganization('Org 2'); // Create project in org1 const project = await createProject(org1.id, { name: 'Secret Project' }); // Try to access from org2 context const result = await getProject(org2.id, project.id); expect(result).toBeNull(); }); it('should prevent tenant ID manipulation', async () => { const org1 = await createOrganization('Org 1'); const org2 = await createOrganization('Org 2'); // Try to create project in org2 while authenticated as org1 const result = await createProjectAction({ name: 'Malicious Project', organizationId: org2.id // Should be ignored }); // Project should be created in org1, not org2 const project = await db.project.findFirst({ where: { name: 'Malicious Project' } }); expect(project?.organizationId).toBe(org1.id); }); }); ``` ## Conclusion Implementing multi-tenancy requires careful attention to data isolation at every layer: 1. **Database design** with proper foreign keys and constraints 2. **Server-side context** to track the current tenant 3. **Data access layer** that enforces tenant filters 4. **Middleware** to verify tenant access 5. **Server actions** that never trust client input 6. **RBAC** for fine-grained permissions 7. **Testing** to verify isolation The patterns in this guide scale from startups to enterprise—the key is building tenant isolation into your architecture from the start. --- _Want multi-tenancy without building it yourself? [Achromatic](/) includes production-ready organization management, member invites, role-based permissions, and tenant isolation out of the box._ --- ## Multi-Tenant Architecture Patterns in Next.js **URL**: https://www.achromatic.dev/blog/multi-tenant-architecture-nextjs **Description**: Learn how to build multi-tenant SaaS applications in Next.js. Explore different tenant isolation strategies, database schemas, subdomain routing, and middleware patterns. **Published**: 2025-02-28 Multi-tenancy is the backbone of modern SaaS applications. It allows you to serve multiple customers (tenants) from a single codebase while keeping their data isolated and secure. In this guide, we'll explore the different multi-tenant architecture patterns you can implement in Next.js, with real code examples for each approach. ## What is Multi-Tenancy? Multi-tenancy means a single instance of your application serves multiple tenants (organizations, teams, or customers). Each tenant: - Has isolated data that other tenants can't access - May have custom settings, branding, or features - Shares the same application infrastructure **Common examples:** Slack (workspaces), Notion (workspaces), Linear (teams), Vercel (teams). ## Tenant Isolation Strategies There are three main approaches to tenant isolation: ### 1. Database-per-Tenant Each tenant gets their own database. Maximum isolation but highest operational overhead. ```text filename="database-per-tenant.txt" lineNumbers tenant-a.database.com -> Tenant A's data tenant-b.database.com -> Tenant B's data tenant-c.database.com -> Tenant C's data ``` **Pros:** - Complete data isolation - Easy to comply with data residency requirements - Can scale databases independently **Cons:** - High operational overhead - Expensive at scale - Complex deployment and migrations ### 2. Schema-per-Tenant Single database with separate schemas for each tenant (PostgreSQL supports this well). ```sql filename="schema-per-tenant.sql" lineNumbers -- PostgreSQL schemas CREATE SCHEMA tenant_a; CREATE SCHEMA tenant_b; CREATE SCHEMA tenant_c; -- Tables exist in each schema tenant_a.users tenant_b.users tenant_c.users ``` **Pros:** - Good isolation without separate databases - Easier to manage than database-per-tenant - Can leverage PostgreSQL RLS (Row Level Security) **Cons:** - Schema migrations become complex - Not all databases support this well - Still some operational overhead ### 3. Shared Database with Tenant ID (Most Common) All tenants share the same tables with a `tenant_id` or `organization_id` column. ```sql filename="shared-database.sql" lineNumbers -- Shared tables with tenant isolation CREATE TABLE users ( id UUID PRIMARY KEY, organization_id UUID NOT NULL REFERENCES organizations(id), email TEXT NOT NULL, name TEXT, created_at TIMESTAMP DEFAULT NOW() ); CREATE TABLE projects ( id UUID PRIMARY KEY, organization_id UUID NOT NULL REFERENCES organizations(id), name TEXT NOT NULL, created_at TIMESTAMP DEFAULT NOW() ); -- Every query filters by organization_id SELECT * FROM projects WHERE organization_id = $1; ``` **Pros:** - Simplest to implement and maintain - Single migration path - Most cost-effective **Cons:** - Risk of data leakage if queries forget the filter - Harder to comply with strict data residency requirements ## Database Schema Design Here's a complete multi-tenant schema using Prisma: ```typescript filename="prisma/schema.prisma" lineNumbers // Prisma schema model Organization { id String @id @default(cuid()) name String slug String @unique plan Plan @default(FREE) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Tenant data members Member[] projects Project[] invitations Invitation[] } model User { id String @id @default(cuid()) email String @unique name String? image String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // User can belong to multiple organizations memberships Member[] } model Member { id String @id @default(cuid()) role Role @default(MEMBER) createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId String organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organizationId String @@unique([userId, organizationId]) } model Project { id String @id @default(cuid()) name String description String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Tenant isolation organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) organizationId String tasks Task[] } model Task { id String @id @default(cuid()) title String completed Boolean @default(false) createdAt DateTime @default(now()) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) projectId String } enum Role { OWNER ADMIN MEMBER } enum Plan { FREE PRO ENTERPRISE } ``` And the equivalent in Drizzle: ```typescript filename="src/db/schema.ts" lineNumbers import { relations } from 'drizzle-orm'; import { boolean, pgEnum, pgTable, text, timestamp } from 'drizzle-orm/pg-core'; export const roleEnum = pgEnum('role', ['OWNER', 'ADMIN', 'MEMBER']); export const planEnum = pgEnum('plan', ['FREE', 'PRO', 'ENTERPRISE']); export const organizations = pgTable('organizations', { id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), name: text('name').notNull(), slug: text('slug').notNull().unique(), plan: planEnum('plan').default('FREE').notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull() }); export const users = pgTable('users', { id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), email: text('email').notNull().unique(), name: text('name'), image: text('image'), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull() }); export const members = pgTable('members', { id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), role: roleEnum('role').default('MEMBER').notNull(), userId: text('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), organizationId: text('organization_id') .notNull() .references(() => organizations.id, { onDelete: 'cascade' }), createdAt: timestamp('created_at').defaultNow().notNull() }); export const projects = pgTable('projects', { id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), name: text('name').notNull(), description: text('description'), organizationId: text('organization_id') .notNull() .references(() => organizations.id, { onDelete: 'cascade' }), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull() }); export const tasks = pgTable('tasks', { id: text('id') .primaryKey() .$defaultFn(() => crypto.randomUUID()), title: text('title').notNull(), completed: boolean('completed').default(false).notNull(), projectId: text('project_id') .notNull() .references(() => projects.id, { onDelete: 'cascade' }), createdAt: timestamp('created_at').defaultNow().notNull() }); // Define relations export const organizationsRelations = relations(organizations, ({ many }) => ({ members: many(members), projects: many(projects) })); export const usersRelations = relations(users, ({ many }) => ({ memberships: many(members) })); export const membersRelations = relations(members, ({ one }) => ({ user: one(users, { fields: [members.userId], references: [users.id] }), organization: one(organizations, { fields: [members.organizationId], references: [organizations.id] }) })); export const projectsRelations = relations(projects, ({ one, many }) => ({ organization: one(organizations, { fields: [projects.organizationId], references: [organizations.id] }), tasks: many(tasks) })); ``` ## Tenant Resolution Strategies How do you identify which tenant a request belongs to? Here are the main approaches: ### 1. Subdomain-Based Routing ```text filename="subdomain-routing.txt" lineNumbers acme.yourapp.com -> Tenant: acme globex.yourapp.com -> Tenant: globex ``` This is the most professional approach, used by Slack, Linear, etc. **Next.js Middleware for Subdomain Resolution:** ```typescript filename="middleware.ts" lineNumbers import { NextRequest, NextResponse } from 'next/server'; export function middleware(request: NextRequest) { const hostname = request.headers.get('host') || ''; const url = request.nextUrl.clone(); // Get subdomain // e.g., "acme.yourapp.com" -> "acme" // e.g., "acme.localhost:3000" -> "acme" const subdomain = hostname.split('.')[0]; // Skip for main domain and special subdomains const isMainDomain = hostname === 'yourapp.com' || hostname === 'www.yourapp.com'; const isLocalhost = hostname.includes('localhost'); const isSpecialSubdomain = ['www', 'app', 'api'].includes(subdomain); if (isMainDomain || isSpecialSubdomain) { return NextResponse.next(); } // For local development, handle "acme.localhost:3000" if (isLocalhost && subdomain !== 'localhost') { // Rewrite to tenant-specific route url.pathname = `/tenant/${subdomain}${url.pathname}`; return NextResponse.rewrite(url); } // For production subdomains if (!isMainDomain && !isLocalhost) { url.pathname = `/tenant/${subdomain}${url.pathname}`; return NextResponse.rewrite(url); } return NextResponse.next(); } export const config = { matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'] }; ``` **Dynamic Route Handler:** ```typescript filename="app/tenant/[slug]/page.tsx" lineNumbers import { notFound } from 'next/navigation'; import { prisma } from '@/lib/prisma'; interface TenantPageProps { params: Promise<{ slug: string }>; } export default async function TenantPage({ params }: TenantPageProps) { const { slug } = await params; const organization = await prisma.organization.findUnique({ where: { slug }, include: { projects: true, }, }); if (!organization) { notFound(); } return (

Welcome to {organization.name}

Projects

    {organization.projects.map((project) => (
  • {project.name}
  • ))}
); } ``` ### 2. Path-Based Routing ```text filename="path-routing.txt" lineNumbers yourapp.com/org/acme/dashboard -> Tenant: acme yourapp.com/org/globex/dashboard -> Tenant: globex ``` Simpler to implement, no DNS configuration needed. ```typescript filename="app/org/[orgSlug]/dashboard/page.tsx" lineNumbers import { notFound } from 'next/navigation'; import { prisma } from '@/lib/prisma'; interface DashboardPageProps { params: Promise<{ orgSlug: string }>; } export default async function DashboardPage({ params }: DashboardPageProps) { const { orgSlug } = await params; const organization = await prisma.organization.findUnique({ where: { slug: orgSlug }, }); if (!organization) { notFound(); } // Fetch tenant-specific data const projects = await prisma.project.findMany({ where: { organizationId: organization.id }, }); return (

{organization.name} Dashboard

{/* Dashboard content */}
); } ``` ### 3. Header-Based (API Tokens) For APIs, use headers to identify tenants: ```typescript filename="app/api/projects/route.ts" lineNumbers import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; export async function GET(request: NextRequest) { // Get tenant from API key or header const apiKey = request.headers.get('X-API-Key'); if (!apiKey) { return NextResponse.json({ error: 'API key required' }, { status: 401 }); } // Lookup organization by API key const apiKeyRecord = await prisma.apiKey.findUnique({ where: { key: apiKey }, include: { organization: true } }); if (!apiKeyRecord) { return NextResponse.json({ error: 'Invalid API key' }, { status: 401 }); } // Fetch tenant-scoped data const projects = await prisma.project.findMany({ where: { organizationId: apiKeyRecord.organizationId } }); return NextResponse.json({ projects }); } ``` ## Tenant Context Pattern Create a context to access tenant information throughout your app: ```typescript filename="lib/tenant-context.ts" lineNumbers import { cache } from 'react'; import { prisma } from '@/lib/prisma'; export type Tenant = { id: string; name: string; slug: string; plan: 'FREE' | 'PRO' | 'ENTERPRISE'; }; // Server-side tenant context using React cache export const getTenant = cache(async (slug: string): Promise => { const organization = await prisma.organization.findUnique({ where: { slug }, select: { id: true, name: true, slug: true, plan: true } }); return organization; }); // Type-safe tenant-scoped queries export const getTenantProjects = cache(async (tenantId: string) => { return prisma.project.findMany({ where: { organizationId: tenantId }, orderBy: { createdAt: 'desc' } }); }); export const getTenantMembers = cache(async (tenantId: string) => { return prisma.member.findMany({ where: { organizationId: tenantId }, include: { user: true }, orderBy: { createdAt: 'asc' } }); }); ``` **Using the Tenant Context in Pages:** ```typescript filename="app/org/[orgSlug]/layout.tsx" lineNumbers import { notFound, redirect } from 'next/navigation'; import { getTenant } from '@/lib/tenant-context'; import { auth } from '@/lib/auth'; interface TenantLayoutProps { children: React.ReactNode; params: Promise<{ orgSlug: string }>; } export default async function TenantLayout({ children, params }: TenantLayoutProps) { const { orgSlug } = await params; const session = await auth(); if (!session?.user) { redirect('/login'); } const tenant = await getTenant(orgSlug); if (!tenant) { notFound(); } // Verify user has access to this tenant const membership = await prisma.member.findUnique({ where: { userId_organizationId: { userId: session.user.id, organizationId: tenant.id, }, }, }); if (!membership) { redirect('/unauthorized'); } return (
{children}
); } ``` ## Authorization & Access Control Implement role-based access control within each tenant: ```typescript filename="lib/permissions.ts" lineNumbers type Role = 'OWNER' | 'ADMIN' | 'MEMBER'; type Permission = | 'project:create' | 'project:read' | 'project:update' | 'project:delete' | 'member:invite' | 'member:remove' | 'settings:update' | 'billing:manage'; const rolePermissions: Record = { OWNER: [ 'project:create', 'project:read', 'project:update', 'project:delete', 'member:invite', 'member:remove', 'settings:update', 'billing:manage' ], ADMIN: [ 'project:create', 'project:read', 'project:update', 'project:delete', 'member:invite', 'member:remove', 'settings:update' ], MEMBER: ['project:create', 'project:read', 'project:update'] }; export function hasPermission(role: Role, permission: Permission): boolean { return rolePermissions[role].includes(permission); } export function requirePermission(role: Role, permission: Permission): void { if (!hasPermission(role, permission)) { throw new Error(`Permission denied: ${permission}`); } } ``` **Using Permissions in Server Actions:** ```typescript filename="app/actions/projects.ts" lineNumbers 'use server'; import { revalidatePath } from 'next/cache'; import { auth } from '@/lib/auth'; import { requirePermission } from '@/lib/permissions'; import { prisma } from '@/lib/prisma'; export async function createProject( organizationId: string, data: { name: string; description?: string } ) { const session = await auth(); if (!session?.user) { throw new Error('Unauthorized'); } // Get user's role in this organization const membership = await prisma.member.findUnique({ where: { userId_organizationId: { userId: session.user.id, organizationId } } }); if (!membership) { throw new Error('Not a member of this organization'); } // Check permission requirePermission(membership.role, 'project:create'); // Create the project (automatically scoped to tenant) const project = await prisma.project.create({ data: { name: data.name, description: data.description, organizationId // Tenant isolation } }); revalidatePath(`/org/${organizationId}/projects`); return project; } export async function deleteProject(projectId: string) { const session = await auth(); if (!session?.user) { throw new Error('Unauthorized'); } // Get the project to find its organization const project = await prisma.project.findUnique({ where: { id: projectId } }); if (!project) { throw new Error('Project not found'); } // Get user's role in this organization const membership = await prisma.member.findUnique({ where: { userId_organizationId: { userId: session.user.id, organizationId: project.organizationId } } }); if (!membership) { throw new Error('Not a member of this organization'); } // Check permission requirePermission(membership.role, 'project:delete'); await prisma.project.delete({ where: { id: projectId } }); revalidatePath(`/org/${project.organizationId}/projects`); } ``` ## Organization Switching Allow users to switch between organizations they belong to: ```typescript filename="components/org-switcher.tsx" lineNumbers 'use client'; import { useRouter } from 'next/navigation'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; interface Organization { id: string; name: string; slug: string; } interface OrgSwitcherProps { organizations: Organization[]; currentOrgSlug: string; } export function OrgSwitcher({ organizations, currentOrgSlug }: OrgSwitcherProps) { const router = useRouter(); const handleOrgChange = (slug: string) => { router.push(`/org/${slug}/dashboard`); }; return ( ); } ``` ## Best Practices ### 1. Always Filter by Tenant ID Never trust client-side tenant information. Always verify and filter on the server: ```typescript filename="lib/data.ts" lineNumbers // BAD - Trust client input const projects = await prisma.project.findMany({ where: { organizationId: request.body.organizationId } // Dangerous! }); // GOOD - Verify membership first const membership = await prisma.member.findUnique({ where: { userId_organizationId: { userId: session.user.id, organizationId: request.body.organizationId } } }); if (!membership) { throw new Error('Unauthorized'); } const projects = await prisma.project.findMany({ where: { organizationId: membership.organizationId } }); ``` ### 2. Use Database-Level Security (Optional) PostgreSQL Row Level Security adds an extra layer of protection: ```sql filename="row-level-security.sql" lineNumbers -- Enable RLS on projects table ALTER TABLE projects ENABLE ROW LEVEL SECURITY; -- Create policy for tenant isolation CREATE POLICY tenant_isolation_policy ON projects USING (organization_id = current_setting('app.current_tenant_id')::uuid); ``` ### 3. Audit Logging Track who did what in each tenant: ```typescript filename="lib/audit.ts" lineNumbers export async function logAuditEvent({ organizationId, userId, action, resourceType, resourceId, metadata }: { organizationId: string; userId: string; action: string; resourceType: string; resourceId: string; metadata?: Record; }) { await prisma.auditLog.create({ data: { organizationId, userId, action, resourceType, resourceId, metadata: metadata ? JSON.stringify(metadata) : null } }); } ``` ## Conclusion Multi-tenant architecture is essential for building scalable SaaS applications. The key decisions are: 1. **Isolation strategy:** Shared database with tenant ID is usually the best balance of simplicity and isolation 2. **Tenant resolution:** Subdomain-based for professional feel, path-based for simplicity 3. **Authorization:** Always verify tenant membership and permissions on every request 4. **Data access:** Never trust client input—always filter by verified tenant ID ## Related Articles - [Prisma vs Drizzle ORM](/blog/prisma-vs-drizzle-orm) - Choose the right ORM for your multi-tenant database - [Implementing Stripe Billing in Next.js](/blog/stripe-billing-nextjs) - Add per-organization billing to your multi-tenant app - [Building a SaaS Dashboard with React Server Components](/blog/saas-dashboard-react-server-components) - Build tenant-aware dashboards --- Ready to build your multi-tenant SaaS? Our starter kits come with multi-tenancy built in: - [Prisma Kit](/docs/starter-kits/monorepo-next-prisma-authjs) - Organizations, roles, and invitations pre-configured - [Drizzle Kit](/docs/starter-kits/monorepo-next-drizzle-authjs) - Lightweight multi-tenancy with type-safe queries Check out our [pricing](/pricing) to get started building your multi-tenant SaaS today. --- ## Drizzle Starter Kit **URL**: https://www.achromatic.dev/blog/drizzle-starter-kit **Description**: Drizzle ORM has been added as an additional starter kit! We've fully ported the monorepo version to Drizzle, providing a lightweight, type-safe and high-performance option that is loved by many. **Published**: 2025-02-25 Drizzle ORM is designed for modern applications, offering a fully type-safe approach to database management without compromising on performance. Here’s why we chose to support it in Achromatic: - **Performance & Efficiency** – Drizzle is optimized for speed and minimal runtime overhead. - **Fully Type-Safe** – Built with TypeScript in mind, it ensures fewer runtime errors. - **Flexible Query Building** – Supports SQL-like query structures while still providing high-level abstractions. - **Better Migrations** – Offers a cleaner, more intuitive migration system. - **Compatibility** – Works seamlessly with PostgreSQL, MySQL, SQLite, and other relational databases. With this new version, users can now **choose between Prisma and Drizzle ORM**, depending on their project’s needs. If you want something lightweight and performant while maintaining strict type safety, Drizzle is a fantastic choice. All kits are included in the same price/package, you don't have to buy multiple kits. This also includes also all future kits. Check out [our pricing](/pricing) to see all available options. - **Access:** [Pro Drizzle starter kit setup instructions](/docs/starter-kits/pro-nextjs-drizzle/setup) - **Documentation:** [Pro Drizzle Starter Kit Docs](/docs/starter-kits/pro-nextjs-drizzle) If you prefer Prisma ORM instead, check out our [Pro Prisma Starter Kit documentation](/docs/starter-kits/pro-nextjs-prisma). ## Related Articles - [Prisma vs Drizzle ORM](/blog/prisma-vs-drizzle-orm) - In-depth comparison to help you choose the right ORM - [Building a SaaS Dashboard with React Server Components](/blog/saas-dashboard-react-server-components) - Data fetching patterns with Drizzle Happy coding! --- ## Prisma vs Drizzle in 2026: Which ORM Fits Your Next.js SaaS? **URL**: https://www.achromatic.dev/blog/prisma-vs-drizzle-orm **Description**: A practical Prisma 7 vs Drizzle comparison based on maintaining the same production Next.js SaaS architecture with both ORMs. **Published**: 2025-01-15 **Updated**: 2026-07-19 Prisma and Drizzle can both power a production Next.js SaaS. The important difference is not whether one can perform a query the other cannot. It is how each ORM asks your team to think about schemas, queries and migrations. We maintain the same Achromatic SaaS architecture in two separate repositories: one built with Prisma and one built with Drizzle. That gives us a useful comparison point. The application features stay aligned while the persistence layer changes. ## The short answer - Choose **Prisma** if you want a concise schema language, a generated client and a higher-level query API with strong support for nested reads and writes. - Choose **Drizzle** if you want schemas in TypeScript, queries that remain close to SQL and explicit control over the SQL migration files entering your repository. - Do not choose based on old claims about Prisma always shipping a large Rust query engine. Prisma 7 introduced a Rust-free client and requires database driver adapters. - For most SaaS products, your team's preferred database workflow matters more than a theoretical ORM benchmark. Achromatic includes both the [Prisma starter kit](/docs/starter-kits/pro-nextjs-prisma) and the [Drizzle starter kit](/docs/starter-kits/pro-nextjs-drizzle), so this decision does not change what the license includes. ## Prisma vs Drizzle at a glance | Area | Prisma 7 | Drizzle | | --------------------- | ------------------------------------------------ | --------------------------------------------------------- | | Schema | Prisma Schema Language | TypeScript | | Query style | Generated, model-oriented client | SQL-shaped and relational APIs | | Type generation | Generated Prisma Client | Inferred from TypeScript schema | | PostgreSQL connection | Driver adapter, such as `@prisma/adapter-pg` | Database driver integration, such as `node-postgres` | | Migration workflow | `prisma migrate dev` and `prisma migrate deploy` | Generate SQL with Drizzle Kit, then apply it | | Nested writes | A core strength | Usually expressed as explicit operations in a transaction | | SQL visibility | More abstract by default | More direct by default | | Best fit | Teams that prefer a higher-level data client | Teams that prefer SQL-shaped control | ## What changed for Prisma in 2026 Many Prisma comparisons still describe an older architecture. Prisma 7's new client is Rust-free and database connections now use a driver adapter. Achromatic's current Prisma kit uses Prisma 7.3 with `@prisma/adapter-pg` and the `pg` connection pool. That change makes blanket claims such as “Drizzle is serverless and Prisma is not” too simplistic. Runtime compatibility and connection behavior now depend heavily on the database driver and deployment environment selected for either ORM. Consult the [Prisma 7 upgrade guide](https://docs.prisma.io/docs/guides/upgrade-prisma-orm/v7) when comparing current architecture rather than relying on Prisma 5 or Prisma 6 assumptions. Drizzle still takes a different approach. Its schema is TypeScript and its query APIs stay closer to SQL. Drizzle Kit can generate SQL migrations from that schema, apply migrations or push schema changes directly. Achromatic uses generated migration files for reviewable changes. ## Schema definition Prisma keeps the data model in `prisma/schema.prisma`: ```prisma filename="prisma/schema.prisma" lineNumbers model User { id String @id @default(cuid()) email String @unique name String? createdAt DateTime @default(now()) sessions Session[] } model Session { id String @id @default(cuid()) userId String expiresAt DateTime user User @relation(fields: [userId], references: [id], onDelete: Cascade) } ``` Drizzle expresses the same structure in TypeScript: ```typescript filename="lib/db/schema/tables.ts" lineNumbers import { relations } from 'drizzle-orm'; import { pgTable, text, timestamp } from 'drizzle-orm/pg-core'; export const userTable = pgTable('user', { id: text('id').primaryKey(), email: text('email').notNull().unique(), name: text('name'), createdAt: timestamp('created_at').defaultNow().notNull() }); export const sessionTable = pgTable('session', { id: text('id').primaryKey(), userId: text('user_id') .notNull() .references(() => userTable.id, { onDelete: 'cascade' }), expiresAt: timestamp('expires_at').notNull() }); export const userRelations = relations(userTable, ({ many }) => ({ sessions: many(sessionTable) })); ``` Prisma's schema is compact and gives the generated client one centralized data model. Drizzle keeps database definitions in the same language as the application and makes SQL names and constraints highly visible. ## Reading related data Prisma's generated client uses model-oriented methods: ```typescript filename="lib/queries/user.ts" lineNumbers const user = await prisma.user.findUnique({ where: { email }, include: { sessions: true } }); ``` Drizzle's relational API can express a similar read: ```typescript filename="lib/queries/user.ts" lineNumbers import { eq } from 'drizzle-orm'; const user = await db.query.userTable.findFirst({ where: eq(userTable.email, email), with: { sessions: true } }); ``` Both are type-safe. Prisma derives the result from its generated client and the selected relation shape. Drizzle derives it from the TypeScript schema and query expression. The practical difference appears as queries grow. Prisma encourages you to describe a model result. Drizzle makes joins, conditions and selected columns feel closer to writing SQL. ## Writes and transactions Prisma makes related writes particularly concise: ```typescript filename="lib/queries/user.ts" lineNumbers const user = await prisma.user.create({ data: { email, name, sessions: { create: { token, expiresAt } } } }); ``` With Drizzle, the equivalent workflow is usually explicit: ```typescript filename="lib/queries/user.ts" lineNumbers const user = await db.transaction(async (tx) => { const [createdUser] = await tx .insert(userTable) .values({ id: crypto.randomUUID(), email, name }) .returning(); await tx.insert(sessionTable).values({ id: crypto.randomUUID(), userId: createdUser.id, token, expiresAt }); return createdUser; }); ``` Prisma is often more convenient when a product performs many nested writes. Drizzle's extra lines can be an advantage when a team wants transaction boundaries and individual SQL operations to remain obvious. ## Migration workflow The current Achromatic Prisma kit uses two distinct commands: ```bash # Create and apply a migration during development npm run db:migrate:dev # Apply committed migrations in production npm run db:migrate ``` The production command maps to `prisma migrate deploy`, which applies pending migrations without generating a new one. The Drizzle kit separates generation and application: ```bash # Generate a SQL migration from schema changes npm run db:generate # Apply committed migrations npm run db:migrate ``` Drizzle Kit documents this as a code-first flow: [`generate`](https://orm.drizzle.team/docs/drizzle-kit-generate) creates SQL migration files and [`migrate`](https://orm.drizzle.team/docs/drizzle-kit-migrate) applies the migrations that have not run yet. The kit also exposes `npm run db:push` for fast local iteration. Because `push` applies schema differences directly and does not create migration files, it should not replace reviewed migrations in production. ## Performance and deployment There is no honest universal winner without a workload, database driver, hosting environment and measurement method. Drizzle has a thin, SQL-shaped runtime and gives developers direct control over selected columns and generated SQL. Prisma 7 removed the old Rust engine from its new client architecture and now uses the underlying driver adapter for database connections. Comparisons based only on old package sizes or cold-start measurements no longer describe the current choice accurately. For a production SaaS, measure the operations that matter to your product: - application bundle and cold start in the target runtime - connection pool behavior under concurrent traffic - query count and selected payload size for real screens - latency at the same database region - migration safety in the deployment pipeline Achromatic's current kits use PostgreSQL through Node runtime drivers. If you need an edge runtime or a specific serverless database transport, verify that transport for the exact ORM and driver combination before deciding. ## Choose Prisma when - your team prefers a compact declarative data model - generated client methods are easier for your developers to navigate - the product relies on nested relation reads and writes - you want database details abstracted behind a consistent model API - your team is less comfortable reviewing SQL directly ## Choose Drizzle when - your team already thinks in SQL - you want schemas to live in TypeScript - selected columns, joins and conditions should remain explicit - reviewing generated SQL migrations is part of your workflow - you expect to write specialized queries close to the database ## How Achromatic keeps the choice practical Achromatic deliberately ships two standalone repositories rather than hiding both ORMs behind a shared monorepo abstraction. The Prisma and Drizzle kits contain the same product capabilities, including authentication, organizations, billing, administration and email. Each implementation can follow its ORM's conventions without adding a compatibility layer to your application. That means you can make the decision based on the code your team wants to maintain: 1. Open the [Prisma documentation](/docs/starter-kits/pro-nextjs-prisma) and [Drizzle documentation](/docs/starter-kits/pro-nextjs-drizzle). 2. Compare the schema and migration workflows with your team's experience. 3. Choose Prisma for the generated model-oriented workflow or Drizzle for the SQL-shaped TypeScript workflow. 4. Start with that repository. Your [Achromatic license](/pricing) includes access to both. ## Final recommendation Choose Prisma if its generated client lets your team express product logic faster. Choose Drizzle if direct, typed SQL-shaped code makes database behavior easier for your team to understand. Neither choice will rescue a poor schema or replace query measurement. In 2026, the strongest distinction is developer workflow, not an outdated claim that one ORM can run in modern Next.js environments while the other cannot. --- ## Multi-Organization & Monorepo **URL**: https://www.achromatic.dev/blog/multi-organization-monorepo **Description**: Announcing the latest version of Achromatic with multi-organization support and monorepo integration, providing better project management and improved development workflows. **Published**: 2025-01-10 We’re excited to introduce **multi-organization support** and **Turborepo** in Achromatic! This update enables users to seamlessly manage and switch between multiple organizations while improving project structure and performance with Turborepo. The changes include database updates, UI refinements, business logic enhancements and a more modular architecture. This feature is available in both our [Prisma](/docs/starter-kits/monorepo-next-prisma-authjs) and [Drizzle](/docs/starter-kits/monorepo-next-drizzle-authjs) starter kits. ### Features #### 1. Organization Management - Create, update, delete, and search organizations. - Upload or remove organization logos. - Invite and manage members within an organization. - Transfer ownership of an organization. - Identify organizations by a **slug**. #### 2. Organization Switching - Switch between organizations using a dropdown or menu. - Derive organization context from the slug. #### 3. UI/UX Updates - Updated navigation and layouts to support multi-organization views. - Context-aware components, data functions, and server actions. - A design approach similar to **Linear, Campsite, and Stripe**. ### Key Changes 1. **New Membership Table:** Tracks user roles and organization access. 2. **Auth.js Simplicity:** No more `organizationId` in sessions; organizations derive from slugs. 3. **Organization Enhancements:** Removed `completedOnboarding`, added `slug` and `logo` and introduced `OrganizationLogo`. 4. **Streamlined Onboarding:** New invitation step and reduced component complexity. 5. **Sidebar Redesign:** More intuitive settings layout and improved space efficiency. 6. **UI Refinements:** Text adjustments for better density and readability. 7. **Improved Invitation Flow:** No more "join" action; users sign in with their invited email. 8. **New replaceOrgSlug() Helper:** Simplifies route construction for organizations. 9. **Organization-Specific Caching:** Ensures data integrity across different organizations. 10. **Refactored Authentication & Context Handling:** Removed `@/lib/auth/organizations.ts`, replacing it with context-based authentication. 11. Added **Turborepo** support for better modularization and performance. 12. Overhauled the auth pages with a new desig 13. Minimized env variables (and ensured consistent naming). 14. Improved the sidebar resize, collapse and animation state. ### Context Handling Achromatic now uses structured context retrieval for both **actions** and **data functions** to ensure seamless organization-aware workflows. #### 1. Actions Example: Adding a contact with **`authOrganizationActionClient`** ```typescript filename="apps/dashboard/actions/add-contact.ts" lineNumbers export const addContact = authOrganizationActionClient .metadata({ actionName: 'addContact' }) .schema(addContactSchema) .action(async ({ parsedInput, ctx }) => { const user = ctx.session.user; const organizationId = ctx.organization.id; const memberships = ctx.organization.memberships; }); ``` #### 2. Data Functions Fetching contact details with **`getAuthOrganizationContext`**: ```typescript filename="apps/dashboard/data/contacts/get-contact.ts" lineNumbers export async function getContact(input: GetContactSchema): Promise { const ctx = await getAuthOrganizationContext(); const user = ctx.session.user; const organizationId = ctx.organization.id; } ``` #### 3. Standard Authentication Context Used outside an organization context (e.g., onboarding): ```typescript filename="apps/dashboard/data/get-some-data.ts" lineNumbers export async function getSomeData(): Promise { const ctx = await getAuthContext(); const user = ctx.session.user; } ``` ### Middleware: Automatic Slug Handling To avoid passing organization slugs manually through components, we introduced a middleware that automatically attaches the organization slug as a request header. ```typescript filename="apps/dashboard/middleware.ts" lineNumbers export function middleware(request: NextRequest) { const pathSegments = request.nextUrl.pathname.split('/').filter(Boolean); let slug = pathSegments.length >= 2 && pathSegments[0] === 'organizations' ? pathSegments[1] : null; const response = NextResponse.next(); if (slug) response.headers.set('x-organization-slug', slug); return response; } export const config = { matcher: ['/organizations/:path*'] }; ``` ### Provider & Hook A new `useActiveOrganization` hook provides quick access to the active organization in **client components**. ```typescript filename="apps/dashboard/components/client-component.tsx" lineNumbers 'use client'; export function Component(): React.JSX.Element { const activeOrganization = useActiveOrganization(); return <>{activeOrganization.name}; } ``` ### Screenshots add organization invite members organizations organization organization general settings delete organization --- Ready to build your own multi-organization SaaS? [Get started with Achromatic](/pricing) today. --- ## Drizzle Studio: The Complete Guide to Visual Database Management **URL**: https://www.achromatic.dev/blog/drizzle-studio-complete-guide **Description**: Learn how to use Drizzle Studio to visually browse, edit, and manage your database. Complete tutorial covering setup, configuration, queries, data editing, and advanced features for PostgreSQL, MySQL, and SQLite. **Published**: 2025-01-08 Drizzle Studio is a powerful visual database browser that comes bundled with Drizzle Kit. It provides a clean, intuitive interface for exploring your database schema, running queries, and editing data—all without leaving your development environment. ## What is Drizzle Studio? Drizzle Studio is a lightweight database GUI that launches directly from your terminal. Unlike standalone database tools like pgAdmin, TablePlus, or DBeaver, Drizzle Studio: - **Understands your schema** — It reads your Drizzle schema files directly - **Zero configuration** — Uses your existing `drizzle.config.ts` - **Runs locally** — Opens in your browser at `https://local.drizzle.studio` - **Type-aware** — Displays data according to your TypeScript types ## Quick Start If you already have Drizzle ORM set up in your project, launching Studio is simple: ```bash npx drizzle-kit studio ``` Or if you have a custom config path: ```bash npx drizzle-kit studio --config=drizzle.config.ts ``` This opens Drizzle Studio in your default browser at `https://local.drizzle.studio`. ## Setting Up Drizzle Studio ### Prerequisites 1. **Drizzle ORM** installed in your project 2. **Drizzle Kit** as a dev dependency 3. A valid **drizzle.config.ts** configuration file ### Installation ```bash npm install drizzle-orm npm install -D drizzle-kit ``` ### Configuration Create a `drizzle.config.ts` file in your project root: ```typescript import { defineConfig } from 'drizzle-kit'; export default defineConfig({ dialect: 'postgresql', // or "mysql" | "sqlite" schema: './lib/db/schema/index.ts', out: './lib/db/migrations', dbCredentials: { url: process.env.DATABASE_URL as string } }); ``` ### Adding an npm Script Add a convenient script to your `package.json`: ```json { "scripts": { "db:studio": "drizzle-kit studio --config=drizzle.config.ts" } } ``` Now you can launch Studio with: ```bash npm run db:studio ``` ## Drizzle Studio Features ### 1. Schema Browser The left sidebar displays all your database tables. Click any table to: - View all columns with their types - See indexes and constraints - Understand relationships between tables ### 2. Data Browser Select a table to view its data in a spreadsheet-like interface: - **Pagination** — Navigate through large datasets - **Sorting** — Click column headers to sort - **Filtering** — Use the filter bar to narrow results - **Column resizing** — Drag column borders to resize ### 3. Inline Data Editing Edit data directly in the table view: 1. Double-click any cell to edit 2. Make your changes 3. Press Enter to save or Escape to cancel 4. Changes are committed immediately to the database **Warning:** Edits are permanent. There's no undo button, so be careful when editing production data. ### 4. SQL Query Editor Run custom SQL queries directly in Studio: ```sql SELECT u.name, u.email, COUNT(o.id) as order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id ORDER BY order_count DESC LIMIT 10; ``` Results appear in a table below the query editor. ### 5. Insert New Records Click the "+" button to add new rows: 1. A form appears with all columns 2. Fill in the required fields 3. Click "Insert" to save The form respects your schema constraints (NOT NULL, defaults, etc.). ### 6. Delete Records Select rows using the checkboxes, then click "Delete" to remove them. ## Real-World Schema Example Here's how a typical SaaS schema looks in Drizzle (the same structure visible in Studio): ```typescript import { boolean, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; export const userTable = pgTable('user', { id: uuid('id').primaryKey().defaultRandom(), name: text('name').notNull(), email: text('email').notNull().unique(), emailVerified: boolean('email_verified').notNull().default(false), image: text('image'), role: text('role').notNull().default('user'), createdAt: timestamp('created_at', { withTimezone: true }) .notNull() .defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }) .notNull() .defaultNow() .$onUpdate(() => new Date()) }); export const organizationTable = pgTable('organization', { id: uuid('id').primaryKey().defaultRandom(), name: text('name').notNull(), slug: text('slug').notNull().unique(), logo: text('logo'), createdAt: timestamp('created_at', { withTimezone: true }) .notNull() .defaultNow() }); export const memberTable = pgTable('member', { id: uuid('id').primaryKey().defaultRandom(), organizationId: uuid('organization_id') .notNull() .references(() => organizationTable.id, { onDelete: 'cascade' }), userId: uuid('user_id') .notNull() .references(() => userTable.id, { onDelete: 'cascade' }), role: text('role').notNull().default('member'), createdAt: timestamp('created_at', { withTimezone: true }) .notNull() .defaultNow() }); ``` In Drizzle Studio, you'll see these tables with their relationships, making it easy to understand your data model. ## Drizzle Studio vs Other Database GUIs | Feature | Drizzle Studio | pgAdmin | TablePlus | DBeaver | | ---------------------- | -------------- | --------------- | --------- | ------- | | Price | Free | Free | $99 | Free | | Schema-aware | ✅ Yes | ❌ No | ❌ No | ❌ No | | Zero config | ✅ Yes | ❌ No | ❌ No | ❌ No | | TypeScript integration | ✅ Yes | ❌ No | ❌ No | ❌ No | | Multi-database support | ✅ Yes | PostgreSQL only | ✅ Yes | ✅ Yes | | Inline editing | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | | Query builder | ❌ No | ✅ Yes | ✅ Yes | ✅ Yes | | ERD diagrams | ❌ No | ✅ Yes | ❌ No | ✅ Yes | **Verdict:** Drizzle Studio excels at quick, schema-aware database browsing during development. For complex database administration, use a full-featured tool alongside it. ## Common Use Cases ### 1. Debugging Data Issues When your app behaves unexpectedly, Studio lets you quickly inspect the actual database state: ```bash npm run db:studio ``` Navigate to the relevant table and verify the data matches your expectations. ### 2. Seeding Test Data During development, manually insert test records: 1. Open the target table 2. Click "+" to add a row 3. Fill in test values 4. Repeat as needed ### 3. Cleaning Up Development Data Select all rows (or use a filter) and delete them to reset your local database state. ### 4. Verifying Migrations After running a migration, open Studio to confirm: - New tables exist - Columns have correct types - Indexes are in place - Constraints are applied ## Advanced Configuration ### Custom Port Run Studio on a different port: ```bash npx drizzle-kit studio --port 3333 ``` ### Verbose Mode Enable detailed logging: ```bash npx drizzle-kit studio --verbose ``` ### Multiple Databases If your project uses multiple databases, create separate config files: ```bash npx drizzle-kit studio --config=drizzle.primary.config.ts npx drizzle-kit studio --config=drizzle.analytics.config.ts ``` ## Troubleshooting ### "Cannot connect to database" 1. Verify your `DATABASE_URL` environment variable is set 2. Ensure the database server is running 3. Check firewall/network settings ```bash # Load env vars before running dotenv -e .env -- npx drizzle-kit studio ``` ### "Schema not found" Ensure your `schema` path in `drizzle.config.ts` points to an existing file: ```typescript export default defineConfig({ schema: './lib/db/schema/index.ts' // Must exist! // ... }); ``` ### "Tables not showing" If you're using a `tablesFilter`, ensure it matches your table names: ```typescript export default defineConfig({ tablesFilter: ['user_*', 'organization_*'] // Only shows matching tables // ... }); ``` ## Best Practices 1. **Never use Studio on production databases** — Accidental edits can cause data loss 2. **Use for development only** — Keep your production database access locked down 3. **Combine with migrations** — Don't use Studio to make schema changes; use migrations instead 4. **Document your schema** — Studio makes it easy to understand your data model; share screenshots with your team ## Conclusion Drizzle Studio is an excellent companion for Drizzle ORM development. It removes the friction of switching to external database tools and provides a fast, schema-aware way to interact with your data. For a production-ready Next.js starter kit with Drizzle ORM already configured (including Studio), check out [Achromatic's Drizzle starter kit](/docs/starter-kits/monorepo-next-drizzle-authjs). ## Related Resources - [Drizzle ORM Documentation](https://orm.drizzle.team/docs/overview) - [Drizzle Kit CLI Reference](https://orm.drizzle.team/docs/kit-overview) - [Multi-Tenancy with Drizzle ORM](/blog/multi-tenancy-implementation-guide) --- ## Prisma Studio: Complete Guide to Visual Database Management **URL**: https://www.achromatic.dev/blog/prisma-studio-complete-guide **Description**: Master Prisma Studio with this comprehensive guide. Learn how to browse, query, edit, and manage your database visually. Covers setup, features, filtering, relations, and best practices for PostgreSQL, MySQL, and SQLite. **Published**: 2025-01-08 Prisma Studio is a visual database browser that ships with Prisma ORM. It provides an intuitive GUI for exploring your data, understanding relationships, and making quick edits—all without writing SQL or leaving your development environment. ## What is Prisma Studio? Prisma Studio is a free, built-in tool that comes with every Prisma installation. Unlike standalone database clients, it: - **Understands your schema** — Reads your `schema.prisma` file directly - **Shows relationships visually** — Navigate between related records easily - **Runs locally** — Opens in your browser at `http://localhost:5555` - **Zero configuration** — Works immediately after Prisma setup ## Quick Start If you have Prisma set up, launching Studio takes one command: ```bash npx prisma studio ``` This opens Prisma Studio in your browser. That's it—no configuration needed. ## Setting Up Prisma Studio ### Prerequisites 1. **Node.js** 16.13 or higher 2. **Prisma** installed in your project 3. A valid `schema.prisma` file with database connection ### Installation If you're starting fresh: ```bash npm install prisma --save-dev npm install @prisma/client ``` Initialize Prisma: ```bash npx prisma init ``` ### Configuration Your `schema.prisma` should have a datasource configured: ```prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" // or "mysql" | "sqlite" url = env("DATABASE_URL") } ``` Set your `DATABASE_URL` in `.env`: ```bash DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public" ``` ### Adding an npm Script Add a convenient script to your `package.json`: ```json { "scripts": { "db:studio": "prisma studio" } } ``` Now launch with: ```bash npm run db:studio ``` ## Prisma Studio Features ### 1. Model Browser The left sidebar shows all your Prisma models. Each model displays: - **Record count** — Total rows in the table - **Fields** — Column names and types - **Relations** — Connected models Click any model to view its data. ### 2. Data Table View The main panel shows records in a spreadsheet-like interface: - **Pagination** — Navigate through large datasets (25, 50, 100 rows per page) - **Column sorting** — Click headers to sort ascending/descending - **Column visibility** — Hide/show columns as needed - **Resizable columns** — Drag borders to adjust width ### 3. Filtering and Search Use the filter bar to narrow down records: ``` // Filter by exact value email = "user@example.com" // Filter by partial match name contains "John" // Filter by comparison createdAt > 2024-01-01 // Multiple conditions status = "active" AND role = "admin" ``` Supported operators: - `=` (equals) - `!=` (not equals) - `>`, `<`, `>=`, `<=` (comparisons) - `contains` (partial string match) - `startsWith`, `endsWith` - `in` (list of values) ### 4. Inline Data Editing Edit records directly in the table: 1. Click any cell to select it 2. Double-click to enter edit mode 3. Modify the value 4. Press Enter to save, Escape to cancel Changes are saved immediately to the database. ### 5. Creating New Records Click the **Add record** button to insert new data: 1. A form appears with all model fields 2. Required fields are marked with asterisks 3. Default values are pre-filled 4. Relations can be selected from dropdowns 5. Click **Save** to insert ### 6. Deleting Records Select records using checkboxes, then click **Delete selected**. You'll be asked to confirm before deletion. **Warning:** Deletes cascade according to your schema relations. Review your `onDelete` settings carefully. ### 7. Relation Navigation One of Studio's best features is exploring relations: - **One-to-one** — Click the related record to view it - **One-to-many** — See a count and click to expand - **Many-to-many** — Browse junction tables seamlessly For example, if a `User` has many `Posts`, click the posts count to see all related posts, then click any post to view its details. ## Real-World Schema Example Here's a typical SaaS schema in Prisma (what you'd see in Studio): ```prisma model User { id String @id @default(uuid()) @db.Uuid name String email String @unique emailVerified Boolean @default(false) @map("email_verified") image String? role UserRole @default(user) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") accounts Account[] memberships Member[] @@map("user") } model Organization { id String @id @default(uuid()) @db.Uuid name String slug String @unique logo String? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") members Member[] subscriptions Subscription[] @@map("organization") } model Member { id String @id @default(uuid()) @db.Uuid organizationId String @map("organization_id") @db.Uuid userId String @map("user_id") @db.Uuid role MemberRole @default(member) createdAt DateTime @default(now()) @map("created_at") organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([organizationId, userId]) @@map("member") } enum UserRole { user admin } enum MemberRole { owner admin member } ``` In Prisma Studio, you'll see `User`, `Organization`, and `Member` models with clickable relation counts. ## Prisma Studio vs Other Database GUIs | Feature | Prisma Studio | pgAdmin | TablePlus | DBeaver | | ------------------- | ------------- | --------------- | --------- | ------- | | Price | Free | Free | $99 | Free | | Schema-aware | ✅ Yes | ❌ No | ❌ No | ❌ No | | Relation navigation | ✅ Yes | ❌ No | Limited | Limited | | Zero config | ✅ Yes | ❌ No | ❌ No | ❌ No | | Multi-database | ✅ Yes | PostgreSQL only | ✅ Yes | ✅ Yes | | Raw SQL | ❌ No | ✅ Yes | ✅ Yes | ✅ Yes | | ERD diagrams | ❌ No | ✅ Yes | ❌ No | ✅ Yes | | Query builder | ❌ No | ✅ Yes | ✅ Yes | ✅ Yes | **Best for:** Quick data exploration and editing during development. Pair with a full-featured tool for complex queries. ## Common Use Cases ### 1. Debugging User Issues When a customer reports a problem: ```bash npm run db:studio ``` Filter the `User` model by email, then navigate through their related records to understand the issue. ### 2. Manual Data Seeding During development, quickly add test data: 1. Open the target model 2. Click **Add record** 3. Fill in values 4. Repeat for related records ### 3. Verifying Migrations After running `npx prisma migrate dev`: 1. Open Studio 2. Check new models appear 3. Verify field types are correct 4. Confirm indexes and constraints ### 4. Customer Support Quickly look up customer data, verify subscription status, or check order history without writing queries. ## Advanced Usage ### Custom Port Run Studio on a different port: ```bash npx prisma studio --port 5556 ``` ### Browser Selection Open in a specific browser: ```bash npx prisma studio --browser firefox ``` ### Headless Mode (No Browser) Run without auto-opening a browser: ```bash npx prisma studio --browser none ``` Then manually navigate to `http://localhost:5555`. ### Multiple Schemas If using Prisma's multi-schema feature: ```prisma datasource db { provider = "postgresql" url = env("DATABASE_URL") schemas = ["public", "auth", "billing"] } ``` Studio will show models from all configured schemas. ## Troubleshooting ### "Error: P1001: Can't reach database server" 1. Verify your database is running 2. Check `DATABASE_URL` in `.env` 3. Ensure network connectivity (firewall, VPN) ```bash # Test connection npx prisma db pull ``` ### "No models found" Your `schema.prisma` might be empty or invalid: ```bash # Validate schema npx prisma validate # Pull schema from existing database npx prisma db pull ``` ### Studio Shows Stale Data Studio caches some data. Force refresh with: 1. Close the Studio browser tab 2. Stop the Studio process (Ctrl+C) 3. Restart with `npx prisma studio` ### Relations Not Showing Ensure your schema defines relations properly: ```prisma model Post { id String @id @default(uuid()) authorId String @map("author_id") // This relation MUST be defined author User @relation(fields: [authorId], references: [id]) } model User { id String @id @default(uuid()) // Back-relation posts Post[] } ``` ## Best Practices 1. **Development only** — Don't use Studio on production databases. Accidental edits can cause data loss. 2. **Use migrations for schema changes** — Never modify your schema through Studio; use `prisma migrate` instead. 3. **Be careful with cascades** — Deleting a record may cascade to related tables. Always check your `onDelete` rules. 4. **Combine with Prisma Client** — For complex operations, write proper Prisma Client code. Studio is for quick exploration, not complex workflows. 5. **Commit your schema** — Always commit `schema.prisma` changes so your team sees the same structure. ## Prisma Studio vs Drizzle Studio If you're choosing between Prisma and Drizzle for a new project: | Aspect | Prisma Studio | Drizzle Studio | | ------------------- | -------------------- | ----------------------- | | Schema source | `schema.prisma` file | TypeScript schema files | | Relation navigation | Excellent | Good | | Type inference | Generated types | Native TypeScript | | Performance | Good | Slightly better | | Learning curve | Lower | Higher | Both tools serve similar purposes. Choose based on your ORM preference. ## Conclusion Prisma Studio removes friction from database exploration during development. Its schema-aware interface and relation navigation make it significantly faster than generic database tools for day-to-day development tasks. For a production-ready Next.js starter kit with Prisma already configured (including Studio), check out [Achromatic's Prisma starter kit](/docs/starter-kits/monorepo-next-prisma-authjs). ## Related Resources - [Prisma Documentation](https://www.prisma.io/docs) - [Prisma Migrate Guide](https://www.prisma.io/docs/orm/prisma-migrate) - [Multi-Tenancy Implementation Guide](/blog/multi-tenancy-implementation-guide) - [Drizzle Studio Guide](/blog/drizzle-studio-complete-guide) --- ## Marketing Pages **URL**: https://www.achromatic.dev/blog/marketing-pages **Description**: The most requested feature is here! Level up your SaaS by adding a landing page, docs, blog and many other pages! **Published**: 2024-11-29 ### Summary We've built a comprehensive set of marketing pages to cover every touchpoint of your SaaS: - **Landing** – A beautiful page to showcase your product, optimized for clear value proposition and call-to-action. - **Blog** – Integrated blogging system that is statically generated during build time. - **Docs** – Integrated documentation pages that is statically generated during build time. - **Pricing** – Responsive pricing tables with a plan comparisons and conversion-optimized design. - **Story** – Authentic company narrative page that builds trust and connects with your audience. - **Legal** – Templates for Terms of Service, Privacy Policy and Cookie Policy. - **Careers** – Values and job listings page. - **Contact** – Contact form. Both the blog and docs are using content collections. That means new content can be added via MDX files. ### Design Philosophy The design follows the latest web trends: a clean grid system with guide lines and subtle hatching patterns. ### Landing Page Achromatic SaaS landing page hero section with headline, value proposition, and call-to-action buttons in light theme Achromatic SaaS landing page hero section with headline, value proposition, and call-to-action buttons in dark theme Achromatic landing page bento grid layout showcasing feature cards with icons and descriptions in light theme Achromatic landing page bento grid layout showcasing feature cards with icons and descriptions in dark theme ### Blog Pages Achromatic blog index page displaying article cards with titles, dates, and featured images in light theme Achromatic blog index page displaying article cards with titles, dates, and featured images in dark theme Achromatic blog post detail page with article content, code blocks, and typography in light theme Achromatic blog post detail page with article content, code blocks, and typography in dark theme ### Docs Achromatic documentation page with sidebar navigation, table of contents, and MDX content in light theme Achromatic documentation page with sidebar navigation, table of contents, and MDX content in dark theme ### Pricing Page Achromatic pricing page with feature comparison table and call-to-action buttons in light theme Achromatic pricing page with feature comparison table and call-to-action buttons in dark theme ### Story Page Achromatic company story page with team narrative and brand values section in light theme Achromatic company story page with team narrative and brand values section in dark theme ### Legal Pages Achromatic terms of service legal page with structured content and typography in light theme Achromatic terms of service legal page with structured content and typography in dark theme ...also privacy policy page and cookie policy page. ### Careers Page Achromatic careers page displaying company values and open job positions in light theme Achromatic careers page displaying company values and open job positions in dark theme ### Contact Page Achromatic contact page with form fields for name, email, and message in light theme Achromatic contact page with form fields for name, email, and message in dark theme ## How to update The good thing, given the size of the update, there are not many changes, just a lot of addtions. #### Automatic Update If you use the Git upstream repository, a simple git pull will already keep you up-to-date. #### Manual update Add following routes to `@/constants/routes.ts` ```typescript filename="@/constants/routes.ts" lineNumbers enum Routes { // ... Contact = '/contact', Docs = '/docs', Pricing = '/pricing', Blog = '/blog', Story = '/story', Careers = '/careers', TermsOfUse = '/terms-of-use', PrivacyPolicy = '/privacy-policy', CookiePolicy = '/cookie-policy' // ... } ``` Add the following hook `@/hooks/use-mounted.tsx` ```tsx filename="@/hooks/use-mounted.tsx" lineNumbers import * as React from 'react'; export function useMounted(): boolean { const [mounted, setMounted] = React.useState(false); React.useEffect(() => { setMounted(true); }, []); return mounted; } ``` Add or change your `package.json` ```json filename="package.json" lineNumbers // scripts "build": "content-collections build && next build", "build:content": "content-collections build", "typecheck": "content-collections build && tsc --noEmit", // dependencies "@radix-ui/react-portal": "1.1.2", "framer-motion": "11.11.17", "mdast-util-toc": "7.1.0", "react-remove-scroll": "2.6.0", "unist-util-visit": "5.0.0" // dev dependencies "@content-collections/cli": "0.1.6", "@content-collections/core": "0.7.3", "@content-collections/mdx": "0.2.0", "@content-collections/next": "0.2.4", "@types/unist": "3.0.3", "rehype": "13.0.2", "rehype-autolink-headings": "7.1.0", "rehype-pretty-code": "0.14.0", "rehype-slug": "6.0.0", "remark": "15.0.1", "remark-code-import": "1.2.0", "remark-gfm": "4.0.0", "shiki": "1.23.1" ``` Add following path to `tsconfig.json` ```json filename="tsconfig.json" lineNumbers "content-collections": ["./.content-collections/generated"] ``` Add following css helpers to your `tailwind.config.cjs` ```javascript filename="tailwind.config.cjs" lineNumbers backgroundImage: { 'diagonal-lines': 'repeating-linear-gradient(-45deg, hsl(var(--background)), hsl(var(--border)) 1px, hsl(var(--background)) 1px, hsl(var(--background)) 8px)' }, keyFrames: { marquee: { from: { transform: 'translateX(0)' }, to: { transform: 'translateX(calc(-100% - var(--gap)))' } }, 'marquee-vertical': { from: { transform: 'translateY(0)' }, to: { transform: 'translateY(calc(-100% - var(--gap)))' } } }, animation: { marquee: 'marquee var(--duration) linear infinite', 'marquee-vertical': 'marquee-vertical var(--duration) linear infinite' } ``` Now in your `next-config.mjs` remove the standard redirect: ```javascript filename="next-config.mjs" lineNumbers { source: '/', destination: '/dashboard/home', permanent: false } ``` Then add content collections to the same file ```typescript filename="next-config.mjs" lineNumbers import { withContentCollections } from '@content-collections/next'; export default withContentCollections(bundleAnalyzerConfig(nextConfig)); ``` Optionally you can also add the remote patterns for the example avatars ```javascript filename="next-config.mjs" lineNumbers images: { remotePatterns: [ { protocol: 'https', hostname: 'randomuser.me', port: '', pathname: '**', search: '' } ] }, ``` Now add the file `content-collections.ts` at the root level ```typescript filename="content-collections.ts" lineNumbers import path from 'path'; import { defineCollection, defineConfig } from '@content-collections/core'; import { compileMDX } from '@content-collections/mdx'; import rehypeAutolinkHeadings from 'rehype-autolink-headings'; import rehypePrettyCode, { Options } from 'rehype-pretty-code'; import rehypeSlug from 'rehype-slug'; import { codeImport } from 'remark-code-import'; import remarkGfm from 'remark-gfm'; import { createHighlighter } from 'shiki'; const prettyCodeOptions: Options = { theme: 'github-dark', getHighlighter: (options) => createHighlighter({ ...options }), onVisitLine(node) { // Prevent lines from collapsing in `display: grid` mode, and allow empty // lines to be copy/pasted if (node.children.length === 0) { node.children = [{ type: 'text', value: ' ' }]; } }, onVisitHighlightedLine(node) { if (!node.properties.className) { node.properties.className = []; } node.properties.className.push('line--highlighted'); }, onVisitHighlightedChars(node) { if (!node.properties.className) { node.properties.className = []; } node.properties.className = ['word--highlighted']; } }; export const authors = defineCollection({ name: 'author', directory: 'content', include: '**/authors/*.mdx', schema: (z) => ({ ref: z.string(), name: z.string().default('Anonymous'), avatar: z.string().url().default('') }) }); export const posts = defineCollection({ name: 'post', directory: 'content', include: '**/blog/*.mdx', schema: (z) => ({ title: z.string(), description: z.string(), published: z.string().datetime(), category: z.string().default('Miscellaneous'), author: z.string() }), transform: async (data, context) => { const body = await compileMDX(context, data, { remarkPlugins: [ remarkGfm, // GitHub Flavored Markdown support codeImport // Enables code imports in markdown ], rehypePlugins: [ rehypeSlug, // Automatically add slugs to headings rehypeAutolinkHeadings, // Auto-link headings for easy navigation [rehypePrettyCode, prettyCodeOptions] ] }); const author = context .documents(authors) .find((a) => a.ref === data.author); return { ...data, author, slug: `/${data._meta.path}`, slugAsParams: data._meta.path.split(path.sep).slice(1).join('/'), body: { raw: data.content, code: body } }; } }); export const docs = defineCollection({ name: 'doc', directory: 'content', include: '**/docs/*.mdx', schema: (z) => ({ title: z.string(), description: z.string() }), transform: async (data, context) => { const body = await compileMDX(context, data, { remarkPlugins: [ remarkGfm, // GitHub Flavored Markdown support codeImport // Enables code imports in markdown ], rehypePlugins: [ rehypeSlug, // Automatically add slugs to headings rehypeAutolinkHeadings, // Auto-link headings for easy navigation [rehypePrettyCode, prettyCodeOptions] ] }); return { ...data, slug: `/${data._meta.path}`, slugAsParams: data._meta.path.split(path.sep).slice(1).join('/'), body: { raw: data.content, code: body } }; } }); export default defineConfig({ collections: [authors, posts, docs] }); ``` Add the stylesheet `@/app/mdx.css` ```css filename="@/app/mdx.css" lineNumbers [data-rehype-pretty-code-figure] code { @apply grid min-w-full break-words rounded-none border-0 bg-transparent p-0; counter-reset: line; box-decoration-break: clone; } [data-rehype-pretty-code-figure] [data-line] { @apply inline-block min-h-[1rem] w-full px-4 py-0.5; } [data-rehype-pretty-code-figure] [data-line-numbers] [data-line] { @apply px-2; } [data-rehype-pretty-code-figure] .line-highlighted span { @apply relative; } [data-rehype-pretty-code-title] { @apply mt-2 px-4 pt-6 text-sm font-medium text-foreground; } [data-rehype-pretty-code-title] + pre { @apply mt-2; } ``` We are set and done! Only content is missing now. Download the last version of the repository and copy following content - @/app/(marketing)/\* - @/components/marketing/\* - @/lib/markdown/get-table-of-contents.ts - @/content/\* As a last step you can optionally link back from your auth pages changing `@/components/auth/auth-container.tsx` by wrapping the logo in a link ```tsx filename="@/components/auth/auth-container.tsx" lineNumbers ``` And add the legal links in your sign up page. We are done! You just added ~9k lines of website code. ### Starter kit implementation - [Pro Prisma marketing documentation](/docs/starter-kits/pro-nextjs-prisma/marketing/overview) - [Pro Drizzle marketing documentation](/docs/starter-kits/pro-nextjs-drizzle/marketing/overview) --- Ready to launch your SaaS with beautiful marketing pages? [Get started with Achromatic](/pricing). --- ## Using the new Shadcn Sidebar **URL**: https://www.achromatic.dev/blog/shadcn-sidebar **Description**: Shadcn UI recently introduced a powerful new sidebar component, featuring a modern and refined design that allows for a more customizable sidebar experience. The latest version of Achromatic also integrates the new shadcn sidebar - with a few adjustments. **Published**: 2024-11-14 ### How does it look? We kept the original Achromatic sidebar look, but had to make a few adjustments. - **Sidebar Width**: Set to 240px in shadcn/ui for alignment with common sidebar standards. - **Collapsed Sidebar Width**: Adjusted to 56px for smooth transitions. - **Group Padding**: Added padding around groups in shadcn/ui for improved layout. - **Vertical Spacing**: Removed extra space between the logo and main menu. - **Menu Item Height**: Adjusted to 36px for easier, more accessible clicking. - **Trigger Icon**: Support for different/multiple icons. sidebar desktop open sidebar desktop open sidebar desktop collapsed sidebar desktop collapsed sidebar mobile closed sidebar mobile closed sidebar mobile open sidebar mobile open ### Extras - **Collapsed Sidebar**: Achromatic ensures a collapsed sidebar on large (lg) to extra-large (xl) screen sizes. This approach provides consistent content space, minimizing the need for responsive adjustments within these widths. - **Double Sidebar**: For settings a secondary sidebar is used, visible only on desktop and larger screens. - **Mobile Sheet**: On mobile devices a single sheet is used to represent both sidebars. - **Drag & Drop**: The favorites list supports drag-and-drop functionality. For smooth transitions we had to adjust the structure a bit. sidebar laptop sidebar laptop ### Automatic Update A simple `git pull` will update your project. ### Manual Update **New component** There is a new component `./components/ui/sidebar.tsx` that contains the new shadcn/ui sidebar. ```tsx filename="./components/ui/sidebar.tsx" lineNumbers 'use client'; import * as React from 'react'; import { usePathname } from 'next/navigation'; import { Slot } from '@radix-ui/react-slot'; import { VariantProps, cva } from 'class-variance-authority'; import { ChevronLeftIcon, ChevronRightIcon, MenuIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet'; import { Skeleton } from '@/components/ui/skeleton'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useMediaQuery } from '@/hooks/use-media-query'; import { cn } from '@/lib/utils'; const SIDEBAR_COOKIE_NAME = 'sidebar:state'; const SIDEBAR_COOKIE_MAX_AGE = 60 _ 60 _ 24 \* 7; const SIDEBAR_WIDTH = '15rem'; const SIDEBAR_WIDTH_MOBILE = '18rem'; const SIDEBAR_WIDTH_ICON = '4rem'; const SIDEBAR_KEYBOARD_SHORTCUT = 'b'; const MOBILE_BREAKPOINT = 1024; type SidebarContext = { state: 'expanded' | 'collapsed'; open: boolean; setOpen: (open: boolean) => void; openMobile: boolean; setOpenMobile: (open: boolean) => void; isMobile: boolean; toggleSidebar: () => void; }; const SidebarContext = React.createContext(null); function useSidebar(): SidebarContext { const context = React.useContext(SidebarContext); if (!context) { throw new Error('useSidebar must be used within a SidebarProvider.'); } return context; } export type SidebarProviderElement = HTMLDivElement; export type SidebarProviderProps = React.ComponentProps<'div'> & { defaultOpen?: boolean; open?: boolean; onOpenChange?: (open: boolean) => void; }; const SidebarProvider = React.forwardRef< SidebarProviderElement, SidebarProviderProps >( ( { defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props }, ref ) => { const isMobile = useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`, { ssr: true, fallback: false }); const [openMobile, setOpenMobile] = React.useState(false); // This is the internal state of the sidebar. // We use openProp and setOpenProp for control from outside the component. const [_open, _setOpen] = React.useState(defaultOpen); const open = openProp ?? _open; const setOpen = React.useCallback( (value: boolean | ((value: boolean) => boolean)) => { const openState = typeof value === 'function' ? value(open) : value; if (setOpenProp) { setOpenProp(openState); } else { _setOpen(openState); } // This sets the cookie to keep the sidebar state. document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; }, [setOpenProp, open] ); // Helper to toggle the sidebar. const toggleSidebar = React.useCallback(() => { return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open); }, [isMobile, setOpen, setOpenMobile]); // Adds a keyboard shortcut to toggle the sidebar. React.useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if ( event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey) ) { event.preventDefault(); toggleSidebar(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [toggleSidebar]); // We add a state so that we can do data-state="expanded" or "collapsed". // This makes it easier to style the sidebar with Tailwind classes. const state = open ? 'expanded' : 'collapsed'; const contextValue = React.useMemo( () => ({ state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar }), [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] ); return (
{children}
); } ); SidebarProvider.displayName = 'SidebarProvider'; export type SidebarElement = HTMLDivElement; export type SidebarProps = React.ComponentProps<'div'> & { side?: 'left' | 'right'; variant?: 'sidebar' | 'floating' | 'inset'; collapsible?: 'offcanvas' | 'icon' | 'none'; }; const Sidebar = React.forwardRef( ( { side = 'left', variant = 'sidebar', collapsible = 'offcanvas', className, children, ...props }, ref ) => { const pathname = usePathname(); const { isMobile, state, openMobile, setOpenMobile } = useSidebar(); React.useEffect(() => { setOpenMobile(false); }, [pathname]); if (collapsible === 'none') { return (
{children}
); } if (isMobile) { return ( Menu Mobile menu
{children}
); } return (
{/* This is what handles the sidebar gap on desktop */}
); } ); Sidebar.displayName = 'Sidebar'; export type SidebarTriggerElement = React.ElementRef; export type SidebarTriggerProps = React.ComponentProps & { icon?: 'menu' | 'chevronLeft' | 'chevronRight'; }; const SidebarTrigger = React.forwardRef< SidebarTriggerElement, SidebarTriggerProps >(({ className, onClick, icon = 'menu', ...props }, ref) => { const { toggleSidebar } = useSidebar(); return ( ); }); SidebarTrigger.displayName = 'SidebarTrigger'; export type SidebarRailElement = React.ElementRef; export type SidebarRailProps = React.ComponentProps<'button'>; const SidebarRail = React.forwardRef( ({ className, ...props }, ref) => { const { toggleSidebar } = useSidebar(); return ( ); } ``` ## Handling Webhooks Webhooks are essential for keeping your database in sync with Stripe. This is the most critical part of your billing implementation: ```typescript filename="app/api/webhooks/stripe/route.ts" lineNumbers import { headers } from 'next/headers'; import { NextResponse } from 'next/server'; import type Stripe from 'stripe'; import { getStripe } from '@/lib/stripe'; const relevantEvents = new Set([ 'checkout.session.completed', 'customer.subscription.created', 'customer.subscription.updated', 'customer.subscription.deleted', 'invoice.payment_succeeded', 'invoice.payment_failed' ]); export async function POST(req: Request) { const body = await req.text(); const requestHeaders = await headers(); const signature = requestHeaders.get('stripe-signature'); if (!signature) { return NextResponse.json( { error: 'Missing stripe-signature header' }, { status: 400 } ); } const stripe = getStripe(); let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( body, signature, process.env.STRIPE_WEBHOOK_SECRET! ); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; console.error(`Webhook signature verification failed: ${message}`); return NextResponse.json( { error: `Webhook Error: ${message}` }, { status: 400 } ); } if (!relevantEvents.has(event.type)) { return NextResponse.json({ received: true }); } try { switch (event.type) { case 'checkout.session.completed': { const session = event.data.object as Stripe.Checkout.Session; await handleCheckoutCompleted(session); break; } case 'customer.subscription.created': case 'customer.subscription.updated': { const subscription = event.data.object as Stripe.Subscription; await handleSubscriptionChange(subscription); break; } case 'customer.subscription.deleted': { const subscription = event.data.object as Stripe.Subscription; await handleSubscriptionDeleted(subscription); break; } case 'invoice.payment_succeeded': { const invoice = event.data.object as Stripe.Invoice; await handleInvoicePaid(invoice); break; } case 'invoice.payment_failed': { const invoice = event.data.object as Stripe.Invoice; await handleInvoiceFailed(invoice); break; } } return NextResponse.json({ received: true }); } catch (error) { console.error('Webhook handler error:', error); return NextResponse.json( { error: 'Webhook handler failed' }, { status: 500 } ); } } async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { const userId = session.metadata?.userId; const subscriptionId = session.subscription as string; if (!userId || !subscriptionId) return; const stripe = getStripe(); // Fetch the full subscription object const subscription = await stripe.subscriptions.retrieve(subscriptionId, { expand: ['items.data.price.product'] }); // Update user's subscription in database await db.subscription.upsert({ where: { userId }, create: { userId, stripeSubscriptionId: subscription.id, stripePriceId: subscription.items.data[0].price.id, stripeCustomerId: subscription.customer as string, status: subscription.status, currentPeriodStart: new Date(subscription.current_period_start * 1000), currentPeriodEnd: new Date(subscription.current_period_end * 1000) }, update: { stripeSubscriptionId: subscription.id, stripePriceId: subscription.items.data[0].price.id, status: subscription.status, currentPeriodStart: new Date(subscription.current_period_start * 1000), currentPeriodEnd: new Date(subscription.current_period_end * 1000) } }); } async function handleSubscriptionChange(subscription: Stripe.Subscription) { const customerId = subscription.customer as string; // Find user by Stripe customer ID const user = await db.user.findFirst({ where: { stripeCustomerId: customerId } }); if (!user) return; await db.subscription.upsert({ where: { userId: user.id }, create: { userId: user.id, stripeSubscriptionId: subscription.id, stripePriceId: subscription.items.data[0].price.id, stripeCustomerId: customerId, status: subscription.status, currentPeriodStart: new Date(subscription.current_period_start * 1000), currentPeriodEnd: new Date(subscription.current_period_end * 1000), cancelAtPeriodEnd: subscription.cancel_at_period_end }, update: { stripePriceId: subscription.items.data[0].price.id, status: subscription.status, currentPeriodStart: new Date(subscription.current_period_start * 1000), currentPeriodEnd: new Date(subscription.current_period_end * 1000), cancelAtPeriodEnd: subscription.cancel_at_period_end } }); } async function handleSubscriptionDeleted(subscription: Stripe.Subscription) { await db.subscription.updateMany({ where: { stripeSubscriptionId: subscription.id }, data: { status: 'canceled' } }); } async function handleInvoicePaid(invoice: Stripe.Invoice) { // Update subscription period dates if (invoice.subscription) { const stripe = getStripe(); const subscription = await stripe.subscriptions.retrieve( invoice.subscription as string ); await db.subscription.updateMany({ where: { stripeSubscriptionId: subscription.id }, data: { status: subscription.status, currentPeriodStart: new Date(subscription.current_period_start * 1000), currentPeriodEnd: new Date(subscription.current_period_end * 1000) } }); } } async function handleInvoiceFailed(invoice: Stripe.Invoice) { // Send notification to user about failed payment const customerId = invoice.customer as string; const user = await db.user.findFirst({ where: { stripeCustomerId: customerId } }); if (user) { await sendEmail({ to: user.email, subject: 'Payment Failed', template: 'payment-failed', data: { invoiceUrl: invoice.hosted_invoice_url, amount: (invoice.amount_due / 100).toFixed(2) } }); } } ``` ## Customer Portal Let users manage their subscriptions through Stripe's hosted Customer Portal: ```typescript filename="app/actions/portal.ts" lineNumbers 'use server'; import { redirect } from 'next/navigation'; import { auth } from '@/lib/auth'; import { getStripe } from '@/lib/stripe'; export async function createPortalSession() { const session = await auth(); if (!session?.user?.id) { redirect('/login'); } const user = await db.user.findUnique({ where: { id: session.user.id }, select: { stripeCustomerId: true } }); if (!user?.stripeCustomerId) { redirect('/pricing'); } const stripe = getStripe(); const portalSession = await stripe.billingPortal.sessions.create({ customer: user.stripeCustomerId, return_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing` }); redirect(portalSession.url); } ``` ## Subscription Management Actions Create actions for common subscription operations: ```typescript filename="app/actions/subscription.ts" lineNumbers 'use server'; import { revalidatePath } from 'next/cache'; import { auth } from '@/lib/auth'; import { getStripe } from '@/lib/stripe'; export async function cancelSubscription() { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); const subscription = await db.subscription.findUnique({ where: { userId: session.user.id } }); if (!subscription) throw new Error('No subscription found'); const stripe = getStripe(); // Cancel at period end (user keeps access until then) await stripe.subscriptions.update(subscription.stripeSubscriptionId, { cancel_at_period_end: true }); revalidatePath('/billing'); return { success: true }; } export async function resumeSubscription() { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); const subscription = await db.subscription.findUnique({ where: { userId: session.user.id } }); if (!subscription) throw new Error('No subscription found'); const stripe = getStripe(); await stripe.subscriptions.update(subscription.stripeSubscriptionId, { cancel_at_period_end: false }); revalidatePath('/billing'); return { success: true }; } export async function changePlan(newPriceId: string) { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); const subscription = await db.subscription.findUnique({ where: { userId: session.user.id } }); if (!subscription) throw new Error('No subscription found'); const stripe = getStripe(); // Get current subscription from Stripe const stripeSubscription = await stripe.subscriptions.retrieve( subscription.stripeSubscriptionId ); // Update the subscription with new price await stripe.subscriptions.update(subscription.stripeSubscriptionId, { items: [ { id: stripeSubscription.items.data[0].id, price: newPriceId } ], proration_behavior: 'create_prorations' }); revalidatePath('/billing'); return { success: true }; } ``` ## One-Time Payments For one-time purchases (like lifetime deals): ```typescript filename="app/actions/one-time-payment.ts" lineNumbers 'use server'; import { redirect } from 'next/navigation'; import { auth } from '@/lib/auth'; import { getStripe } from '@/lib/stripe'; export async function createOneTimePayment(priceId: string) { const session = await auth(); if (!session?.user?.id) { redirect('/login'); } const stripe = getStripe(); const checkoutSession = await stripe.checkout.sessions.create({ mode: 'payment', // Not 'subscription' payment_method_types: ['card'], line_items: [ { price: priceId, quantity: 1 } ], success_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing?success=true`, cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`, customer_email: session.user.email!, metadata: { userId: session.user.id, type: 'lifetime' }, invoice_creation: { enabled: true } }); redirect(checkoutSession.url!); } ``` ## Metered/Usage-Based Billing For APIs or usage-based pricing: ```typescript filename="lib/billing/usage.ts" lineNumbers import { getStripe } from '@/lib/stripe'; // Create a meter and attach it to a recurring price export async function createMeteredPrice(productId: string) { const stripe = getStripe(); const meter = await stripe.billing.meters.create({ display_name: 'API calls', event_name: 'api_call', default_aggregation: { formula: 'sum' }, customer_mapping: { type: 'by_id', event_payload_key: 'stripe_customer_id' }, value_settings: { event_payload_key: 'value' } }); const price = await stripe.prices.create({ product: productId, currency: 'usd', recurring: { interval: 'month', usage_type: 'metered', meter: meter.id }, billing_scheme: 'tiered', tiers_mode: 'graduated', tiers: [ { up_to: 1000, unit_amount: 0 }, // First 1000 free { up_to: 'inf', unit_amount: 1 } // $0.01 per unit after 1000 ] }); return price; } // Report usage to Stripe export async function reportUsage(stripeCustomerId: string, quantity: number) { const stripe = getStripe(); await stripe.billing.meterEvents.create({ event_name: 'api_call', payload: { stripe_customer_id: stripeCustomerId, value: String(quantity) }, identifier: crypto.randomUUID(), timestamp: Math.floor(Date.now() / 1000) }); } // Example: Track API usage export async function trackApiCall(userId: string) { const subscription = await db.subscription.findUnique({ where: { userId } }); if (subscription?.stripeCustomerId) { await reportUsage(subscription.stripeCustomerId, 1); } } ``` ## Billing Page Component Display subscription status to users: ```tsx filename="app/billing/page.tsx" lineNumbers import { redirect } from 'next/navigation'; import { createPortalSession } from '@/app/actions/portal'; import { Button } from '@/components/ui/button'; import { auth } from '@/lib/auth'; import { getStripe } from '@/lib/stripe'; export default async function BillingPage() { const session = await auth(); if (!session?.user?.id) { redirect('/login'); } const stripe = getStripe(); const subscription = await db.subscription.findUnique({ where: { userId: session.user.id } }); // Get price details from Stripe let priceDetails = null; if (subscription?.stripePriceId) { const price = await stripe.prices.retrieve(subscription.stripePriceId, { expand: ['product'] }); priceDetails = price; } return (

Billing

{subscription ? (

{(priceDetails?.product as any)?.name || 'Pro Plan'}

{subscription.status === 'active' ? 'Active subscription' : `Status: ${subscription.status}`}

${(priceDetails?.unit_amount || 0) / 100} /{priceDetails?.recurring?.interval}

{subscription.cancelAtPeriodEnd && (

Your subscription will cancel on{' '} {subscription.currentPeriodEnd.toLocaleDateString()}

)}

Current period:{' '} {subscription.currentPeriodStart.toLocaleDateString()} {' - '} {subscription.currentPeriodEnd.toLocaleDateString()}

) : (

No active subscription

Choose a plan to get started

)}
); } ``` ## Testing Use Stripe's test cards for development: | Card Number | Scenario | | ------------------ | ------------------ | | `4242424242424242` | Successful payment | | `4000000000000002` | Card declined | | `4000002500003155` | Requires 3D Secure | | `4000000000009995` | Insufficient funds | Test webhooks locally with Stripe CLI: ```bash filename="Terminal" lineNumbers # Install Stripe CLI brew install stripe/stripe-cli/stripe # Login stripe login # Forward webhooks to your local server stripe listen --forward-to localhost:3000/api/webhooks/stripe # Trigger test events stripe trigger checkout.session.completed stripe trigger customer.subscription.updated stripe trigger invoice.payment_failed ``` ## Production Best Practices ### 1. Store Billing Data Locally Don't rely solely on Stripe API calls: ```typescript filename="lib/subscription.ts" lineNumbers // Bad: Fetching from Stripe on every request const subscription = await stripe.subscriptions.retrieve(subId); // Good: Cache in your database, sync via webhooks const subscription = await db.subscription.findUnique({ where: { stripeSubscriptionId: subId } }); ``` ### 2. Use Idempotency Keys Prevent duplicate charges: ```typescript filename="lib/payment.ts" lineNumbers await stripe.paymentIntents.create( { amount: 1000, currency: 'usd' }, { idempotencyKey: `order_${orderId}` } ); ``` ### 3. Handle Webhook Retries Stripe retries failed webhooks for up to 3 days. Make your handlers idempotent: ```typescript filename="lib/webhooks.ts" lineNumbers async function handleCheckoutCompleted(session: Stripe.Checkout.Session) { // Check if already processed const existing = await db.subscription.findFirst({ where: { stripeSubscriptionId: session.subscription as string } }); if (existing) { console.log('Already processed, skipping'); return; } // Process the event... } ``` ### 4. Secure Your Webhook Endpoint Always verify signatures and use HTTPS in production. ## Conclusion Implementing Stripe billing in Next.js requires: 1. **Server Actions** for secure server-side operations 2. **Webhooks** to sync Stripe events to your database 3. **Customer Portal** for self-service subscription management 4. **Proper error handling** and idempotency For maintained subscription, one-time payment, per-seat billing, and prepaid-credit implementations, review the [SaaS starter kits](/docs/starter-kits) and their billing documentation. ## Related Articles - [Building a SaaS Dashboard with React Server Components](/blog/saas-dashboard-react-server-components) - Learn how to build performant dashboards that complement your billing system - [Multi-Tenant Architecture in Next.js](/blog/multi-tenant-architecture-nextjs) - Implement organization-based billing with multi-tenancy - [Prisma vs Drizzle ORM](/blog/prisma-vs-drizzle-orm) - Choose the right database layer for your billing data --- Ready to skip the billing implementation headache? Our [Pro Prisma](/docs/starter-kits/pro-nextjs-prisma/billing/overview) and [Pro Drizzle](/docs/starter-kits/pro-nextjs-drizzle/billing/overview) starter kits include Stripe billing implementations and documentation. [Compare the kits](/pricing).