# Achromatic Prisma starter kit ## 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, 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 --- ## 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 three admin pages: - `/dashboard/admin/users` - `/dashboard/admin/organizations` - `/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. ### 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({ 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 - [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 - 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. 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. --- ## 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. ## 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) --- ## 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, 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`) ### 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. ### 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) for components. This gives you complete control over the visual appearance of your application. ## 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 [shadcn/ui](https://ui.shadcn.com), which is a set of [Tailwind CSS](https://tailwindcss.com)-styled components based on [Radix UI](https://www.radix-ui.com/). ### 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 * as React from 'react'; import { cn } from '@/lib/utils'; export interface ButtonProps extends React.ButtonHTMLAttributes { variant?: | 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'; size?: 'default' | 'sm' | 'lg' | 'icon'; } const Button = React.forwardRef( ({ className, variant = 'default', size = 'default', ...props }, ref) => { 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 and React 19** 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. - **Operations** with Pino logging, Sentry, Vercel Analytics, Speed Insights, tests, Oxlint, Oxfmt and strict TypeScript. - **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. --- ## 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 an authenticated tRPC mutation that returns a presigned `PutObject` URL: ```typescript filename="trpc/routers/storage/index.ts" lineNumbers import { signedUploadUrlSchema } from '@/schemas/upload-schemas'; import { createTRPCRouter, protectedProcedure } from '@/trpc/init'; import { TRPCError } from '@trpc/server'; import { storageConfig } from '@/config/storage.config'; import { getSignedUploadUrl } from '@/lib/storage'; export const storageRouter = createTRPCRouter({ signedUploadUrl: protectedProcedure .input(signedUploadUrlSchema) .mutation(async ({ input }) => { if (input.bucket === storageConfig.bucketNames.images) { const signedUrl = await getSignedUploadUrl(input.path, input.bucket); return { signedUrl }; } throw new TRPCError({ code: 'FORBIDDEN' }); }) }); ``` The schema accepts a non-empty bucket and path. The router requires a signed-in user and permits only the configured images bucket. 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. Generate a flat UUID-based `.png` object key. 4. Request a signed upload URL. 5. Upload the blob directly with `PUT`. 6. 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 = `${user.id}-${crypto.randomUUID()}.png`; const { signedUrl } = await trpc.storage.signedUploadUrl.mutateAsync({ path, bucket: storageConfig.bucketNames.images }); 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 does not: - Derive the object key from the authenticated user - Confirm that a key belongs to the user or active organization - Enforce a maximum byte size - Inspect the uploaded file contents - Restrict extensions in `signedUploadUrlSchema` - Create file metadata or enforce storage plan limits - Prevent an authenticated user from requesting a valid known key in the images bucket 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. Generate the object key on the server from the session or active organization. 2. Validate an allowed content type and file size before signing. 3. Enforce provider-side upload limits where your S3-compatible provider supports them. 4. Add a file metadata record with ownership and an upload lifecycle state. 5. Confirm the object after upload before marking the record ready. 6. 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 Radix 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