# Achromatic Drizzle 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)
# Drizzle documentation
## App Config
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/admin-panel/app-config
**Description**: Inspect the configuration values loaded by the current deployment.
The App Config page at `/dashboard/admin/app-config` is a read-only viewer. It
does not edit configuration, save values to the database or keep a change
history.
The parent admin layout restricts the page to users whose platform role is
`admin`.
## Configuration Sections
`components/admin/app-config/app-config-table.tsx` imports four configuration
objects and displays each one in a tab:
- **App** from `config/app.config.ts`
- **Auth** from `config/auth.config.ts`
- **Billing** from `config/billing.config.ts`
- **Storage** from `config/storage.config.ts`
These are the values resolved by the configuration modules for the current
deployment.
## Displayed Fields
The table flattens nested objects into dot-separated keys. Each row contains:
- **Key** - The configuration path, such as `pagination.defaultLimit`
- **Type** - The JavaScript value type
- **Value** - The resolved value
Arrays remain on one row. Object items inside an array are displayed as JSON.
Each value has a copy button.
The page does not display field descriptions, update timestamps or a change
author. It also does not include search, filters, pagination or row selection.
## Change Configuration
Edit the corresponding file in `config/` and update any environment variables
that file reads. Validate the application locally, then deploy the code and
environment changes. The App Config page will reflect the values loaded by the
new deployment.
There is no App Config tRPC procedure or database table in the shipped kit.
## Security
The table is rendered by a Client Component. Do not add secrets to the imported
configuration objects. Keep server-only credentials in server environment
variables and avoid exposing them through client-imported config modules.
## Related Documentation
- [Configuration](/docs/starter-kits/pro-nextjs-drizzle/configuration) - Configure the application
- [App Configuration](/docs/starter-kits/pro-nextjs-drizzle/configuration/app) - Review the app config structure
- [Environment Variables](/docs/starter-kits/pro-nextjs-drizzle/codebase/environment-variables) - Manage deployment values
---
## Credits
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/admin-panel/credits
**Description**: View and adjust organization credit balances from the admin Organizations page.
The shipped kit manages credits from `/dashboard/admin/organizations`. It does
not include a standalone `/dashboard/admin/credits` route or an
`admin.credit` tRPC router.
## Included Admin Controls
The Organizations table shows the current credit balance for each organization.
Admins can:
- Search for an organization by name
- Filter organizations by credit balance
- Open **Adjust credits** from an organization's row menu
- Add or subtract credits with a required description
The balance filters are `zero`, `low`, `medium` and `high`. Their ranges are
defined by `admin.organization.list` in
`trpc/routers/admin/admin-organization-router.ts`.
## List Organization Balances
Credit balances are returned by the existing organization list procedure:
```tsx filename="components/admin/organizations/organizations-table.tsx" lineNumbers
const { data, isPending } = trpc.admin.organization.list.useQuery({
limit: 25,
offset: 0,
query: '',
sortBy: 'name',
sortOrder: 'asc',
filters: {
balanceRange: ['low']
}
});
const organizations = data?.organizations ?? [];
// Each organization includes `credits`, the current balance.
```
The list procedure does not return an admin-wide credit transaction history.
## Adjust Organization Credits
Use the procedure called by
`components/admin/credits/adjust-credits-modal.tsx`:
```tsx lineNumbers
const adjustCredits = trpc.admin.organization.adjustCredits.useMutation();
adjustCredits.mutate({
organizationId,
amount: 500,
description: 'Support credit'
});
```
A positive amount adds credits and a negative amount subtracts credits. The
amount cannot be zero. The description must contain between 1 and 500
characters.
The mutation records the adjustment through `lib/billing/credits.ts` and
returns `newBalance` plus `transactionId`. It also stores the acting admin's ID
and email in the transaction metadata.
## Not Included
The current kit does not include:
- A separate admin credits page
- `trpc.admin.credit.list`, `trpc.admin.credit.get` or
`trpc.admin.credit.listTransactions`
- An admin-wide credit transaction browser
- User-level credit balances
The credit system is organization-scoped. The
`trpc.organization.credit.getTransactions` procedure is available to the active
organization through `protectedOrganizationProcedure`. It is not a global
admin query.
## Related Documentation
- [Organizations](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/organizations) - Use the shipped admin interface
- [Credits](/docs/starter-kits/pro-nextjs-drizzle/billing/credits) - Understand the organization credit system
- [Billing Overview](/docs/starter-kits/pro-nextjs-drizzle/billing/overview) - Review the billing architecture
---
## Organizations
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/admin-panel/organizations
**Description**: View organizations and use the billing controls included in the admin table.
The Organizations page at `/dashboard/admin/organizations` is the shipped
admin interface for organization records, subscription summaries and credit
balances.
## Table Contents
Each row displays:
- Organization name and logo
- Member count
- Pending invitation count
- Latest subscription plan and status
- Scheduled cancellation or trial details when present
- Current credit balance
- Creation date
The list does not include an organization owner, slug, payment history or full
credit transaction history.
## Search, Filters and Sorting
Search matches organization names. The table can filter by:
- Member count
- Subscription status
- Billing interval
- Credit balance range
- Creation date
Sorting is supported for name, member count and creation date.
```tsx filename="components/admin/organizations/organizations-table.tsx" lineNumbers
const { data, isPending } = trpc.admin.organization.list.useQuery({
limit: 25,
offset: 0,
query: '',
sortBy: 'name',
sortOrder: 'asc',
filters: {
membersCount: ['1-5'],
subscriptionStatus: ['active'],
subscriptionInterval: ['month'],
balanceRange: ['low'],
createdAt: ['this-month']
}
});
```
## Row Actions
The row menu includes these actions:
- **Sync from Stripe** calls `trpc.admin.organization.syncFromStripe` for the
selected organization
- **Adjust credits** calls `trpc.admin.organization.adjustCredits`
- **Open in Stripe** opens the stored subscription in the Stripe Dashboard
- **Cancel at period end** calls
`trpc.admin.organization.cancelSubscription` with `immediate: false`
- **Delete** calls `trpc.admin.organization.delete`
Cancellation is unavailable when a subscription is already canceled or
scheduled to cancel. The local subscription record is updated by the Stripe
webhook.
## Bulk Actions
Selected organizations can be:
- Exported to CSV
- Exported to Excel
- Synchronized with Stripe
The current table does not include bulk deletion, bulk credit adjustments or
bulk subscription cancellation.
## Returned Billing Fields
`trpc.admin.organization.list` returns one latest subscription summary and one
credit balance per organization. Relevant fields include:
- `subscriptionId`
- `subscriptionStatus`
- `subscriptionPlan`
- `cancelAtPeriodEnd`
- `trialEnd`
- `credits`
## Related Documentation
- [Subscription controls](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/subscriptions) - Manage the latest organization subscription
- [Credit controls](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/credits) - View and adjust organization credits
- [Organizations Overview](/docs/starter-kits/pro-nextjs-drizzle/organizations/overview) - Understand organization features
---
## Overview
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/admin-panel/overview
**Description**: Learn which admin pages and organization management controls ship with the kit.
The admin area is available only to users whose platform role is `admin`. The
current kit ships 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-drizzle/authentication/superadmin).
2. Sign in with that account.
3. Open the Admin entry from the organization switcher. It links to `/dashboard/admin/users`.
`app/(saas)/dashboard/(sidebar)/admin/layout.tsx` checks the server session. A
non-admin user is redirected to `/dashboard`.
## Shipped Features
### Users
The Users page provides the account management actions documented in the
[Users guide](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/users).
### Organizations
The Organizations page includes:
- Search, pagination and filters
- Subscription and credit balance summaries
- CSV and Excel exports
- Stripe synchronization for selected organizations
- Credit adjustments for one organization
- Subscription cancellation at period end
- Organization deletion
See the [Organizations guide](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/organizations)
for the exact fields and procedures.
### 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-drizzle/admin-panel/users) - Manage accounts
- [Organizations](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/organizations) - Manage organizations and billing summaries
- [Subscriptions](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/subscriptions) - Use the shipped subscription controls
- [Credits](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/credits) - Use the shipped credit controls
- [App Config](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/app-config) - Inspect runtime configuration
---
## Subscriptions
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/admin-panel/subscriptions
**Description**: Monitor and manage organization subscriptions from the admin Organizations page.
The shipped kit manages subscriptions from `/dashboard/admin/organizations`.
It does not include a standalone `/dashboard/admin/subscriptions` route or an
`admin.subscription` tRPC router.
## Included Admin Controls
The Organizations table shows the latest subscription for each organization.
Admins can:
- Search for an organization by name
- Filter organizations by subscription status or billing interval
- Open a stored subscription in Stripe
- Schedule a subscription to cancel at the end of its billing period
- Sync subscription and order data from Stripe
The table resolves configured Stripe price IDs to plan names where possible. It
also shows the subscription status, scheduled cancellation and trial end date.
It does not provide revenue analytics or payment history.
## List Subscription Summaries
Subscription summaries are returned by the existing organization list
procedure:
```tsx filename="components/admin/organizations/organizations-table.tsx" lineNumbers
const { data, isPending } = trpc.admin.organization.list.useQuery({
limit: 25,
offset: 0,
query: '',
sortBy: 'name',
sortOrder: 'asc',
filters: {
subscriptionStatus: ['active', 'trialing'],
subscriptionInterval: ['month']
}
});
const organizations = data?.organizations ?? [];
// Each result includes subscriptionId, subscriptionStatus,
// subscriptionPlan and cancelAtPeriodEnd.
```
There is no `trpc.admin.subscription.list` procedure. The list returns the most
recent stored subscription for each organization instead of every subscription
record.
## Cancel a Subscription
The row action uses the admin organization router:
```tsx lineNumbers
const cancelSubscription =
trpc.admin.organization.cancelSubscription.useMutation();
cancelSubscription.mutate({
subscriptionId,
immediate: false
});
```
The shipped interface always passes `immediate: false`, which schedules the
subscription to cancel at the end of its current period. The procedure also
accepts `immediate: true` for custom admin interfaces. Stripe webhooks update
the local subscription record after cancellation.
## Sync Billing Data from Stripe
Use the sync procedure with one or more organization IDs:
```tsx lineNumbers
const syncFromStripe = trpc.admin.organization.syncFromStripe.useMutation();
syncFromStripe.mutate({
organizationIds: [organizationId]
});
```
This syncs subscriptions and one-time orders for the selected organizations.
The input accepts between 1 and 1,000 organization IDs. The Organizations table
supports both a row action and a bulk action for this procedure.
## Not Included
The current kit does not include:
- A separate admin subscriptions page
- `trpc.admin.subscription.list`, `trpc.admin.subscription.cancel` or
`trpc.admin.subscription.syncFromStripe`
- A global subscription table with revenue or churn reporting
- Admin payment history or bulk subscription cancellation
Organization owners and organization admins manage their own billing from
`/dashboard/organization/settings?tab=subscription` through the
`trpc.organization.subscription` router.
## Related Documentation
- [Organizations](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/organizations) - Use the shipped admin interface
- [Subscriptions](/docs/starter-kits/pro-nextjs-drizzle/billing/subscriptions) - Understand subscription billing
- [Webhooks](/docs/starter-kits/pro-nextjs-drizzle/billing/webhooks) - Keep local billing data synchronized
---
## Users
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/admin-panel/users
**Description**: Manage users, ban/unban accounts, and view user details in the admin panel.
The Users section of the admin panel allows you to manage all users in your application, including viewing user details, filtering users, banning/unbanning accounts, and exporting user data.
## Features
### View All Users
The users table displays all registered users with the following information:
- **Name and Email** - User identification
- **Role** - User role (user or admin)
- **Verification Status** - Whether the email is verified
- **Ban Status** - Whether the user is banned
- **Created Date** - When the user account was created
### Filter Users
You can filter users by multiple criteria:
- **Search Query** - Search by name or email
- **Role** - Filter by user role (user, admin)
- **Email Verification** - Filter by verification status (verified, pending)
- **Ban Status** - Filter by ban status (banned, active)
- **Creation Date** - Filter by when the account was created (today, this week, this month, older)
### Ban/Unban Users
You can ban users temporarily or permanently:
```tsx filename="components/admin/users/ban-user-modal.tsx" lineNumbers
import { banUserAdminSchema } from '@/schemas/admin-user-schemas';
// Ban user with optional expiration date
const form = useZodForm({
schema: banUserAdminSchema,
defaultValues: {
userId,
reason: '',
expiresAt: undefined // Omit the date for a permanent ban
}
});
```
**Ban Options:**
- **Permanent Ban** - Omit `expiresAt` or set it to `undefined`
- **Temporary Ban** - Set `expiresAt` to a future date to automatically unban the user
- **Ban Reason** - Provide a reason for the ban (stored for audit purposes)
### Export Users
Export user data to CSV format for analysis or backup:
```ts filename="trpc/routers/admin/admin-user-router.ts" lineNumbers
exportSelectedToCsv: protectedAdminProcedure
.input(exportUsersAdminSchema)
.mutation(async ({ input }) => {
const users = await db.query.userTable.findMany({
where: inArray(userTable.id, input.userIds),
});
const Papa = await import('papaparse');
const csv = Papa.unparse(users);
return csv;
}),
```
## Using the Admin Users API
### List Users
```tsx filename="components/admin/users/users-table.tsx" lineNumbers
import { trpc } from '@/trpc/client';
export function UsersTable() {
const { data, isPending } = trpc.admin.user.list.useQuery({
limit: 25,
offset: 0,
query: '', // Optional search query
sortBy: 'name', // 'name' | 'email' | 'role' | 'createdAt'
sortOrder: 'asc', // 'asc' | 'desc'
filters: {
role: ['user'], // Optional role filter
emailVerified: ['verified'], // Optional: 'verified' | 'pending'
banned: ['active'], // Optional: 'active' | 'banned'
createdAt: ['today'] // Optional: 'today' | 'this-week' | 'this-month' | 'older'
}
});
return (
{data?.users.map((user) => (
{user.name} - {user.email}
))}
);
}
```
### Ban a User
```tsx filename="components/admin/users/ban-user-modal.tsx" lineNumbers
const banUser = trpc.admin.user.banUser.useMutation({
onSuccess: () => {
toast.success('User banned successfully');
utils.admin.user.list.invalidate();
}
});
const handleBan = (data: BanUserInput) => {
banUser.mutate({
userId: user.id,
reason: data.reason,
expiresAt: data.expiresAt
});
};
```
### Unban a User
```tsx filename="components/admin/users/users-table.tsx" lineNumbers
const unbanUser = trpc.admin.user.unbanUser.useMutation({
onSuccess: () => {
toast.success('User unbanned successfully');
utils.admin.user.list.invalidate();
}
});
const handleUnban = (userId: string) => {
unbanUser.mutate({ userId });
};
```
## User Management Best Practices
### When to Ban Users
Ban users when they:
- Violate the terms of service
- Engage in abusive behavior
- Attempt to exploit the system
- Show signs of fraudulent activity
### Temporary vs Permanent Bans
- **Temporary bans** - Use for first-time violations or minor infractions
- **Permanent bans** - Use for serious violations or repeat offenders
- Always provide a clear reason for the ban
### User Data Privacy
- Only export user data when necessary
- Ensure compliance with applicable data protection regulations
- Store exported data securely
- Delete exported files after use
## Related Documentation
- [Admin UI](/docs/starter-kits/pro-nextjs-drizzle/authentication/superadmin) - Learn how to create admin users
- [Permissions and Access Control](/docs/starter-kits/pro-nextjs-drizzle/authentication/permissions) - Understand user roles and permissions
---
## AI
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/ai-integration
**Description**: Learn how to leverage the built-in AI features including chatbots and LLM integration.
The Pro Next.js Drizzle starter kit ships an organization-scoped chat built with **AI SDK 7**, the direct **OpenAI provider**, tRPC history and usage-based credits. This page describes the code in the repository. Sections labeled as custom examples are additions you can build yourself.
## Overview
The AI system is built with a hybrid architecture to support high-performance streaming while maintaining a type-safe tRPC API for CRUD operations.
| Feature | Technology | Reason |
| ------------------- | ------------- | -------------------------------- |
| Streaming responses | API Route | tRPC doesn't support streaming |
| Chat CRUD | tRPC | Type-safe, cached queries |
| State management | Vercel AI SDK | `useChat` hook handles streaming |
## Configuration
Add your OpenAI API key to the `.env` file to enable the AI features.
```ini filename=".env" lineNumbers
OPENAI_API_KEY=sk-...
```
## Streaming Endpoint
The complete shipped route lives at `app/api/ai/chat/route.ts`. It authenticates the request, validates the selected model, checks organization access and credits, persists the response and returns a text stream. The reduced example below shows the same message normalization and stream protocol without the product-specific billing flow.
```typescript filename="app/api/ai/example/route.ts" lineNumbers
import { openai } from '@ai-sdk/openai';
import { streamText, type ModelMessage } from 'ai';
import { z } from 'zod/v4';
const messageSchema = z
.object({
role: z.enum(['user', 'assistant', 'system']),
content: z.string().optional(),
parts: z
.array(
z.object({
type: z.string(),
text: z.string().optional()
})
)
.optional()
})
.passthrough();
const requestSchema = z.object({
messages: z.array(messageSchema)
});
function toModelMessages(
messages: z.infer[]
): ModelMessage[] {
return messages.map((message) => {
const content =
message.content ??
message.parts?.find((part) => part.type === 'text')?.text ??
'';
switch (message.role) {
case 'system':
return { role: 'system', content };
case 'assistant':
return { role: 'assistant', content };
default:
return { role: 'user', content };
}
});
}
export async function POST(req: Request) {
const body = requestSchema.parse(await req.json());
const result = streamText({
model: openai('gpt-4o-mini'),
messages: toModelMessages(body.messages)
});
return result.toUIMessageStreamResponse({
onError: () => 'AI is temporarily unavailable. Please try again later.'
});
}
```
`DefaultChatTransport` and `toUIMessageStreamResponse()` are the matched pair used by the shipped chat. The UI message protocol carries sanitized failures as well as generated text. Normalize message parts before passing them to `streamText`, as the shipped route does.
## UI Components
We provide a complete suite of components to build a premium AI chat experience.
### Main Chat Component
The `AiChat` component provides a full conversation interface with a history sidebar.
```tsx filename="app/(saas)/dashboard/(sidebar)/organization/chatbot/page.tsx" lineNumbers
import { redirect } from 'next/navigation';
import { AiChat } from '@/components/ai/ai-chat';
import { getOrganizationById, getSession } from '@/lib/auth/server';
export default async function ChatbotPage() {
const session = await getSession();
const organizationId = session?.session.activeOrganizationId;
if (!organizationId) redirect('/dashboard');
const organization = await getOrganizationById(organizationId);
if (!organization) redirect('/dashboard');
return ;
}
```
### Custom Hook
For more control, you can use the `useChat` hook directly from the Vercel AI SDK.
```tsx filename="components/my-custom-ai.tsx" lineNumbers
'use client';
import { useState, type FormEvent } from 'react';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
type MyCustomAIProps = {
chatId: string;
organizationId: string;
};
export function MyCustomAI({ chatId, organizationId }: MyCustomAIProps) {
const [input, setInput] = useState('');
const { messages, sendMessage, status } = useChat({
id: chatId,
transport: new DefaultChatTransport({
api: '/api/ai/chat',
body: { chatId, organizationId }
})
});
const isSending = status === 'submitted' || status === 'streaming';
function handleSubmit(event: FormEvent) {
event.preventDefault();
const text = input.trim();
if (!text) return;
setInput('');
sendMessage({
role: 'user',
parts: [{ type: 'text', text }]
});
}
return (
);
}
```
## Custom Example: Tool Calling
The shipped route does not register tools. You can add a tool definition like this and pass it to `streamText`. The UI message protocol can carry tool parts, but the client must render and handle each tool state.
```typescript filename="lib/ai/find-leads-tool.ts" lineNumbers
import { tool } from 'ai';
import { ilike } from 'drizzle-orm';
import { z } from 'zod/v4';
import { db } from '@/lib/db';
import { leadTable } from '@/lib/db/schema';
export const findLeadsTool = tool({
description: 'Find leads in the database',
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }) => {
return await db.query.leadTable.findMany({
where: ilike(leadTable.name, `%${query}%`)
});
}
});
```
---
## AI
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/ai
**Description**: Learn how to leverage the built-in AI features including chatbots and LLM integration.
The Pro Next.js Drizzle starter kit ships an organization-scoped chat built with **AI SDK 7**, the direct **OpenAI provider**, tRPC history and usage-based credits. This page describes the code in the repository. Sections labeled as custom examples are additions you can build yourself.
## Overview
The AI system is built with a hybrid architecture to support high-performance streaming while maintaining a type-safe tRPC API for CRUD operations.
| Feature | Technology | Reason |
| ------------------- | ------------- | -------------------------------- |
| Streaming responses | API Route | tRPC doesn't support streaming |
| Chat CRUD | tRPC | Type-safe, cached queries |
| State management | Vercel AI SDK | `useChat` hook handles streaming |
## Configuration
Add your OpenAI API key to the `.env` file to enable the AI features.
```ini filename=".env" lineNumbers
OPENAI_API_KEY=sk-...
```
## Streaming Endpoint
The complete shipped route lives at `app/api/ai/chat/route.ts`. It authenticates the request, validates the selected model, checks organization access and credits, persists the response and returns a text stream. The reduced example below shows the same message normalization and stream protocol without the product-specific billing flow.
```typescript filename="app/api/ai/example/route.ts" lineNumbers
import { openai } from '@ai-sdk/openai';
import { streamText, type ModelMessage } from 'ai';
import { z } from 'zod/v4';
const messageSchema = z
.object({
role: z.enum(['user', 'assistant', 'system']),
content: z.string().optional(),
parts: z
.array(
z.object({
type: z.string(),
text: z.string().optional()
})
)
.optional()
})
.passthrough();
const requestSchema = z.object({
messages: z.array(messageSchema)
});
function toModelMessages(
messages: z.infer[]
): ModelMessage[] {
return messages.map((message) => {
const content =
message.content ??
message.parts?.find((part) => part.type === 'text')?.text ??
'';
switch (message.role) {
case 'system':
return { role: 'system', content };
case 'assistant':
return { role: 'assistant', content };
default:
return { role: 'user', content };
}
});
}
export async function POST(req: Request) {
const body = requestSchema.parse(await req.json());
const result = streamText({
model: openai('gpt-4o-mini'),
messages: toModelMessages(body.messages)
});
return result.toUIMessageStreamResponse({
onError: () => 'AI is temporarily unavailable. Please try again later.'
});
}
```
`DefaultChatTransport` and `toUIMessageStreamResponse()` are the matched pair used by the shipped chat. The UI message protocol carries sanitized failures as well as generated text. Normalize message parts before passing them to `streamText`, as the shipped route does.
## UI Components
We provide a complete suite of components to build a premium AI chat experience.
### Main Chat Component
The `AiChat` component provides a full conversation interface with a history sidebar.
```tsx filename="app/(saas)/dashboard/(sidebar)/organization/chatbot/page.tsx" lineNumbers
import { redirect } from 'next/navigation';
import { AiChat } from '@/components/ai/ai-chat';
import { getOrganizationById, getSession } from '@/lib/auth/server';
export default async function ChatbotPage() {
const session = await getSession();
const organizationId = session?.session.activeOrganizationId;
if (!organizationId) redirect('/dashboard');
const organization = await getOrganizationById(organizationId);
if (!organization) redirect('/dashboard');
return ;
}
```
### Custom Hook
For more control, you can use the `useChat` hook directly from the Vercel AI SDK.
```tsx filename="components/my-custom-ai.tsx" lineNumbers
'use client';
import { useState, type FormEvent } from 'react';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
type MyCustomAIProps = {
chatId: string;
organizationId: string;
};
export function MyCustomAI({ chatId, organizationId }: MyCustomAIProps) {
const [input, setInput] = useState('');
const { messages, sendMessage, status } = useChat({
id: chatId,
transport: new DefaultChatTransport({
api: '/api/ai/chat',
body: { chatId, organizationId }
})
});
const isSending = status === 'submitted' || status === 'streaming';
function handleSubmit(event: FormEvent) {
event.preventDefault();
const text = input.trim();
if (!text) return;
setInput('');
sendMessage({
role: 'user',
parts: [{ type: 'text', text }]
});
}
return (
);
}
```
## Custom Example: Tool Calling
The shipped route does not register tools. You can add a tool definition like this and pass it to `streamText`. The UI message protocol can carry tool parts, but the client must render and handle each tool state.
```typescript filename="lib/ai/find-leads-tool.ts" lineNumbers
import { tool } from 'ai';
import { ilike } from 'drizzle-orm';
import { z } from 'zod/v4';
import { db } from '@/lib/db';
import { leadTable } from '@/lib/db/schema';
export const findLeadsTool = tool({
description: 'Find leads in the database',
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }) => {
return await db.query.leadTable.findMany({
where: ilike(leadTable.name, `%${query}%`)
});
}
});
```
---
## Chatbot
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/ai/chatbot
**Description**: Build AI-powered chatbots with streaming responses and conversation history.
The starter kit ships a complete organization chatbot with text streaming, conversation history, model selection and credit accounting. The repository uses AI SDK 7. Custom examples on this page are labeled so they are not confused with shipped files.
## Overview
The chatbot uses:
- **Vercel AI SDK** - For streaming responses and state management
- **tRPC** - For type-safe chat CRUD operations
- **OpenAI** - For the LLM backend (configurable)
## Streaming Endpoint
The shipped `app/api/ai/chat/route.ts` authenticates and validates each request, checks organization ownership and credit balance, normalizes UI message parts, calls OpenAI, deducts the actual credit cost and saves the response. This reduced example keeps the same UI message protocol and normalization but omits model selection and credit accounting.
```typescript filename="app/api/ai/chat/route.ts" lineNumbers
import { openai } from '@ai-sdk/openai';
import { streamText, type ModelMessage } from 'ai';
import { and, eq } from 'drizzle-orm';
import { assertUserIsOrgMember, getSession } from '@/lib/auth/server';
import { db } from '@/lib/db';
import { aiChatTable } from '@/lib/db/schema';
type ChatRequest = {
messages: Array<{
role: 'user' | 'assistant' | 'system';
content?: string;
parts?: Array<{ type: string; text?: string }>;
}>;
chatId: string;
organizationId: string;
};
function toModelMessages(messages: ChatRequest['messages']): ModelMessage[] {
return messages.map((message) => {
const content =
message.content ??
message.parts?.find((part) => part.type === 'text')?.text ??
'';
switch (message.role) {
case 'system':
return { role: 'system', content };
case 'assistant':
return { role: 'assistant', content };
default:
return { role: 'user', content };
}
});
}
export async function POST(req: Request) {
const session = await getSession();
if (!session) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const {
messages: uiMessages,
chatId,
organizationId
}: ChatRequest = await req.json();
await assertUserIsOrgMember(organizationId, session.user.id);
const messages = toModelMessages(uiMessages);
const result = streamText({
model: openai('gpt-4o-mini'),
messages,
async onFinish({ text }) {
// Save assistant's response to the database
const updatedMessages = [
...messages,
{ role: 'assistant' as const, content: text }
];
await db
.update(aiChatTable)
.set({ messages: JSON.stringify(updatedMessages) })
.where(
and(
eq(aiChatTable.id, chatId),
eq(aiChatTable.organizationId, organizationId)
)
);
}
});
return result.toUIMessageStreamResponse({
onError: () => 'AI is temporarily unavailable. Please try again later.'
});
}
```
`DefaultChatTransport` on the client must stay paired with `toUIMessageStreamResponse()` on the server. Do not pass the hook's raw `UIMessage[]` to `streamText`; normalize its `parts` to model content first, as the shipped route does.
## UI Components
### Main Chat Component
The `AiChat` component provides a full conversation interface with a history sidebar.
```tsx filename="app/(saas)/dashboard/(sidebar)/organization/chatbot/page.tsx" lineNumbers
import { AiChat } from '@/components/ai/ai-chat';
import { getSession } from '@/lib/auth/server';
export default async function AiPage() {
const session = await getSession();
const organizationId = session?.session.activeOrganizationId;
if (!organizationId) {
return
No active organization
;
}
return ;
}
```
### Custom Component
This optional component shows the AI SDK 7 transport and input APIs in a self-contained example. The shipped `AiChat` component has additional history, billing and error handling behavior.
```tsx filename="components/my-custom-ai.tsx" lineNumbers
'use client';
import { useState, type FormEvent } from 'react';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { MessageResponse } from '@/components/ai/message';
type MyCustomAIProps = {
chatId: string;
organizationId: string;
};
export function MyCustomAI({ chatId, organizationId }: MyCustomAIProps) {
const [input, setInput] = useState('');
const { messages, sendMessage, status } = useChat({
id: chatId,
transport: new DefaultChatTransport({
api: '/api/ai/chat',
body: { chatId, organizationId }
})
});
const isSending = status === 'submitted' || status === 'streaming';
function handleSubmit(event: FormEvent) {
event.preventDefault();
const text = input.trim();
if (!text) return;
setInput('');
sendMessage({
role: 'user',
parts: [{ type: 'text', text }]
});
}
return (
);
}
```
## Conversation History
Chats are stored in the database and can be retrieved via tRPC:
```typescript filename="trpc/routers/organization/organization-ai-router.ts" lineNumbers
import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init';
import { TRPCError } from '@trpc/server';
import { and, desc, eq, sql } from 'drizzle-orm';
import { z } from 'zod';
import { appConfig } from '@/config/app.config';
import { db } from '@/lib/db';
import { aiChatTable } from '@/lib/db/schema';
export const organizationAiRouter = createTRPCRouter({
listChats: protectedOrganizationProcedure
.input(
z
.object({
limit: z
.number()
.min(1)
.max(appConfig.pagination.maxLimit)
.optional()
.default(appConfig.pagination.defaultLimit),
offset: z.number().min(0).optional().default(0)
})
.optional()
)
.query(async ({ ctx, input }) => {
// Use SQL builder to select only needed columns and extract first message
const chats = await db
.select({
id: aiChatTable.id,
title: aiChatTable.title,
pinned: aiChatTable.pinned,
createdAt: aiChatTable.createdAt,
firstMessageContent: sql`
CASE
WHEN ${aiChatTable.messages} IS NOT NULL
AND ${aiChatTable.messages}::jsonb != '[]'::jsonb
THEN (${aiChatTable.messages}::jsonb->0->>'content')
ELSE NULL
END
`.as('first_message_content')
})
.from(aiChatTable)
.where(eq(aiChatTable.organizationId, ctx.organization.id))
.orderBy(desc(aiChatTable.pinned), desc(aiChatTable.createdAt))
.limit(input?.limit ?? 20)
.offset(input?.offset ?? 0);
return { chats };
}),
getChat: protectedOrganizationProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const chat = await db.query.aiChatTable.findFirst({
where: and(
eq(aiChatTable.id, input.id),
eq(aiChatTable.organizationId, ctx.organization.id)
)
});
if (!chat) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Chat not found'
});
}
return {
chat: {
...chat,
messages: chat.messages ? JSON.parse(chat.messages) : []
}
};
}),
createChat: protectedOrganizationProcedure
.input(z.object({ title: z.string().optional() }).optional())
.mutation(async ({ ctx, input }) => {
const [chat] = await db
.insert(aiChatTable)
.values({
organizationId: ctx.organization.id,
title: input?.title || 'New Chat',
messages: JSON.stringify([])
})
.returning();
return { chat };
}),
deleteChat: protectedOrganizationProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ input, ctx }) => {
await db
.delete(aiChatTable)
.where(
and(
eq(aiChatTable.id, input.id),
eq(aiChatTable.organizationId, ctx.organization.id)
)
);
})
});
```
## Custom Example: Tool Calling
The shipped chat route does not register tools. This custom route expects `ModelMessage[]`, not the `UIMessage[]` returned by `useChat`. Protect it with the same authentication and organization checks as the shipped route before using it in production.
```typescript filename="app/api/ai/chat/route.ts" lineNumbers
import { openai } from '@ai-sdk/openai';
import { streamText, type ModelMessage } from 'ai';
import { ilike } from 'drizzle-orm';
import { z } from 'zod/v4';
import { db } from '@/lib/db';
import { leadTable } from '@/lib/db/schema';
export async function POST(req: Request) {
const { messages }: { messages: ModelMessage[] } = await req.json();
const result = streamText({
model: openai('gpt-4o-mini'),
messages,
tools: {
findLeads: {
description: 'Find leads in the database by name',
inputSchema: z.object({
query: z.string().describe('The search query')
}),
execute: async ({ query }) => {
const leads = await db.query.leadTable.findMany({
where: ilike(leadTable.name, `%${query}%`),
limit: 10
});
return leads;
}
}
}
});
return result.toTextStreamResponse();
}
```
## Custom Example: Generation Settings
The shipped route only accepts model IDs from `chatModels` in `config/billing.config.ts`. For a separate fixed-model helper, use the AI SDK 7 `maxOutputTokens` setting:
```typescript filename="lib/ai/generate-short-reply.ts" lineNumbers
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
export async function generateShortReply(prompt: string) {
const { text } = await generateText({
model: openai('gpt-4o-mini'),
prompt,
temperature: 0.7,
maxOutputTokens: 1000
});
return text;
}
```
## Custom Example: Error Handling
This custom route also accepts `ModelMessage[]`. A client using `useChat` must normalize its message parts first or switch to the UI message protocol.
```typescript filename="app/api/ai/chat/route.ts" lineNumbers
import { openai } from '@ai-sdk/openai';
import { streamText, type ModelMessage } from 'ai';
export async function POST(req: Request) {
try {
const { messages }: { messages: ModelMessage[] } = await req.json();
const result = streamText({
model: openai('gpt-4o-mini'),
messages
});
return result.toTextStreamResponse();
} catch (error) {
console.error('AI chat error:', error);
return Response.json(
{ error: 'Failed to process chat request' },
{ status: 500 }
);
}
}
```
## Custom Rate Limiting
The starter kit does not ship a generic `@/lib/rate-limit` module. It checks organization credit balance before generation and deducts actual usage after generation. If you need request-frequency limits too, add a durable rate-limit provider and enforce it after authentication.
## Best Practices
1. **Stream responses** - Always use streaming for better UX
2. **Save conversations** - Store chat history in the database
3. **Implement rate limiting** - Control API costs
4. **Handle errors** - Provide user-friendly error messages
5. **Use tools wisely** - Add tools for database queries and external APIs
6. **Monitor usage** - Track token usage and costs
---
## Overview
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/ai/overview
**Description**: Learn about the built-in AI features powered by the Vercel AI SDK.
The Pro Next.js Drizzle starter kit ships an organization-scoped chat powered by **AI SDK 7** and the direct **OpenAI provider**. It includes text streaming, tRPC conversation history, model selection and usage-based credits.
## Architecture
The AI system is built with a hybrid architecture to support high-performance streaming while maintaining a type-safe tRPC API for CRUD operations.
| Feature | Technology | Reason |
| ------------------- | ------------- | -------------------------------- |
| Streaming responses | API Route | tRPC doesn't support streaming |
| Chat CRUD | tRPC | Type-safe, cached queries |
| State management | Vercel AI SDK | `useChat` hook handles streaming |
## Configuration
Add your OpenAI API key to the `.env` file to enable the AI features.
```env filename=".env" lineNumbers
OPENAI_API_KEY=sk-...
```
Getting Your API Key
Create an API key at{' '}
OpenAI Platform
. Make sure to keep it secure and never commit it to version control.
OPENAI_API_KEY must contain a secret key created in the OpenAI
Platform. OpenAI keys commonly begin with sk-. A value beginning
with pk_test_ is a Stripe publishable test key and cannot
authenticate an OpenAI request. Keep the OpenAI key server-only and never add
a NEXT_PUBLIC_ prefix.
A ChatGPT Plus, Pro or Team subscription does not include OpenAI API usage.
The OpenAI Platform project that owns the key must have API billing enabled,
available credit and a budget that permits requests.
## Shipped Provider
The repository installs `@ai-sdk/openai` and configures OpenAI models in `config/billing.config.ts`. Anthropic, Google, Mistral and other provider packages are not included. Install and configure another provider package before using it in custom code.
See the [AI SDK provider directory](https://ai-sdk.dev/providers/ai-sdk-providers) for provider-specific installation and configuration.
## Verify the Integration
After adding `OPENAI_API_KEY`, restart the development server and verify the
complete chat flow:
1. Create or select an organization with a positive credit balance.
2. Open **AI Chatbot**, create a new chat and send a short prompt with
**GPT-4o Mini**.
3. Confirm assistant text streams into the page.
4. Reload the chat and confirm both messages were persisted.
5. Open **Settings → Credits** and confirm a usage transaction was recorded.
An HTTP `200` from `/api/ai/chat` alone does not prove generation succeeded.
Streaming responses send their headers before the provider has finished. If no
assistant text appears, verify the server-side key, OpenAI quota and access to
the selected model, then inspect the server logs for the provider error.
### No assistant response
Use the provider error in the development server log to identify the failing
layer:
| Provider error | Meaning | What to check |
| ------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `invalid_api_key` or HTTP `401` | OpenAI rejected the credential | Create a server key in the intended OpenAI Platform project, replace `OPENAI_API_KEY` and restart the development server |
| `insufficient_quota` or HTTP `429` with a quota message | The key is valid but its project cannot spend | Enable API billing, add credit and confirm the project budget allows requests |
| `model_not_found` or HTTP `404` | The project cannot use the selected model | Select a model listed in `config/billing.config.ts` that is available to the project |
| `insufficient_credits` or HTTP `402` from Achromatic | The organization does not have enough application credits | Add credits in the application, then retry the message |
OpenAI API keys are scoped to a project. When you create a replacement key,
verify that the selected project is the same project where API billing and the
budget are configured. Never paste a real key into an issue, support message or
client-side environment variable. Rotate any key that has been shared.
The shipped route logs the original provider failure on the server and sends a
reviewed quota, credential, model, rate-limit or generic message through the
UI stream. Raw provider responses stay out of the browser because they can
contain sensitive request details.
---
## Prompting
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/ai/prompting
**Description**: Learn how to use LLMs for text generation, completion and prompting.
The starter kit ships the AI chat described in the [Chatbot guide](/docs/starter-kits/pro-nextjs-drizzle/ai/chatbot). The examples on this page are custom additions you can build with the same AI SDK 7 and OpenAI packages already installed in the repository.
## Basic Text Generation
Generate text using the `generateText` function:
```typescript filename="lib/ai/generate.ts" lineNumbers
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
export async function generateSummary(content: string) {
const { text } = await generateText({
model: openai('gpt-4o-mini'),
prompt: `Summarize the following content in 3 sentences:\n\n${content}`
});
return text;
}
```
## Server Actions
Use AI in Server Actions:
```typescript filename="app/actions/generate-content.ts" lineNumbers
'use server';
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
export async function generateBlogPost(topic: string) {
const { text } = await generateText({
model: openai('gpt-4o-mini'),
prompt: `Write a blog post about: ${topic}`,
maxOutputTokens: 2000
});
return text;
}
```
## Structured Outputs
Generate structured JSON outputs:
```typescript filename="lib/ai/generate-structured.ts" lineNumbers
import { openai } from '@ai-sdk/openai';
import { generateText, Output } from 'ai';
import { z } from 'zod';
const ProductSchema = z.object({
name: z.string(),
description: z.string(),
price: z.number(),
features: z.array(z.string())
});
export async function generateProduct(productType: string) {
const { output } = await generateText({
model: openai('gpt-4o-mini'),
output: Output.object({ schema: ProductSchema }),
prompt: `Generate a product specification for: ${productType}`
});
return output;
}
```
## Prompt Templates
Create reusable prompt templates:
```typescript filename="lib/ai/prompts.ts" lineNumbers
export const prompts = {
summarize: (content: string) =>
`Summarize the following content in 3 sentences:\n\n${content}`,
translate: (text: string, targetLanguage: string) =>
`Translate the following text to ${targetLanguage}:\n\n${text}`,
extractKeywords: (content: string) =>
`Extract 5 key keywords from the following content:\n\n${content}`,
generateTitle: (content: string) =>
`Generate a compelling title for the following content:\n\n${content}`
};
```
Usage:
```typescript filename="lib/ai/use-prompts.ts" lineNumbers
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
import { prompts } from './prompts';
export async function summarizeContent(content: string) {
const { text } = await generateText({
model: openai('gpt-4o-mini'),
prompt: prompts.summarize(content)
});
return text;
}
```
## System Prompts
Use system prompts to guide model behavior:
```typescript filename="lib/ai/generate-with-system.ts" lineNumbers
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
export async function generateResponse(userInput: string) {
const { text } = await generateText({
model: openai('gpt-4o-mini'),
system:
'You are a helpful assistant that provides concise, accurate answers.',
prompt: userInput
});
return text;
}
```
## Temperature and Sampling
Control randomness and creativity:
```typescript filename="lib/ai/generate-creative.ts" lineNumbers
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
// Creative writing (higher temperature)
export async function generateCreativeStory(prompt: string) {
const { text } = await generateText({
model: openai('gpt-4o-mini'),
prompt,
temperature: 0.9, // More creative
maxOutputTokens: 1000
});
return text;
}
// Factual content (lower temperature)
export async function generateFactualContent(prompt: string) {
const { text } = await generateText({
model: openai('gpt-4o-mini'),
prompt,
temperature: 0.2, // More deterministic
maxOutputTokens: 500
});
return text;
}
```
## Streaming Text Generation
Stream text generation for better UX:
```typescript filename="app/api/ai/generate/route.ts" lineNumbers
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';
export async function POST(req: Request) {
const { prompt } = await req.json();
const result = streamText({
model: openai('gpt-4o-mini'),
prompt
});
return result.toTextStreamResponse();
}
```
Client-side usage:
```tsx filename="components/streaming-generator.tsx" lineNumbers
'use client';
import { useCompletion } from '@ai-sdk/react';
import { MessageResponse } from '@/components/ai/message';
export function StreamingGenerator() {
const { completion, input, handleInputChange, handleSubmit, isLoading } =
useCompletion({
api: '/api/ai/generate',
streamProtocol: 'text'
});
return (
{completion}
);
}
```
## Custom Provider Packages
The starter kit installs the OpenAI provider only. To add another provider, follow its current instructions in the [AI SDK provider directory](https://ai-sdk.dev/providers/ai-sdk-providers), install the provider package and choose a model that the provider currently supports. Provider packages and model IDs are intentionally not hard-coded here because they are not part of the shipped repository.
## Error Handling
Handle API errors gracefully:
```typescript filename="lib/ai/generate-safe.ts" lineNumbers
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
export async function generateTextSafely(prompt: string) {
try {
const { text } = await generateText({
model: openai('gpt-4o-mini'),
prompt
});
return { success: true, text };
} catch (error) {
console.error('AI generation error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
```
## Best Practices
1. **Use appropriate models** - Choose models based on task complexity
2. **Set temperature wisely** - Lower for factual, higher for creative
3. **Limit token usage** - Set `maxOutputTokens` to control costs
4. **Use system prompts** - Guide model behavior with system messages
5. **Handle errors** - Always wrap AI calls in try-catch
6. **Cache results** - Cache expensive generations when possible
7. **Monitor usage** - Track token usage and costs
---
## Authentication
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/authentication
**Description**: Learn how to manage user authentication and authorization with Better Auth.
The Pro Next.js Drizzle starter kit uses **Better Auth** for robust and flexible authentication. It supports various providers, multi-factor authentication and organization-based access control.
## Setup
Authentication is primarily configured in `lib/auth/index.ts`. Follow the [Setup](/docs/starter-kits/pro-nextjs-drizzle/setup) to set up the basic environment variables.
```typescript filename="lib/auth/index.ts" lineNumbers
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { db } from '@/lib/db';
import { env } from '@/lib/env';
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: 'pg'
}),
emailAndPassword: {
enabled: true
},
socialProviders: {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET
}
}
// ... other configurations
});
```
## Client Usage
On the client side, use the `authClient` and the `useSession` hook to access user information.
```typescript filename="hooks/use-session.tsx" lineNumbers
import { authClient } from '@/lib/auth/client';
const { data: session, isPending } = authClient.useSession();
```
## Protecting Routes
### API Routes
Better Auth handles authentication through API routes at `app/api/auth/[...all]/route.ts`. This route proxies all authentication requests to Better Auth.
### Page Protection
Routes are protected by checking the session in Server Components. Use `getSession()` from `@/lib/auth/server` to verify authentication.
### Server-side (tRPC)
For API routes, use `protectedProcedure` or `protectedOrganizationProcedure` in your tRPC routers.
```typescript filename="trpc/routers/organization/organization-router.ts" lineNumbers
import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init';
export const organizationRouter = createTRPCRouter({
get: protectedOrganizationProcedure.query(async ({ ctx }) => {
// Current organization is available in ctx.organization
return ctx.organization;
})
});
```
## Organizations
Multi-tenancy is built-in. Users can create, join and switch between organizations.
### Creating an Organization
```typescript filename="components/organization-switcher.tsx" lineNumbers
await authClient.organization.create({
name: 'My New Company'
});
```
### Inviting Members
```typescript filename="lib/actions/organization.ts" lineNumbers
await authClient.organization.inviteMember({
email: 'teammate@example.com',
role: 'member'
});
```
## Admin Features
Platform admins can manage users and organizations through a dedicated admin panel.
### Impersonation
Admins can impersonate users for debugging purposes.
```typescript filename="lib/actions/admin.ts" lineNumbers
await authClient.admin.impersonateUser({
userId: 'user-id-to-impersonate'
});
```
### Banning Users
Admins can ban users permanently or for a specific duration.
```typescript filename="trpc/routers/admin/admin-user-router.ts" lineNumbers
import { banUserAdminSchema } from '@/schemas/admin-user-schemas';
import { createTRPCRouter, protectedAdminProcedure } from '@/trpc/init';
export const adminUserRouter = createTRPCRouter({
banUser: protectedAdminProcedure
.input(banUserAdminSchema)
.mutation(async ({ input, ctx }) => {
// Ban user logic
return { success: true };
})
});
```
---
## OAuth Providers
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/authentication/oauth
**Description**: Learn how to set up and configure OAuth providers.
The starter kit comes with Google OAuth pre-configured, but you can easily add additional OAuth providers like Facebook, GitHub or any provider supported by Better Auth.
## Plan OAuth URLs for Every Environment
OAuth providers compare the callback URL in each request with the URLs saved in
their developer console. Register every environment where people will complete
sign-in:
| Environment | Application origin | Google callback URL |
| ----------------- | -------------------------------- | --------------------------------------------------------- |
| Local development | `http://localhost:3000` | `http://localhost:3000/api/auth/callback/google` |
| Staging | `https://staging.yourdomain.com` | `https://staging.yourdomain.com/api/auth/callback/google` |
| Production | `https://yourdomain.com` | `https://yourdomain.com/api/auth/callback/google` |
The kit derives Better Auth's `baseURL` from `getBaseUrl()` in `lib/utils.ts`.
On Vercel, preview deployments use `NEXT_PUBLIC_VERCEL_BRANCH_URL`. Production
uses `NEXT_PUBLIC_SITE_URL` when it is set, then falls back to Vercel's generated
URL.
Google requires an exact registered callback URL. A new branch preview can have
a new hostname, so arbitrary preview URLs are not a reliable place to test
OAuth. Use a stable staging domain for repeatable pre-production testing or add
the exact preview callback URL before testing that deployment.
Use separate OAuth clients for local or staging work and production when your
provider supports it. Store each client secret only in that environment and
never expose it through a NEXT_PUBLIC_ variable.
## Google OAuth (Pre-configured)
Google OAuth is already set up in the starter kit. To enable it:
### 1. Create Google OAuth Credentials
1. Visit the [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project or select an existing one
3. Navigate to **APIs & Services** > **Credentials**
4. Click **Create Credentials** > **OAuth client ID**
5. Configure the OAuth consent screen if you haven't already
6. Select **Web application** as the application type
7. Add authorized JavaScript origins:
- `http://localhost:3000` (for development)
- `https://yourdomain.com` (for production)
8. Add authorized redirect URIs:
- `http://localhost:3000/api/auth/callback/google` (for development)
- `https://yourdomain.com/api/auth/callback/google` (for production)
9. Copy the **Client ID** and **Client Secret**
### 2. Configure Environment Variables
Add the credentials to your `.env` file:
```env filename=".env" lineNumbers
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
```
### 3. Verify Configuration
The Google provider is already configured in `lib/auth/index.ts`:
```typescript filename="lib/auth/index.ts" lineNumbers
socialProviders: {
google: {
prompt: "select_account",
clientId: env.GOOGLE_CLIENT_ID ?? "",
clientSecret: env.GOOGLE_CLIENT_SECRET ?? "",
scope: ["email", "profile"],
},
},
```
The sign-in and sign-up pages render providers from
`lib/auth/oauth-providers.tsx`. Google is included there by default. If the
credentials are not ready, set `enableSocialLogin` to `false` in
`config/auth.config.ts` so users are not shown a button that cannot complete
authentication.
### 4. Test Both Environments
1. Restart the development server after changing `.env`.
2. Sign in with a Google account and confirm the callback returns to
`/dashboard`.
3. Confirm a first-time Google user is created and an existing user follows the
account-linking behavior you intend.
4. Repeat the flow on the final HTTPS production domain. Preview and production
deployments need callback URLs accepted by the provider before they can
complete OAuth.
## Adding Additional OAuth Providers
To add a new OAuth provider (e.g., Facebook, GitHub), follow these steps:
### 1. Get Provider Credentials
Create an application with your chosen OAuth provider and obtain the Client ID and Client Secret.
### 2. Add Environment Variables
Add the provider credentials to your `.env` file:
```env filename=".env" lineNumbers
FACEBOOK_CLIENT_ID=your-facebook-client-id
FACEBOOK_CLIENT_SECRET=your-facebook-client-secret
```
### 3. Update Auth Configuration
Add the provider to `lib/auth/index.ts`:
```typescript filename="lib/auth/index.ts" lineNumbers
import { betterAuth } from 'better-auth';
export const auth = betterAuth({
// ... other config
account: {
accountLinking: {
enabled: true,
trustedProviders: ['google', 'facebook'] // Add new provider here
}
},
socialProviders: {
google: {
// ... existing Google config
},
facebook: {
clientId: env.FACEBOOK_CLIENT_ID ?? '',
clientSecret: env.FACEBOOK_CLIENT_SECRET ?? ''
}
}
});
```
### 4. Update Environment Schema
Add the new variables to `lib/env.ts`:
```typescript filename="lib/env.ts" lineNumbers
server: {
// ... existing variables
FACEBOOK_CLIENT_ID: z.string().optional(),
FACEBOOK_CLIENT_SECRET: z.string().optional(),
},
```
```typescript filename="lib/env.ts" lineNumbers
runtimeEnv: {
// ... existing variables
FACEBOOK_CLIENT_ID: process.env.FACEBOOK_CLIENT_ID,
FACEBOOK_CLIENT_SECRET: process.env.FACEBOOK_CLIENT_SECRET,
},
```
### 5. Add Provider to the UI Registry
Add the provider's display name and icon to `lib/auth/oauth-providers.tsx`. The
existing sign-in and sign-up cards iterate over this registry, so you do not
need to create another button component:
```tsx filename="lib/auth/oauth-providers.tsx" lineNumbers
export const oAuthProviders = {
google: {
name: 'Google',
icon: GoogleIcon
},
facebook: {
name: 'Facebook',
icon: FacebookIcon
}
} as const;
```
The registry key must match the provider key passed to Better Auth. Keep
`enableSocialLogin` enabled in `config/auth.config.ts` when at least one listed
provider is fully configured.
## Supported Providers
Better Auth supports many OAuth providers out of the box:
- Google
- Facebook
- GitHub
- Discord
- Apple
- Microsoft
- And many more...
For configuration details and social sign-in examples, see the [Better Auth OAuth documentation](https://better-auth.com/docs/concepts/oauth).
## Account Linking
The starter kit has account linking enabled, which allows users to connect multiple OAuth providers to the same account. This is configured in the `accountLinking` section:
```typescript filename="lib/auth/index.ts" lineNumbers
account: {
accountLinking: {
enabled: true,
trustedProviders: ["google"], // Providers that can be linked
},
},
```
When a user signs in with a trusted provider using the same verified email address, Better Auth can link the accounts. Only add providers you trust to verify email ownership.
---
## Overview
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/authentication/overview
**Description**: Learn more about authentication in the starter kit.
Authentication is a core part of any SaaS application. The Pro Next.js Drizzle starter kit uses [Better Auth](https://better-auth.com/) to handle authentication and provides all the necessary UI for the authentication flow.
Why choose Better Auth?
Better Auth is a modern, type-safe authentication solution built for Next.js.
It provides a simple API, excellent TypeScript support, and works seamlessly
with server components and server actions.
The starter kit comes with pre-configured providers, helper methods and extensions.
## Included authentication flows
- Email and password registration with email verification
- Password reset and email-address changes
- 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-drizzle/authentication/permissions
**Description**: Learn how to protect pages and display UI based on user roles or permissions.
The [tRPC endpoint protection guide](/docs/starter-kits/pro-nextjs-drizzle/trpc/protect-endpoint) explains how to enforce access control in your API procedures.
In this guide we will show you how you can protect pages and display UI based on the user's role or permissions.
## Protect a route (server side)
### For authenticated users
To protect a route to be only accessible for authenticated users, you can simply get the session in the RSC component and check if the user is authenticated.
_Note: When you are inside the `/app/(saas)/dashboard` directory, you don't need to check if the user is authenticated, because the session is verified in the middleware._
```tsx filename="app/dashboard/page.tsx" lineNumbers
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth/server';
export default async function MyProtectedPage() {
const session = await getSession();
if (!session) {
return redirect('/auth/sign-in');
}
return
My protected page
;
}
```
### For specific roles
More interesting is to check if the user has the necessary permissions to access the page. For example, you can make a page only accessible for users with the `admin` role.
```tsx filename="app/admin/page.tsx" lineNumbers
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth/server';
export default async function MyAdminPage() {
const session = await getSession();
if (session?.user.role !== 'admin') {
return redirect('/app');
}
return
This page is only accessible for admins
;
}
```
### For active or specific subscription
Or if you want to check for an active subscription, you can do the following:
```tsx filename="app/premium/page.tsx" lineNumbers
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth/server';
import {
getActivePlanForOrganization,
requirePaidPlan,
requireSpecificPlan
} from '@/lib/billing/guards';
export default async function MyPremiumPage() {
const session = await getSession();
if (!session?.activeOrganizationId) {
return redirect('/app');
}
// Check for any paid plan
try {
const { planId } = await requirePaidPlan(session.activeOrganizationId);
// User has a paid plan
} catch {
return redirect('/app');
// or show a message to the user that they need to subscribe to the premium plan
}
// Or check for a specific plan
try {
const { planId } = await requireSpecificPlan(session.activeOrganizationId, [
'pro',
'enterprise'
]);
// User has pro or enterprise plan
} catch {
return (
This page is only accessible for users with a pro subscription
);
}
return (
This page is only accessible for users with an active subscription
);
}
```
### For organization role
You can also check if a user has a specific role inside the current organization. For example, you might want to add features that are only available for organization owners or admins.
```tsx filename="app/[organizationSlug]/settings/page.tsx" lineNumbers
import { redirect } from 'next/navigation';
import { eq } from 'drizzle-orm';
import { getOrganizationById, getSession } from '@/lib/auth/server';
import { db } from '@/lib/db';
import { organizationTable } from '@/lib/db/schema';
export default async function MyOrganizationPage({
params
}: {
params: Promise<{ organizationSlug: string }>;
}) {
const { organizationSlug } = await params;
const session = await getSession();
// First, find organization by slug
const [org] = await db
.select({ id: organizationTable.id })
.from(organizationTable)
.where(eq(organizationTable.slug, organizationSlug))
.limit(1);
if (!org) {
redirect('/app');
}
// Then get full organization with members
const organization = await getOrganizationById(org.id);
if (!organization) {
redirect('/app');
}
const membership = organization.members.find(
(member) => member.userId === session?.user.id
);
if (
!membership ||
(membership.role !== 'admin' && membership.role !== 'owner')
) {
return
This page is only accessible for organization admins
;
}
return
This page is only accessible for organization admins
;
}
```
## Display UI based on permissions (client side)
On client side, you can use the `authClient.useSession()` hook to get the session and then check if the user has the necessary permissions.
### For authenticated users
```tsx filename="components/protected-component.tsx" lineNumbers
'use client';
import { authClient } from '@/lib/auth/client';
export function MyComponent() {
const { data: session } = authClient.useSession();
if (!session) {
return
You need to be logged in to access this page
;
}
return
You are logged in
;
}
```
Security Note
You always want to check the permission on the server side first to avoid any
security issues.
### For specific roles
```tsx filename="components/admin-component.tsx" lineNumbers
'use client';
import { authClient } from '@/lib/auth/client';
export function MyComponent() {
const { data: session } = authClient.useSession();
if (session?.user.role !== 'admin') {
return
This page is only accessible for admins
;
}
return
This page is only accessible for admins
;
}
```
### For active or specific subscription
```tsx filename="components/premium-component.tsx" lineNumbers
'use client';
import { trpc } from '@/trpc/client';
export function MyComponent() {
const { data: subscriptionStatus } =
trpc.organization.subscription.getStatus.useQuery();
if (!subscriptionStatus?.enabled) {
return
You need to subscribe to the pro plan to access this page
;
}
return
You have an active subscription
;
}
```
### For organization role
```tsx filename="components/organization-component.tsx" lineNumbers
'use client';
import { authClient } from '@/lib/auth/client';
export function MyComponent() {
const { data: activeOrganization } = authClient.useActiveOrganization();
const { data: session } = authClient.useSession();
if (!activeOrganization || !session) {
return
No active organization
;
}
// Find the user's membership in the active organization
const membership = activeOrganization.members.find(
(member) => member.userId === session.user.id
);
// Check if user is admin or owner
const isOrganizationAdmin =
membership && (membership.role === 'admin' || membership.role === 'owner');
if (!isOrganizationAdmin) {
return
This page is only accessible for organization admins
;
}
if (membership.role !== 'owner') {
return
This page is only accessible for organization owners
;
}
return
This page is only accessible for organization admins
;
}
```
---
## User and Session
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/authentication/sessions
**Description**: Learn how to access the user and session in your application.
## Accessing the user and session
You can access the user and session in your application using the `useSession` hook on the client or `getSession` function on the server.
### Client-side
```tsx filename="components/user-profile.tsx" lineNumbers
'use client';
import { authClient } from '@/lib/auth/client';
export function UserProfile() {
const { data: session, isPending } = authClient.useSession();
if (isPending) return
Loading...
;
if (!session) return
Not authenticated
;
return (
Hello, {session.user.name}!
Email: {session.user.email}
);
}
```
Both `user` and `session` can be `null` if the user is not authenticated, but if you use the hook inside a `/dashboard/...` route, they should always be defined.
The `user` object contains the information of the authenticated user and the `session` object contains the session data.
```typescript filename="types.ts" lineNumbers
type Session = {
id: string;
userId: string;
createdAt: Date;
updatedAt: Date;
expiresAt: Date;
token: string;
ipAddress?: string | null;
userAgent?: string | null;
impersonatedBy?: string | null;
activeOrganizationId?: string | null;
};
type User = {
id: string;
createdAt: Date;
updatedAt: Date;
email: string;
emailVerified: boolean;
name: string;
image?: string | null;
role: 'admin' | 'user';
onboardingComplete: boolean;
twoFactorEnabled?: boolean;
banned?: boolean;
};
```
`activeOrganizationId` is selected by the organization plugin and is used by
organization-protected pages and tRPC procedures. Treat it as context, not as
authorization on its own: protected procedures also verify that the current
user is still a member of that organization.
The security and administration fields on `User` are returned to the client but
are not accepted by the generic client update API. This prevents a browser from
marking onboarding complete, enabling two-factor authentication or changing an
account ban without the appropriate server-side flow.
### Wait until the session has been loaded
In some cases you might want to wait until the session has been loaded before accessing the user and session. For this there is an `isPending` property that you can use.
```tsx filename="components/loading-example.tsx" lineNumbers
'use client';
import { authClient } from '@/lib/auth/client';
export function MyComponent() {
const { data: session, isPending } = authClient.useSession();
if (!isPending && !session) {
return
Not authenticated
;
}
if (isPending) {
return
Loading...
;
}
return
Hello, {session.user.name}!
;
}
```
## Reload session
If for some reason you need to reload the session, for example when you changed some property of the user like its name or role, you can use the `refetch` function.
```tsx filename="components/reload-session.tsx" lineNumbers
'use client';
import { authClient } from '@/lib/auth/client';
export function MyComponent() {
const { data: session, refetch } = authClient.useSession();
const handleReload = async () => {
await refetch();
};
return (
Hello, {session?.user.name}!
);
}
```
## Get session on server
To use the session on the server, e.g. in a React Server Component, you can use the `getSession` function.
```tsx filename="app/dashboard/page.tsx" lineNumbers
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth/server';
export default async function DashboardPage() {
const session = await getSession();
if (!session) {
redirect('/auth/sign-in');
}
return
User name: {session.user.name}
;
}
```
### Via tRPC context
In tRPC procedures, the session is automatically available in the context:
```typescript filename="trpc/routers/example.ts" lineNumbers
import { createTRPCRouter, protectedProcedure } from '@/trpc/init';
export const exampleRouter = createTRPCRouter({
getProfile: protectedProcedure.query(async ({ ctx }) => {
// Session is available in ctx.session, user is in ctx.user
const user = ctx.user;
return user;
})
});
```
## Session strategy
User sessions are securely stored in the database. This allows active sessions to be managed in the security settings (i.e. you can log out all devices) and changes take effect immediately.
---
## Admin UI
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/authentication/superadmin
**Description**: Learn how to create admin users and access the admin UI.
The starter kit comes with an admin role and a UI for managing users and organizations out of the box.
The only thing you need to do is to create a new user and assign it the admin role.
## Create admin user via database
You can create an admin user directly in the database. First, start Drizzle Studio:
```bash filename="Terminal" lineNumbers
npm run db:studio
```
Then:
1. Navigate to the `users` table
2. Create a new user or find an existing user
3. Set the `role` field to `admin`
4. Save the changes
## Assign admin role to existing user
If you have already created a user and want to make it an admin, you can update the database entry directly.
### Using Drizzle Studio
1. Start Drizzle Studio:
```bash filename="Terminal" lineNumbers
npm run db:studio
```
2. Select the `User` table and find the user you want to make an admin
3. Click on the `role` field and change it to `admin`
4. Save the changes
### Using SQL
You can also update the role directly using SQL:
```sql filename="update-user-role.sql" lineNumbers
UPDATE users SET role = 'admin' WHERE email = 'admin@example.com';
```
## Next Steps
Once you've created an admin user, you can:
- **Access the Admin Panel** - Log in and navigate to the Admin section (see [Admin Panel Overview](/docs/starter-kits/pro-nextjs-drizzle/admin-panel/overview))
- **Manage your application** - Use the admin panel to manage users, organizations, subscriptions and more
---
## Inngest
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/background-tasks/inngest
**Description**: Integrate Inngest with your application for event-driven background workflows.
[Inngest](https://www.inngest.com) is a developer-first platform for building reliable background jobs, scheduled functions, and event-driven workflows. It provides a simple API for creating durable functions that automatically retry on failure.
The starter kits do not include the Inngest SDK, credentials, functions or API
route. This page is an optional implementation recipe. Add it only when its
event-driven execution model fits your product.
Inngest makes it easy to build reliable background jobs with automatic
retries, scheduling, and event-driven workflows. Functions are defined in your
codebase and run on Inngest's infrastructure, giving you the reliability of a
queue with the simplicity of writing regular functions.
## Setup
Visit [Inngest](https://www.inngest.com) and create a free account. Create a new app and note down your signing key.
Add your Inngest credentials to your environment variables:
```env filename=".env" lineNumbers
INNGEST_EVENT_KEY=your_event_key_here
INNGEST_SIGNING_KEY=your_signing_key_here
```
Register both server-only variables in `lib/env.ts` so the client example below
typechecks and missing production credentials fail validation:
```diff filename="lib/env.ts" lineNumbers
server: {
// Existing variables...
+ INNGEST_EVENT_KEY: z.string().min(1),
+ INNGEST_SIGNING_KEY: z.string().min(1)
}
```
Expose them to the validator in the same file:
```diff filename="lib/env.ts" lineNumbers
runtimeEnv: {
// Existing variables...
+ INNGEST_EVENT_KEY: process.env.INNGEST_EVENT_KEY,
+ INNGEST_SIGNING_KEY: process.env.INNGEST_SIGNING_KEY
}
```
Keep these values server-only and use separate Inngest environments and keys
for development, previews and production.
## Install dependencies
This recipe uses the stable Inngest TypeScript SDK v3 API. Pin that major so a
future v4 upgrade does not silently invalidate the examples:
```bash filename="Terminal" lineNumbers
npm install inngest@^3
```
## Configure Inngest
Create an Inngest client:
```typescript filename="lib/inngest.ts" lineNumbers
import { EventSchemas, Inngest } from 'inngest';
import * as z from 'zod';
const schemas = new EventSchemas().fromSchema({
'user/data.process': z.object({
userId: z.string(),
operation: z.enum(['export', 'analyze', 'cleanup'])
})
});
export const inngest = new Inngest({
id: 'your-app-id',
schemas
});
```
The SDK reads `INNGEST_EVENT_KEY` from the server environment when sending and
the Next.js handler uses `INNGEST_SIGNING_KEY` to authenticate incoming
requests. The event schema gives `inngest.send()` and `event.data` one shared
contract with runtime validation.
## Create your first function
Create functions in a `lib/inngest/functions` directory:
```typescript filename="lib/inngest/functions/process-user-data.ts" lineNumbers
import { inngest } from '@/lib/inngest';
export const processUserData = inngest.createFunction(
{ id: 'process-user-data' },
{ event: 'user/data.process' },
async ({ event, step }) => {
const { userId, operation } = event.data;
await step.run('process-data', async () => {
console.log('Processing user data', { userId, operation });
switch (operation) {
case 'export':
// Export user data
await new Promise((resolve) => setTimeout(resolve, 2000));
return { success: true, result: 'Data exported to CSV' };
case 'analyze':
// Analyze user data
await new Promise((resolve) => setTimeout(resolve, 5000));
return {
success: true,
result: { totalActions: 156, avgSessionTime: '4m 32s' }
};
case 'cleanup':
// Cleanup user data
await new Promise((resolve) => setTimeout(resolve, 3000));
return { success: true, result: 'Removed 23 obsolete records' };
default:
throw new Error(`Unknown operation: ${operation}`);
}
});
}
);
```
Create a scheduled function:
```typescript filename="lib/inngest/functions/daily-cleanup.ts" lineNumbers
import { inngest } from '@/lib/inngest';
export const dailyCleanup = inngest.createFunction(
{ id: 'daily-cleanup' },
{ cron: '0 2 * * *' }, // Daily at 2 AM
async ({ step }) => {
await step.run('cleanup-logs', async () => {
console.log('Cleaning up old logs');
await new Promise((resolve) => setTimeout(resolve, 5000));
return { logsCleaned: true };
});
await step.run('cleanup-temp-files', async () => {
console.log('Cleaning up temporary files');
await new Promise((resolve) => setTimeout(resolve, 3000));
return { tempFilesCleaned: true };
});
await step.run('generate-reports', async () => {
console.log('Generating daily reports');
await new Promise((resolve) => setTimeout(resolve, 8000));
return { reportsGenerated: true };
});
}
);
```
## Register functions
Create an API route to serve your Inngest functions:
```typescript filename="app/api/inngest/route.ts" lineNumbers
import { serve } from 'inngest/next';
import { inngest } from '@/lib/inngest';
import { dailyCleanup } from '@/lib/inngest/functions/daily-cleanup';
import { processUserData } from '@/lib/inngest/functions/process-user-data';
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [processUserData, dailyCleanup]
});
```
## Triggering functions
### From an API route
```typescript filename="app/api/tasks/process-user-data/route.ts" lineNumbers
import { NextRequest, NextResponse } from 'next/server';
import * as z from 'zod';
import { getSession } from '@/lib/auth/server';
import { inngest } from '@/lib/inngest';
const processUserDataSchema = z.object({
operation: z.enum(['export', 'analyze', 'cleanup'])
});
export async function POST(request: NextRequest) {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { operation } = processUserDataSchema.parse(body);
await inngest.send({
name: 'user/data.process',
data: { userId: session.user.id, operation }
});
return NextResponse.json({
success: true,
message: 'Background task started successfully'
});
}
```
### From a server action
```typescript filename="app/actions/user-actions.ts" lineNumbers
'use server';
import { getSession } from '@/lib/auth/server';
import { inngest } from '@/lib/inngest';
export async function processUserData(
operation: 'export' | 'analyze' | 'cleanup'
) {
const session = await getSession();
if (!session) {
throw new Error('Unauthorized');
}
try {
await inngest.send({
name: 'user/data.process',
data: { userId: session.user.id, operation }
});
return { success: true };
} catch (error) {
console.error('Failed to trigger background task:', error);
throw new Error('Failed to start background task');
}
}
```
The authenticated user ID is derived from the server session. Do not accept a
user or organization owner from the browser and treat it as authorization. For
organization work, verify membership before publishing and send only a stored
job ID or the minimum identifiers the worker needs.
## Monitoring and debugging
Visit the [Inngest Dashboard](https://app.inngest.com) to monitor your functions:
- View function execution logs and performance metrics
- Track success and failure rates
- Monitor function duration and step execution
- Replay failed functions
- Set up alerts for function failures
## Best practices
### Use step functions for reliability
Break your function into steps using `step.run()` to make it more reliable and debuggable:
```typescript
await step.run('step-name', async () => {
// This step will be retried independently if it fails
return await processData();
});
```
### Use descriptive function IDs
```typescript
// ✅ Good
{
id: 'user-data-export-csv';
}
// ❌ Not so good
{
id: 'task1';
}
```
### Handle errors gracefully
```typescript
await step.run('process', async () => {
try {
return await processData();
} catch (error) {
console.error('Processing failed:', error);
throw error; // Re-throw to trigger retry
}
});
```
## Next steps
With Inngest integrated into your application, you can now:
- **Build reliable background jobs** with automatic retries
- **Schedule recurring tasks** with cron expressions
- **Create event-driven workflows** that respond to events
- **Compose complex workflows** using step functions
Ready to explore more? Check out the official documentation for advanced features like function composition, event filtering, and more.
---
## Overview
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/background-tasks/overview
**Description**: Learn about background tasks & cron jobs and how they can power your application.
Background work lets an HTTP request acknowledge an operation before all of its
work finishes. Use it for jobs that need retries, scheduling or more execution
time than the request path should consume.
The starter kits do not install or configure a background-task provider by
default. The guides in this section show patterns you can add after choosing a
provider. Install only the SDK you plan to operate.
## Choose the simplest execution model
| Requirement | Recommended starting point | Why |
| ------------------------------------------------ | ---------------------------------------------------- | ----------------------------------------------------------- |
| The user needs the result immediately | Keep it in the request | The response can report success or failure directly |
| Deliver an HTTP message later or on a schedule | QStash | HTTP delivery, signing and retries fit serverless routes |
| Run durable steps with retries and observability | Trigger.dev, Inngest or Vercel Workflow | The provider records progress outside the request process |
| Run a small recurring operation | A provider schedule that invokes a protected handler | Scheduling remains outside the web process |
| Run a persistent in-process worker | A separately operated worker service | Serverless application instances are not persistent workers |
Do not add a queue only because a function is asynchronous. A short operation
that must succeed before the response is often clearer and safer when it stays
in the request.
## Good background-task candidates
- Generate exports, reports or media after accepting a request.
- Send batches of notifications with provider rate limits.
- Synchronize data with an external service and retry transient failures.
- Process a webhook after its signature and minimum payload are validated.
- Run scheduled cleanup against records that are safe to process repeatedly.
- Execute a durable multi-step workflow where progress must survive a restart.
Keep authentication, authorization decisions and ordinary interactive database
queries in the request path. Run schema migrations as a controlled release step,
not as a background job.
## Design the job before choosing a provider
Every job should define:
1. **Identity**: a stable job or idempotency key.
2. **Tenant scope**: the organization or user that owns the operation.
3. **Input contract**: a small validated payload containing identifiers rather
than large or sensitive objects.
4. **Retry behavior**: which failures are transient and how many attempts are
safe.
5. **Completion state**: where the application records pending, successful and
failed outcomes.
6. **Operations**: logs, alerts and a documented way to replay or cancel work.
Retries, timeouts and provider redelivery can run the same task more than
once. Make writes idempotent with a unique operation key, database constraint
or transactional state transition. A queue does not make a non-idempotent
operation safe automatically.
## Secure the producer and worker
- Authorize the user before publishing a job.
- Derive organization access from the authenticated session instead of trusting
an organization ID supplied by the browser.
- Verify provider signatures on public task endpoints.
- Store provider tokens and signing keys as server-only environment variables.
- Re-check permissions in the worker when delayed execution could outlive the
user's membership or access.
- Avoid putting access tokens, full customer records or other unnecessary
secrets in queue payloads and logs.
## Provider guides
These guides are alternatives, not steps that must all be completed:
Start with one provider and one narrow job. Verify success, retry, duplicate
delivery and permanent failure paths before moving business-critical work out of
the request.
---
## Upstash QStash
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/background-tasks/qstash
**Description**: Integrate Upstash QStash with your application for serverless-first background task processing.
[Upstash QStash](https://upstash.com/docs/qstash/quickstarts/vercel-nextjs)
delivers signed HTTP requests with retries, delays and schedules. It fits a
serverless Next.js application because the worker is an ordinary route handler,
not a persistent process.
Add QStash only when its HTTP delivery model fits your job. The starter kit
does not include the SDK, credentials, task tables or task routes.
## Install the SDK
```sh filename="Terminal" lineNumbers
npm install @upstash/qstash
```
## Register server-only variables
Copy the token and both signing keys from the Upstash console:
```env filename=".env" lineNumbers
QSTASH_TOKEN=replace-me
QSTASH_CURRENT_SIGNING_KEY=replace-me
QSTASH_NEXT_SIGNING_KEY=replace-me
```
Add the variables to the `server` schema in `lib/env.ts`:
```typescript filename="lib/env.ts" lineNumbers
server: {
// Existing variables...
QSTASH_TOKEN: z.string().min(1),
QSTASH_CURRENT_SIGNING_KEY: z.string().min(1),
QSTASH_NEXT_SIGNING_KEY: z.string().min(1),
QSTASH_URL: z.string().url().optional()
}
```
Expose them to the server-side validator in the same file:
```typescript filename="lib/env.ts" lineNumbers
runtimeEnv: {
// Existing variables...
QSTASH_TOKEN: process.env.QSTASH_TOKEN,
QSTASH_CURRENT_SIGNING_KEY: process.env.QSTASH_CURRENT_SIGNING_KEY,
QSTASH_NEXT_SIGNING_KEY: process.env.QSTASH_NEXT_SIGNING_KEY,
QSTASH_URL: process.env.QSTASH_URL
}
```
Do not prefix these values with `NEXT_PUBLIC_`. Set a separate credential set
in every deployed environment.
## Create the publishing client
`QSTASH_URL` is optional for the managed service and useful when connecting to a
local QStash server:
```typescript filename="lib/qstash.ts" lineNumbers
import 'server-only';
import { Client } from '@upstash/qstash';
import { env } from '@/lib/env';
export const qstash = new Client({
token: env.QSTASH_TOKEN,
...(env.QSTASH_URL ? { baseUrl: env.QSTASH_URL } : {})
});
```
## Add a signed worker route
QStash calls a public HTTP endpoint. Wrap the handler with the official
App Router verifier so requests without a valid QStash signature are rejected:
```typescript filename="app/api/tasks/process/route.ts" lineNumbers
import { verifySignatureAppRouter } from '@upstash/qstash/nextjs';
import * as z from 'zod';
import { processStoredJob } from '@/lib/tasks/process-stored-job';
const payloadSchema = z.object({
jobId: z.string().uuid()
});
async function handler(request: Request): Promise {
const { jobId } = payloadSchema.parse(await request.json());
await processStoredJob(jobId);
return Response.json({ success: true });
}
export const POST = verifySignatureAppRouter(handler);
```
Create `processStoredJob` for your product. Load the job and its organization
from the database instead of trusting tenant data carried in the message. Make
the state transition idempotent so delivering the same `jobId` twice does not
repeat billing, credits, email or another external side effect.
Return a non-success status or throw for transient failures that QStash should
retry. Record permanent failures so they can be investigated without retrying
forever.
## Publish after authorization
Authorize the current user and persist a pending job before publishing its ID.
Use the kit's `getBaseUrl()` rather than introducing another site URL variable:
```typescript filename="lib/tasks/publish-job.ts" lineNumbers
import 'server-only';
import { qstash } from '@/lib/qstash';
import { getBaseUrl } from '@/lib/utils';
export async function publishJob(jobId: string): Promise {
const result = await qstash.publishJSON({
url: `${getBaseUrl()}/api/tasks/process`,
body: { jobId },
retries: 3
});
return result.messageId;
}
```
Do not expose this function directly to the browser. Call it from an
authenticated server action, route or tRPC procedure after checking access to
the organization that owns the stored job.
## Test locally
The managed QStash service cannot deliver to an inaccessible localhost URL. Use
the official local server or a public development tunnel.
Start local QStash in one terminal:
```sh filename="Terminal" lineNumbers
npx @upstash/qstash-cli dev
```
Copy the printed local URL, token and signing keys into the root `.env`, then
restart `npm run dev`. Publish a test job and verify all four outcomes:
1. The producer returns a QStash message ID.
2. The signed worker changes the stored job from pending to successful.
3. A failing attempt is retried and remains observable.
4. Publishing the same `jobId` again does not repeat its side effect.
## Production checklist
- Set managed QStash credentials in the production environment and remove any
local `QSTASH_URL` override.
- Confirm the destination uses the final HTTPS production origin.
- Keep both signing keys configured so key rotation can complete safely.
- Monitor QStash delivery logs and the application's stored job state.
- Add alerts for exhausted retries and jobs that remain pending too long.
- Avoid logging message bodies when they contain customer or organization data.
For delays, schedules, queues and callbacks, use the
[QStash TypeScript SDK documentation](https://upstash.com/docs/qstash/sdks/ts/gettingstarted).
---
## trigger.dev
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/background-tasks/trigger
**Description**: Integrate trigger.dev with your application for reliable background task processing.
[trigger.dev](https://trigger.dev) is an open-source background jobs framework that lets you write reliable workflows in plain async code.
The starter kits do not include Trigger.dev packages, configuration, task
definitions or credentials. Add this recipe only after choosing Trigger.dev as
your background-task provider.
trigger.dev provides automatic retries, real-time monitoring, and seamless
scaling - all while letting you write background tasks in familiar
JavaScript/TypeScript code directly in your project.
## Setup
Visit [trigger.dev](https://trigger.dev) and create a free account. Create a new project and note down your API key.
Add your trigger.dev API key to your environment variables:
```env filename=".env" lineNumbers
TRIGGER_SECRET_KEY=your_secret_key_here
```
For production, make sure to add the production API key to your deployment environment.
## Install dependencies
Install the SDK, build package and CLI. Keep all three on the same version:
```bash filename="Terminal" lineNumbers
npm install @trigger.dev/sdk@latest
npm install -D @trigger.dev/build@latest trigger.dev@latest
```
Commit the resolved versions from `package.json` and `package-lock.json`.
Trigger.dev warns when the CLI, SDK and build package versions drift.
## Configure trigger.dev
Create a `trigger.config.ts` file in the root of your project:
```typescript filename="trigger.config.ts" lineNumbers
import { defineConfig } from "@trigger.dev/sdk";
export default defineConfig({
project: "your_project_id", // Replace with your actual project ID
runtime: "node",
logLevel: "log",
maxDuration: 300,
dirs: ["./src/trigger"],
});
```
Update your `package.json` to include trigger.dev scripts:
```json filename="package.json" lineNumbers
{
"scripts": {
"trigger:dev": "trigger dev",
"trigger:deploy": "trigger deploy"
}
}
```
## Create your first task
Create a `src/trigger` directory and add your first task:
```typescript filename="src/trigger/process-user-data.ts" lineNumbers
import { task, logger, wait } from "@trigger.dev/sdk";
import * as z from "zod";
const ProcessUserDataSchema = z.object({
userId: z.string(),
operation: z.enum(["export", "analyze", "cleanup"]),
});
export const processUserDataTask = task({
id: "process-user-data",
run: async (payload: z.infer) => {
const { userId, operation } = payload;
logger.info("Starting user data processing", { userId, operation });
switch (operation) {
case "export":
await wait.for({ seconds: 2 });
logger.info("User data exported successfully");
return { success: true, result: "Data exported to CSV" };
case "analyze":
await wait.for({ seconds: 5 });
logger.info("User data analysis completed");
return {
success: true,
result: { totalActions: 156, avgSessionTime: "4m 32s" },
};
case "cleanup":
await wait.for({ seconds: 3 });
logger.info("User data cleanup completed");
return { success: true, result: "Removed 23 obsolete records" };
default:
throw new Error(`Unknown operation: ${operation}`);
}
},
});
```
Create a scheduled task:
```typescript filename="src/trigger/daily-cleanup.ts" lineNumbers
import { schedules, logger, wait } from "@trigger.dev/sdk";
export const dailyCleanupTask = schedules.task({
id: "daily-cleanup",
cron: "0 2 * * *",
run: async () => {
logger.info("Starting daily cleanup");
// Cleanup old logs
await wait.for({ seconds: 5 });
logger.info("Logs cleaned up");
// Cleanup temporary files
await wait.for({ seconds: 3 });
logger.info("Temp files cleaned up");
// Generate daily reports
await wait.for({ seconds: 8 });
logger.info("Reports generated");
return {
success: true,
cleanupTime: new Date().toISOString(),
itemsProcessed: 1247,
};
},
});
```
A string cron is evaluated in UTC. Use the object form documented by
Trigger.dev when the job must run in a named timezone. Reserve
`schedules.create()` for dynamic schedules created by an authenticated
server flow, not a fixed call executed when this module loads.
## Test your task
You can test your tasks locally by running:
```bash filename="Terminal" lineNumbers
npm run trigger:dev
```
This will deploy your tasks to trigger.dev in the development environment, allowing you to trigger them from the dashboard or programmatically.
## Deploy your tasks
To deploy your tasks to production on trigger.dev, run:
```bash filename="Terminal" lineNumbers
npm run trigger:deploy
```
You can also add this command as an automated deployment step in your CI/CD pipeline.
Add the `TRIGGER_ACCESS_TOKEN` secret to your repository secrets, which you can create in the trigger.dev dashboard.
```yaml filename=".github/workflows/deploy-tasks.yml" lineNumbers
name: Deploy to trigger.dev (prod)
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: npm install
- name: Deploy trigger tasks
env:
TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
run: |
npm run trigger:deploy
```
## Triggering tasks
You can trigger tasks from your application using the trigger.dev SDK.
### From an API route
Create an API route to handle task triggering:
```typescript filename="app/api/tasks/process-user-data/route.ts" lineNumbers
import { tasks } from "@trigger.dev/sdk";
import { NextResponse } from "next/server";
import * as z from "zod";
import { getSession } from "@/lib/auth/server";
import { processUserDataTask } from "@/src/trigger/process-user-data";
const processUserDataSchema = z.object({
operation: z.enum(["export", "analyze", "cleanup"]),
});
export async function POST(request: Request) {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { operation } = processUserDataSchema.parse(body);
const handle = await tasks.trigger(
"process-user-data",
{ userId: session.user.id, operation },
);
return NextResponse.json({
success: true,
taskId: handle.id,
message: "Background task started successfully",
});
}
```
### From a server action
```typescript filename="app/actions/user-actions.ts" lineNumbers
"use server";
import { tasks } from "@trigger.dev/sdk";
import { getSession } from "@/lib/auth/server";
import { processUserDataTask } from "@/src/trigger/process-user-data";
export async function processUserData(
operation: "export" | "analyze" | "cleanup",
) {
const session = await getSession();
if (!session) {
throw new Error("Unauthorized");
}
try {
const handle = await tasks.trigger(
"process-user-data",
{ userId: session.user.id, operation },
);
return {
success: true,
taskId: handle.id,
};
} catch (error) {
console.error("Failed to trigger background task:", error);
throw new Error("Failed to start background task");
}
}
```
### From the client
You can call the task endpoint from your React components:
```tsx filename="components/process-data-button.tsx" lineNumbers
"use client";
import { useMutation } from "@tanstack/react-query";
export function ProcessDataButton() {
const { mutate: processData, isPending } = useMutation({
mutationFn: async (operation: "export" | "analyze" | "cleanup") => {
const response = await fetch("/api/tasks/process-user-data", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ operation }),
});
if (!response.ok) {
throw new Error("Failed to start task");
}
return response.json();
},
onSuccess: (data) => {
console.log("Task started:", data.taskId);
},
});
return (
);
}
```
The authenticated server derives `userId`; the browser never chooses the
task owner. For organization-scoped work, accept an organization ID only as
a lookup key, verify membership with the shipped authorization helpers and
persist the authorized tenant scope before triggering the task.
## Monitoring and debugging
### Dashboard access
Visit the [trigger.dev dashboard](https://trigger.dev) to monitor your tasks:
- View task execution logs and performance metrics
- Track success and failure rates across all your tasks
- Monitor task duration and resource usage
- Replay failed tasks with a single click
- Set up alerts for task failures or performance issues
### Local development
During development, run your tasks locally while connected to trigger.dev:
```bash filename="Terminal" lineNumbers
npm run trigger:dev
```
This allows you to:
- Test tasks locally with real data
- Debug with breakpoints and console logs
- See immediate feedback as you develop
## Best practices
### Use descriptive task IDs
```typescript
// ✅ Good - Clear and descriptive
id: 'user-data-export-csv';
id: 'weekly-newsletter-campaign';
id: 'cleanup-temp-files';
// ❌ Not so good - Generic and unclear
id: 'task1';
id: 'job';
id: 'process';
```
### Include proper error handling
```typescript
run: async (payload) => {
try {
const result = await processData(payload);
logger.info("Task completed successfully", { result });
return result;
} catch (error) {
logger.error("Task failed:", error.message);
throw error; // Re-throw to trigger retry logic
}
},
```
### Use structured logging
```typescript
logger.info('Processing started', {
userId: payload.userId,
operation: payload.operation,
timestamp: new Date().toISOString()
});
```
### Keep tasks focused
Instead of one massive task, create focused, single-purpose tasks that can be
composed together for complex workflows.
### Configure appropriate retries
Set retry policies based on your task's requirements:
```typescript
// For critical operations
retry: {
maxAttempts: 5,
minTimeoutInMs: 2000,
maxTimeoutInMs: 30000,
factor: 2,
}
// For less critical operations
retry: {
maxAttempts: 2,
minTimeoutInMs: 1000,
maxTimeoutInMs: 5000,
factor: 1.5,
}
```
## Next steps
With trigger.dev integrated into your application, you can now:
- **Handle long-running operations** that would timeout in serverless functions
- **Schedule recurring tasks** like reports, cleanups, and maintenance
- **Process background jobs** reliably with automatic retries
- **Scale your application** without worrying about task execution infrastructure
Ready to explore more advanced features? Check out the official documentation for additional capabilities like webhooks, batching, and custom integrations.
---
## Vercel Workflows
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/background-tasks/vercel-workflows
**Description**: Integrate Vercel Workflows with your application for serverless background tasks.
[Vercel Workflows](https://vercel.com/docs/workflows) runs durable TypeScript
functions that can pause, retry and resume after a deployment or process failure.
It is built on the open-source Workflow SDK.
The starter kit does not install or configure Workflow. Check the current
pricing, limits and release status before making it part of a critical product
path. Use it when a process genuinely needs durable steps, not for an ordinary
short route handler.
## Install and configure Workflow
Run the current setup command from the starter kit root:
```sh filename="Terminal" lineNumbers
npx workflow@latest
```
The setup installs the `workflow` package and adds the required Next.js plugin.
Review the resulting `next.config.ts` carefully because the starter kit already
composes Content Collections, Fumadocs, Sentry and the bundle analyzer there.
Preserve those wrappers when adding `withWorkflow` from `workflow/next`, then
wrap the existing final configuration once. Running the setup a second time
should update the integration, not add another wrapper.
Use a current `workflow` release. Older beta releases contained a webhook
token vulnerability. Run `npm audit` after installation and follow the
Workflow SDK security guidance before exposing hooks or webhooks.
## Create a durable workflow
Keep orchestration in the workflow function and database or external-service I/O
inside step functions. Pass a stored job ID rather than a browser-supplied user
or organization object:
```typescript filename="workflows/process-stored-job.ts" lineNumbers
import { processStoredJob } from '@/lib/tasks/process-stored-job';
export async function processStoredJobWorkflow(jobId: string): Promise {
'use workflow';
await processJobStep(jobId);
}
async function processJobStep(jobId: string): Promise {
'use step';
await processStoredJob(jobId);
}
```
`processStoredJob` is product code you create. It should load the pending job
and its organization from the database, claim it atomically and record success
or failure. Make the operation idempotent because a step may be retried.
Workflow functions are deterministic orchestration code. Put database queries,
Node.js APIs, ordinary `fetch` calls and third-party SDK calls in a `use step`
function. Use Workflow's own durable primitives when the orchestration needs to
sleep or wait for an external event.
## Start the workflow after authorization
Start workflows through `workflow/api`. Do not call the workflow function
directly:
```typescript filename="app/api/tasks/process/route.ts" lineNumbers
import { NextResponse } from 'next/server';
import { processStoredJobWorkflow } from '@/workflows/process-stored-job';
import { start } from 'workflow/api';
import { z } from 'zod/v4';
import { getSession } from '@/lib/auth/server';
const requestSchema = z.object({
jobId: z.string().uuid()
});
export async function POST(request: Request): Promise {
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { jobId } = requestSchema.parse(await request.json());
// Verify that session.user can start this stored job before continuing.
const run = await start(processStoredJobWorkflow, [jobId]);
return NextResponse.json({ runId: run.runId }, { status: 202 });
}
```
The authorization comment is a required product-specific step, not optional
sample cleanup. Verify ownership or organization membership before starting the
workflow. Re-check access in delayed steps when permissions may have changed.
## Test the complete lifecycle
The Workflow CLI can verify the generated endpoints and inspect runs:
```sh filename="Terminal" lineNumbers
npx workflow health
npx workflow web
npx workflow inspect runs
```
Test more than the successful path:
1. An authenticated member can start a job they are allowed to operate.
2. Another user cannot start the same organization's job.
3. A transient step failure retries without repeating an external side effect.
4. A permanent failure becomes visible and does not remain pending forever.
5. Redeploying while a workflow is paused does not lose the run.
6. Starting the same stored job twice does not process it twice.
On Vercel, inspect runs under **Observability → Workflows**. Keep application job
state in the database as well so customers and support can see a stable product
status without depending on provider-specific run details.
## Production checklist
- Pin a reviewed Workflow SDK version and update it deliberately.
- Keep step inputs small and avoid secrets or complete customer records.
- Log stable job and organization identifiers, not sensitive payloads.
- Define which errors retry and which failures are permanent.
- Add alerts for failed runs and jobs that remain pending too long.
- Document how support can safely replay or cancel a job.
- Review [Workflow pricing and limits](https://vercel.com/docs/workflows/pricing-and-limits)
before launch.
Continue with the
[Workflow SDK documentation](https://useworkflow.dev/docs/getting-started) for
sleep, hooks, streaming and other durable primitives.
---
## Billing
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/billing
**Description**: Manage subscriptions, one-time payments and credit-based billing for AI features.
The Pro Next.js Drizzle starter kit provides a comprehensive billing system integrated with **Stripe**. It supports traditional subscriptions and a flexible credit system for AI-driven features.
## Configuration
Billing plans are configured in `config/billing.config.ts`. Credit packages are configured separately in the same file.
```typescript filename="config/billing.config.ts" lineNumbers
export const billingConfig = {
enabled: true,
defaultCurrency: 'usd',
plans: {
free: {
id: 'free',
name: 'Free',
isFree: true,
features: ['Basic features']
},
pro: {
id: 'pro',
name: 'Pro',
description: 'For professional developers',
prices: [
{
id: 'pro_monthly',
stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY ?? '',
type: 'recurring',
interval: 'month',
amount: 2900, // $29.00 in cents
currency: 'usd'
}
],
features: ['Advanced AI', 'Priority Support']
}
}
} satisfies BillingConfig;
// Credit packages are exported separately
export const creditPackages = [
{
id: 'basic',
name: 'Basic Credits',
credits: 1000,
priceAmount: 1000, // $10.00 in cents
stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC ?? ''
}
] as const;
```
## Subscriptions
Subscriptions are managed per organization. The kit handles checkout sessions, customer portals and webhooks automatically.
### Checking Subscription Status
You can check if an organization has an active subscription using tRPC queries:
```typescript filename="trpc/routers/organization/organization-router.ts" lineNumbers
import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init';
import { getActiveSubscriptionByOrganizationId } from '@/lib/billing';
export const organizationRouter = createTRPCRouter({
getSettings: protectedOrganizationProcedure.query(async ({ ctx }) => {
const subscription = await getActiveSubscriptionByOrganizationId(
ctx.organization.id
);
const isSubscribed =
!!subscription &&
(subscription.status === 'active' || subscription.status === 'trialing');
return { isSubscribed };
})
});
```
## Credit System
The Pro kit includes a robust credit system for usage-based billing, typically used for AI features.
### Consuming Credits
Use the `consumeCredits` helper to deduct credits from an organization's balance.
```typescript filename="lib/actions/ai.ts" lineNumbers
import { consumeCredits } from '@/lib/billing/credits';
await consumeCredits({
organizationId: ctx.organization.id,
amount: 50,
description: 'AI Image Generation',
referenceType: 'ai_image',
referenceId: imageId
});
```
### Checking Balance
```typescript filename="trpc/routers/organization/organization-credit-router.ts" lineNumbers
import { getCreditBalance } from '@/lib/billing/credits';
// In a procedure:
const { balance } = await getCreditBalance(organizationId);
```
## Webhooks
Stripe webhooks are handled in `app/api/webhooks/stripe/route.ts`. They keep the local database in sync with Stripe events (e.g., successful payments, subscription cancellations).
### Local Webhook Testing
To test webhooks locally, use the Stripe CLI:
```bash filename="Terminal" lineNumbers
npm run stripe:listen
```
## UI Components
The kit includes pre-built UI components for:
- **Pricing Tables**: Display plans and packages.
- **Billing Settings**: Manage subscriptions and view payment history.
- **Credit Dashboard**: View current balance and recent transactions.
---
## Check Purchases & Subscriptions
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/billing/check-purchases
**Description**: Learn how to check for purchases and subscriptions to provide access to premium features.
One of the most common use cases for billing is to provide access to premium features based on a user's or organization's subscription status.
## Plan IDs
Plan IDs are defined by the keys in your `billing.config.ts` file. For example, if you have:
```typescript filename="config/billing.config.ts" lineNumbers
export const billingConfig = {
plans: {
free: { isFree: true },
pro: {
/* ... */
},
lifetime: {
/* ... */
}
}
};
```
The plan IDs would be `"free"`, `"pro"` and `"lifetime"`.
## Client-Side Checks
Use tRPC queries to check for purchases and subscriptions on the client:
```tsx filename="components/premium-feature.tsx" lineNumbers
'use client';
import { trpc } from '@/trpc/client';
export function PremiumFeature() {
const { data: billingStatus } =
trpc.organization.subscription.getStatus.useQuery();
if (!billingStatus?.activePlan) {
return
Please subscribe to access this feature
;
}
// Check if user has a specific plan
const hasProPlan = billingStatus.activePlan.planId === 'pro';
const hasLifetimeAccess = billingStatus.activePlan.planId === 'lifetime';
const hasActiveSubscription =
billingStatus.subscription?.status === 'active' ||
billingStatus.subscription?.status === 'trialing';
if (!hasActiveSubscription && !hasLifetimeAccess) {
return
Please subscribe to access this feature
;
}
return
Premium feature content
;
}
```
### Billing Status Properties
The `getStatus` query returns:
- **`activePlan`**: The currently active plan (if any), containing `planId`, `planName`, `status`, etc.
- **`subscription`**: The active subscription object (if any), containing `status`, `currentPeriodEnd`, etc.
- **`enabled`**: Whether billing is enabled
## Server-Side Checks
### In tRPC Procedures
```typescript filename="trpc/routers/premium-feature.ts" lineNumbers
import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init';
import { TRPCError } from '@trpc/server';
import {
getActivePlanForOrganization,
hasActivePaidPlan,
hasSpecificPlan
} from '@/lib/billing';
export const premiumFeatureRouter = createTRPCRouter({
access: protectedOrganizationProcedure.query(async ({ ctx }) => {
const activePlan = await getActivePlanForOrganization(ctx.organization.id);
if (
!activePlan ||
(activePlan.planId === 'free' && !activePlan.isLifetime)
) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'This feature requires an active subscription'
});
}
return { data: 'premium content' };
})
});
```
### In Server Components
```typescript filename="app/(saas)/dashboard/premium/page.tsx" lineNumbers
import { getSession } from "@/lib/auth";
import { getActivePlanForOrganization } from "@/lib/billing";
import { redirect } from "next/navigation";
export default async function PremiumPage() {
const session = await getSession();
if (!session?.session.activeOrganizationId) {
redirect("/auth/sign-in");
}
const activePlan = await getActivePlanForOrganization(session.session.activeOrganizationId);
if (!activePlan || (activePlan.planId === 'free' && !activePlan.isLifetime)) {
redirect("/dashboard/choose-plan");
}
return
Premium page content
;
}
```
## Organization-Based Checks
Billing is organization-based by default. All checks use the organization ID from the context:
### Client-Side
```tsx filename="components/organization-feature.tsx" lineNumbers
'use client';
import { trpc } from '@/trpc/client';
export function OrganizationFeature() {
const { data: billingStatus } =
trpc.organization.subscription.getStatus.useQuery();
if (
!billingStatus?.subscription ||
(billingStatus.subscription.status !== 'active' &&
billingStatus.subscription.status !== 'trialing')
) {
return
This organization needs an active subscription
;
}
return
Organization premium feature
;
}
```
### Server-Side
```typescript filename="trpc/routers/organization-feature.ts" lineNumbers
import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init';
import { TRPCError } from '@trpc/server';
import { hasActivePaidPlan } from '@/lib/billing';
export const organizationFeatureRouter = createTRPCRouter({
access: protectedOrganizationProcedure.query(async ({ ctx }) => {
const hasActivePlan = await hasActivePaidPlan(ctx.organization.id);
if (!hasActivePlan) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'This organization requires an active subscription'
});
}
return { data: 'organization premium content' };
})
});
```
## Plan Limits
You can also check plan limits:
```typescript filename="lib/billing/check-limits.ts" lineNumbers
import { getOrganizationPlanLimits } from '@/lib/billing/guards';
export async function checkMemberLimit(organizationId: string) {
const limits = await getOrganizationPlanLimits(organizationId);
// -1 means unlimited
if (limits.maxMembers === -1) {
return true; // Unlimited members
}
// Check current member count against limit
const currentMembers = await getMemberCount(organizationId);
return currentMembers < limits.maxMembers;
}
```
## Helper Functions
Use the built-in guard functions for common checks:
```typescript filename="lib/billing/feature-guards.ts" lineNumbers
import { TRPCError } from '@trpc/server';
import {
hasActivePaidPlan,
hasSpecificPlan,
requirePaidPlan,
requireSpecificPlan
} from '@/lib/billing';
// Require any paid plan
export async function requireActivePlan(organizationId: string) {
await requirePaidPlan(organizationId);
}
// Require a specific plan
export async function requirePlan(organizationId: string, planIds: string[]) {
await requireSpecificPlan(organizationId, planIds);
}
// Check if has active plan (doesn't throw)
export async function checkHasActivePlan(
organizationId: string
): Promise {
return hasActivePaidPlan(organizationId);
}
// Check if has specific plan (doesn't throw)
export async function checkHasPlan(
organizationId: string,
planId: string
): Promise {
return hasSpecificPlan(organizationId, planId);
}
```
---
## Configuration
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/billing/configuration
**Description**: Learn about the billing configuration.
The billing configuration ensures consistent behavior across the application and any billing provider.
## Basic Setup
Configure your Stripe keys in `.env`:
```ini filename=".env" lineNumbers
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY=price_...
NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=price_...
NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=price_...
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER=price_...
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC=price_...
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PRO=price_...
```
## Products and Plans
Define your products and plans in your billing configuration file. A simple monthly pro plan would look like this:
```typescript filename="config/billing.config.ts" lineNumbers
import { env } from '@/lib/env';
export const billingConfig = {
enabled: true,
defaultCurrency: 'usd',
plans: {
free: {
id: 'free',
name: 'Free',
description: 'Get started with basic features',
isFree: true,
features: ['Basic analytics'],
limits: {
maxMembers: 3,
maxStorage: 1
}
},
pro: {
id: 'pro',
name: 'Pro',
description: 'Best for most teams.',
features: ['Feature 1', 'Feature 2'],
limits: {
maxMembers: 10,
maxStorage: 100
},
prices: [
{
id: 'pro_monthly',
stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY ?? '',
type: 'recurring',
interval: 'month',
intervalCount: 1,
amount: 2900, // $29.00 in cents
currency: 'usd'
}
]
}
}
} satisfies BillingConfig;
```
## Environment Variables
Make sure to set the following environment variables:
- `STRIPE_SECRET_KEY` - Your Stripe secret key
- `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` - Your Stripe publishable key
- `STRIPE_WEBHOOK_SECRET` - Your Stripe webhook secret (for production)
- `NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY` - Monthly Pro price ID
- `NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY` - Yearly Pro price ID
- `NEXT_PUBLIC_STRIPE_PRICE_LIFETIME` - One-time lifetime price ID
- `NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER` - Starter credit package price ID
- `NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC` - Basic credit package price ID
- `NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PRO` - Pro credit package price ID
## Email Configuration (Resend)
The starter kit uses [Resend](https://resend.com/) for sending emails. Configure Resend in your `.env`:
```ini filename=".env" lineNumbers
RESEND_API_KEY=re_...
EMAIL_FROM=noreply@yourdomain.com
```
### Setting up Resend
1. Create an account at [Resend](https://resend.com/)
2. Get your API key from the dashboard
3. Add your domain and verify it with DNS records
4. Set `EMAIL_FROM` to use your verified domain
For more details, see the [Email documentation](/docs/starter-kits/pro-nextjs-drizzle/email).
---
## Credits
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/billing/credits
**Description**: Learn how to implement and manage a credit-based billing system.
The Pro Next.js Drizzle starter kit includes a robust credit system for usage-based billing, typically used for AI features and pay-as-you-go services.
## Overview
Credits allow you to charge users based on their actual usage rather than fixed subscription tiers. This is ideal for:
- AI-powered features (image generation, text analysis, etc.)
- API calls and compute resources
- Pay-as-you-go services
## Configuration
Credit packages are configured separately in `config/billing.config.ts`:
```typescript filename="config/billing.config.ts" lineNumbers
import { env } from '@/lib/env';
// Credit packages are exported separately from billingConfig
export const creditPackages = [
{
id: 'credits_starter',
name: 'Starter',
credits: 10_000,
priceAmount: 999, // $9.99 in cents
stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER ?? ''
},
{
id: 'credits_basic',
name: 'Basic',
credits: 50_000,
bonusCredits: 5_000, // 10% bonus
priceAmount: 3999, // $39.99 in cents
stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC ?? ''
}
] as const;
```
## Consuming Credits
Use the `consumeCredits` helper to deduct credits from an organization's balance:
```typescript filename="lib/billing/credits.ts" lineNumbers
import { consumeCredits } from '@/lib/billing/credits';
await consumeCredits({
organizationId: ctx.organization.id,
amount: 50,
description: 'AI Image Generation',
referenceType: 'ai_image',
referenceId: imageId
});
```
The `consumeCredits` function will:
- Check if the organization has sufficient credits
- Deduct the specified amount
- Create a transaction record
- Throw an error if insufficient credits
## Checking Credit Balance
Get the current credit balance for an organization:
```typescript filename="trpc/routers/organization/organization-credit-router.ts" lineNumbers
import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init';
import { getCreditBalance } from '@/lib/billing/credits';
export const organizationCreditRouter = createTRPCRouter({
getBalance: protectedOrganizationProcedure.query(async ({ ctx }) => {
const { balance } = await getCreditBalance(ctx.organization.id);
return { balance };
})
});
```
## Adding Credits
Credits are automatically added when a user purchases a credit package. This is handled automatically via Stripe webhooks in `lib/billing/sync.ts`. The webhook handler processes `checkout.session.completed` events and adds credits based on the purchased package.
## Credit Transactions
All credit operations are tracked in the database, allowing you to:
- View transaction history
- Audit credit usage
- Generate reports
```typescript filename="trpc/routers/organization/organization-credit-router.ts" lineNumbers
import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init';
import { listCreditTransactions } from '@/lib/billing/credits';
export const organizationCreditRouter = createTRPCRouter({
getTransactions: protectedOrganizationProcedure.query(async ({ ctx }) => {
return await listCreditTransactions(ctx.organization.id, {
limit: 50,
offset: 0
});
})
});
```
## UI Components
The kit includes pre-built UI components for:
- **Credit Balance Display**: Show current balance
- **Credit Purchase**: Purchase credit packages
- **Transaction History**: View credit transactions
- **Low Balance Warnings**: Alert users when credits are running low
---
## Overview
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/billing/overview
**Description**: Learn about the billing package.
The starter kit comes with a billing package that enables you to charge your users, display relevant information and let them manage their plan or billing info.
## Monetization
The billing package supports multiple monetization options:
- **Subscriptions:** Automatically generates recurring invoices at fixed intervals, typically monthly or annually. Ideal for products that offer ongoing access.
- **One-time Payments:** Charges the customer a single upfront payment for perpetual access. Useful for lifetime deals, downloadable products or purchasable addons.
- **Credits:** A flexible credit system for usage-based billing, typically used for AI features and pay-as-you-go services.
## Configuration
The billing configuration ensures consistent behavior across the application and any billing provider.
---
## Paywall
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/billing/paywall
**Description**: Learn how to set up a paywall to restrict access to paid plans only.
By default, the starter kit includes a free plan, allowing users to access your application after signing up without payment. If you want to require a paid plan or trial, you can set up a paywall.
## Setting Up a Paywall
To enable a paywall, remove or disable the free plan in your billing configuration:
```typescript filename="config/billing.config.ts" lineNumbers
export const billingConfig = {
plans: {
// Remove the free plan
// free: {
// isFree: true,
// },
pro: {
// ... pro plan configuration
}
}
};
```
When the free plan is removed, users will be redirected to the plan selection page (`/dashboard/choose-plan`) after signup and onboarding.
## Checking for Active Plans
To restrict access to features based on plan status, check if the user has an active plan:
### Server-Side (tRPC)
```typescript filename="trpc/routers/premium-feature.ts" lineNumbers
import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init';
import { TRPCError } from '@trpc/server';
import { requirePaidPlan } from '@/lib/billing';
export const premiumFeatureRouter = createTRPCRouter({
access: protectedOrganizationProcedure.query(async ({ ctx }) => {
// This will throw if organization doesn't have a paid plan
await requirePaidPlan(ctx.organization.id);
// Allow access to premium feature
return { data: 'premium content' };
})
});
```
### Client-Side
```tsx filename="components/premium-feature.tsx" lineNumbers
'use client';
import Link from 'next/link';
import { trpc } from '@/trpc/client';
export function PremiumFeature() {
const { data: billingStatus } =
trpc.organization.subscription.getStatus.useQuery();
if (
!billingStatus?.activePlan ||
billingStatus.activePlan.planId === 'free' ||
(billingStatus.subscription?.status !== 'active' &&
billingStatus.subscription?.status !== 'trialing')
) {
return (
This feature requires an active subscription.
Upgrade now
);
}
return
Premium feature content
;
}
```
## Protecting Routes
You can protect entire routes based on plan status:
```typescript filename="app/(saas)/dashboard/premium/page.tsx" lineNumbers
import { getSession } from "@/lib/auth";
import { getActivePlanForOrganization } from "@/lib/billing";
import { redirect } from "next/navigation";
export default async function PremiumPage() {
const session = await getSession();
if (!session?.session.activeOrganizationId) {
redirect("/auth/sign-in");
}
const activePlan = await getActivePlanForOrganization(session.session.activeOrganizationId);
if (!activePlan || activePlan.planId === 'free') {
redirect("/dashboard/choose-plan");
}
return
Premium page content
;
}
```
## Plan-Specific Access
You can also check for specific plans:
```typescript filename="lib/billing/check-access.ts" lineNumbers
import { hasSpecificPlan } from '@/lib/billing';
export async function checkPlanAccess(
organizationId: string,
requiredPlanId: string
): Promise {
return hasSpecificPlan(organizationId, requiredPlanId);
}
```
```tsx filename="components/pro-feature.tsx" lineNumbers
'use client';
import Link from 'next/link';
import { trpc } from '@/trpc/client';
export function ProFeature() {
const { data: billingStatus } =
trpc.organization.subscription.getStatus.useQuery();
if (billingStatus?.activePlan?.planId !== 'pro') {
return (
This feature is only available on the Pro plan.
Upgrade to Pro
);
}
return
Pro feature content
;
}
```
## Organization-Based Paywalls
Billing is organization-based by default. Use the organization context:
```typescript filename="trpc/routers/organization-feature.ts" lineNumbers
import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init';
import { TRPCError } from '@trpc/server';
import { requirePaidPlan } from '@/lib/billing';
export const organizationFeatureRouter = createTRPCRouter({
access: protectedOrganizationProcedure.query(async ({ ctx }) => {
// This will throw if organization doesn't have a paid plan
await requirePaidPlan(ctx.organization.id);
return { data: 'organization premium content' };
})
});
```
---
## Plans & Products
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/billing/plans
**Description**: Learn how to manage plans and products in your application.
Plans and products are defined in `config/billing.config.ts`. This configuration determines what plans are available, their pricing, and how they appear in your pricing table.
Storage limits are metadata
The shipped kit does not meter stored objects or enforce{' '}
maxStorage. Storage amounts in feature lists and plan limits are
display and configuration metadata until you add usage tracking and
server-side guards.
## Plan Types
The starter kit supports several plan types:
### Free Plan
The free plan is the default plan for users who haven't purchased any paid plans. It provides limited access to your product.
```typescript filename="config/billing.config.ts" lineNumbers
export const billingConfig = {
plans: {
free: {
id: 'free',
name: 'Free',
description: 'Get started with basic features',
isFree: true,
features: [
'Up to 3 team members',
'Basic analytics',
'Community support'
],
limits: {
maxMembers: 3,
maxStorage: 1 // GB
}
// No prices needed for free plans
}
}
};
```
### Subscription Plans
Subscription plans charge users on a recurring basis (monthly, yearly, etc.):
```typescript filename="config/billing.config.ts" lineNumbers
import { env } from '@/lib/env';
export const billingConfig = {
plans: {
pro: {
id: 'pro',
name: 'Pro',
description: 'For growing teams',
recommended: true, // Highlights this plan in the pricing table
features: [
'Unlimited team members',
'Advanced analytics',
'Priority support',
'100 GB storage'
],
limits: {
maxMembers: -1, // unlimited
maxStorage: 100 // GB
},
prices: [
{
id: 'pro_monthly',
stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY ?? '',
type: 'recurring',
interval: 'month',
intervalCount: 1,
amount: 2900, // $29.00 in cents
currency: 'usd',
seatBased: true, // Per-seat pricing
trialDays: 14 // Optional: 14-day free trial
},
{
id: 'pro_yearly',
stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY ?? '',
type: 'recurring',
interval: 'year',
intervalCount: 1,
amount: 27800, // $278.00 in cents
currency: 'usd',
seatBased: true,
trialDays: 14
}
]
}
}
};
```
### One-Time Purchase Plans
One-time purchase plans charge users a single upfront payment:
```typescript filename="config/billing.config.ts" lineNumbers
import { env } from '@/lib/env';
export const billingConfig = {
plans: {
lifetime: {
id: 'lifetime',
name: 'Lifetime',
description: 'Pay once, use forever',
features: [
'All Pro features',
'Lifetime updates',
'Priority support for 1 year'
],
limits: {
maxMembers: -1,
maxStorage: 100
},
prices: [
{
id: 'lifetime_once',
stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_LIFETIME ?? '',
type: 'one_time',
amount: 49900, // $499.00 in cents
currency: 'usd'
}
]
}
}
};
```
### Enterprise Plan
Enterprise plans show up in the pricing table with a link to a contact form:
```typescript filename="config/billing.config.ts" lineNumbers
export const billingConfig = {
plans: {
enterprise: {
id: 'enterprise',
name: 'Enterprise',
description: 'For large organizations with custom needs',
isEnterprise: true,
features: [
'Everything in Pro',
'Dedicated account manager',
'Custom SLA',
'Unlimited storage',
'SSO / SAML'
],
limits: {
maxMembers: -1,
maxStorage: -1
}
// No prices needed - users contact you directly
}
}
};
```
## Price Properties
Each price object supports the following properties:
- **`id`**: Unique identifier for the price (e.g., `"pro_monthly"`)
- **`type`**: `"recurring"` or `"one_time"`
- **`stripePriceId`**: The Stripe Price ID (from environment variables, starts with `price_`)
- **`interval`**: For recurring plans: `"month"`, `"year"`, `"week"` or `"day"`
- **`intervalCount`**: Number of intervals to bill (defaults to 1)
- **`amount`**: The price amount (in cents for Stripe)
- **`currency`**: Currency code (e.g., `"usd"`, `"eur"`)
- **`trialDays`**: Optional trial period in days
- **`seatBased`**: If `true`, price is per seat (multiplies by number of organization members)
## Plan Configuration Options
- **`recommended`**: Highlights the plan in the pricing table
- **`hidden`**: Hides the plan from the pricing table (useful for grandfathering old plans)
## Creating Plans in Stripe
1. Go to your [Stripe Dashboard](https://dashboard.stripe.com/login)
2. Navigate to **Products** > **Add product**
3. Create your product with pricing
4. Copy the **Price ID** (starts with `price_`)
5. Add it to your environment variables:
```env filename=".env" lineNumbers
NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY=price_xxxxx
NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=price_xxxxx
NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=price_xxxxx
```
## Plan Limits
You can define plan limits in `config/billing.config.ts`:
```typescript filename="config/billing.config.ts" lineNumbers
export const billingConfig = {
plans: {
pro: {
// ... prices
limits: {
maxMembers: 10, // Maximum organization members
maxStorage: 50 // GB of plan metadata
}
}
}
};
```
The shipped invitation guards enforce `maxMembers`. `maxStorage` is plan
metadata until you add stored-byte usage tracking and a server-side guard. To
add another limit such as `maxProjects`, extend the billing schema and enforce
it on every relevant server mutation.
---
## Subscriptions
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/billing/subscriptions
**Description**: Learn how to set up and manage subscriptions.
## Creating a Subscription
To create a subscription, use the Stripe API:
```typescript filename="create-subscription.ts" lineNumbers
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }]
});
```
## Checking Subscription Status
Check if an organization has an active subscription:
```typescript filename="check-subscription.ts" lineNumbers
import {
getActivePlanForOrganization,
getActiveSubscriptionByOrganizationId
} from '@/lib/billing';
// Get the active subscription
const subscription =
await getActiveSubscriptionByOrganizationId(organizationId);
if (subscription?.status === 'active' || subscription?.status === 'trialing') {
// Organization has active subscription
}
// Or get the active plan (includes subscription and lifetime orders)
const activePlan = await getActivePlanForOrganization(organizationId);
if (activePlan && activePlan.planId !== 'free') {
// Organization has an active paid plan
}
```
## Canceling a Subscription
Allow users to cancel their subscriptions:
```typescript filename="cancel-subscription.ts" lineNumbers
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
await stripe.subscriptions.cancel(subscriptionId);
```
---
## Webhooks
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/billing/webhooks
**Description**: Learn how to handle Stripe webhooks.
Webhooks are used to receive events from Stripe. They are important to get the latest data so your application is in sync with Stripe.
## Setting up Webhooks
1. Go to your [Stripe Dashboard](https://dashboard.stripe.com/login?redirect=%2Fwebhooks)
2. Click "Add endpoint"
3. Enter your webhook URL: `https://yourdomain.com/api/webhooks/stripe`
4. Select the required events (see list below)
5. Copy the webhook signing secret
## Required Webhook Events
The following Stripe webhook events are handled by the webhook handler:
### Subscription Events
- `customer.subscription.created` - When a new subscription is created
- `customer.subscription.updated` - When a subscription is modified (plan changes, status updates)
- `customer.subscription.deleted` - When a subscription is canceled or expires
- `customer.subscription.trial_will_end` - When a trial is ending soon (3 days before)
- `customer.subscription.paused` - When a subscription is paused
- `customer.subscription.resumed` - When a paused subscription is resumed
### Checkout Events
- `checkout.session.completed` - When a checkout session completes (subscriptions, one-time payments, credit purchases)
### Invoice Events
- `invoice.paid` - When an invoice payment succeeds
- `invoice.payment_failed` - When an invoice payment fails
### Charge Events
- `charge.refunded` - When a charge is refunded (handles both full and partial refunds)
### Refund Events
- `refund.created` - When a refund is initiated
- `refund.updated` - When a refund's status updates
- `refund.failed` - When a refund fails
### Dispute Events
- `charge.dispute.created` - When a customer disputes a charge
- `charge.dispute.updated` - When a dispute status updates
- `charge.dispute.closed` - When a dispute is resolved
- `charge.dispute.funds_withdrawn` - Funds withdrawn from balance
- `charge.dispute.funds_reinstated` - Funds reinstated to balance
### Customer Events
- `customer.deleted` - When a customer is deleted from Stripe
### Payment Intent Events
- `payment_intent.succeeded` - When a payment intent succeeds (for audit logging)
## Webhook Handler
The starter kit includes a comprehensive webhook handler at `app/api/webhooks/stripe/route.ts` that handles all billing events. The handler includes:
- **Signature verification** - Validates webhook authenticity using Stripe's signature
- **Idempotency** - Prevents duplicate processing of the same event
- **Error handling** - Distinguishes between transient and permanent errors
- **Event logging** - Records all events in the database for audit trails
### Supported Events
The handler processes the following events:
- `checkout.session.completed` - Handles subscriptions, one-time payments and credit purchases
- `customer.subscription.created` - Creates subscription records
- `customer.subscription.updated` - Updates subscription status and plan changes
- `customer.subscription.deleted` - Marks subscriptions as canceled
- `customer.subscription.trial_will_end` - Sends trial ending notifications
- `customer.subscription.paused` - Handles subscription pauses
- `customer.subscription.resumed` - Handles subscription resumption
- `invoice.paid` - Logs successful invoice payments
- `invoice.payment_failed` - Sends payment failure notifications
- `charge.refunded` - Handles refunds (full and partial)
- `refund.created` - Tracks refund lifecycle
- `refund.updated` - Updates refund status
- `refund.failed` - Logs refund failure
- `charge.dispute.created` - Alerts admins of new chargebacks
- `charge.dispute.updated` - Updates dispute status
- `charge.dispute.closed` - Logs dispute resolution
- `customer.deleted` - Clears Stripe customer ID from organizations
- `payment_intent.succeeded` - Logs payment intents for audit
### Extending the Handler
To add custom logic for a specific event, you can modify the handler functions in `app/api/webhooks/stripe/route.ts`. For example, to add custom logic when a subscription is created:
```typescript filename="app/api/webhooks/stripe/route.ts" lineNumbers
async function handleSubscriptionCreated(
eventId: string,
subscription: Stripe.Subscription
): Promise {
// ... existing code ...
// Add your custom logic here
await sendWelcomeEmail(organizationId);
await createInitialResources(organizationId);
}
```
## Testing Webhooks
Install and authenticate the Stripe CLI, start the application and run the
included listener from a second terminal:
```sh filename="Terminal" lineNumbers
npm run stripe:listen
```
The CLI prints a temporary `whsec_...` signing secret. Put that value in the
local `.env` as `STRIPE_WEBHOOK_SECRET`, then restart the development server so
the handler reads it.
The Stripe CLI listener secret and the production endpoint secret are
different. Use the value printed by `stripe listen` locally. Store the
endpoint's Dashboard secret in the hosting provider for production.
You can ask the Stripe CLI to send a fixture event through the listener:
```sh filename="Terminal" lineNumbers
npm run stripe:trigger -- payment_intent.succeeded
```
A generated fixture proves that forwarding and signature verification work. It
may not contain the organization, price and checkout metadata created by the
application. Test state synchronization by completing a checkout through the
local UI with Stripe test-mode credentials, then confirm the related order,
subscription or credits in the application.
## Verify Production Delivery
After deploying:
1. Confirm the endpoint URL is the final HTTPS origin plus
`/api/webhooks/stripe`.
2. Confirm the endpoint is subscribed to every event used by your enabled
billing modes.
3. Complete a test-mode checkout and inspect its delivery in Stripe's webhook
event log.
4. Check that the application recorded the event and updated the intended
organization.
5. Resend the same event from Stripe and confirm it is treated as already
processed rather than applying credits or access twice.
The handler returns a failure status for transient processing errors so Stripe
can retry. Permanent data errors are recorded and acknowledged to avoid an
endless retry loop. Monitor failed billing-event records and Stripe delivery
attempts together when diagnosing synchronization problems.
---
## Blog
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/cms/blog
**Description**: Learn how to write blog posts using Content Collections.
The starter kit uses [Content Collections](https://www.content-collections.dev/) for managing blog content. All blog posts are written using `.mdx` files, which combine markdown with React components.
Why choose Content Collections?
Content Collections is a great alternative to headless CMS platforms such as
Contentful or Prismic. It's powered by MDX, free, open-source, and saves
content directly in your repository.
The blog is configured in `content-collections.ts` and uses [Fumadocs](https://www.fumadocs.dev/) for rendering documentation-style content.
## Add a new blog post
To create a new blog post, follow these steps:
1. **Create a new file**
Navigate to the `content/posts` directory and create a new `.mdx` file.
The file name will act as the URL slug for the post. For example:
- File name: `hello-world.mdx`
- URL: `https://your-app.com/blog/hello-world`
2. **Add metadata**
At the top of the `.mdx` file, include a frontmatter block. This block contains key metadata about your post, written in a YAML-like format enclosed by three dashes (`---`). Here's an example:
```mdx filename="content/posts/hello-world.mdx" lineNumbers
---
title: How to create a blog post
date: 2025-01-20T12:00:00.000Z
authorName: John Doe
authorImage: /authors/john.jpg
authorLink: https://example.com
excerpt: A short description of your blog post.
tags: [Innovation, Tutorial]
published: true
content: |
Your blog post content goes here...
---
```
### Frontmatter Fields
The blog post schema supports the following fields:
- `title` (required) - The title of the blog post
- `date` (required) - ISO 8601 date string for publication date
- `authorName` (required) - Name of the author
- `authorImage` (optional) - URL to author's image
- `authorLink` (optional) - Link to author's profile
- `excerpt` (optional) - A short description/excerpt of the post
- `tags` (required) - Array of tag strings
- `published` (required) - Boolean to control visibility
- `image` (optional) - Featured image URL
- `content` (required) - The full content of the post
## Using MDX Components
You can use React components directly in your MDX files. The starter kit provides several custom components:
```mdx filename="content/posts/example.mdx" lineNumbers
---
title: Example Post
date: 2025-01-20T12:00:00.000Z
authorName: John Doe
tags: [Example]
published: true
content: |
# My Blog Post
This is a callout component!
Regular markdown content here.
---
```
## Code Blocks
Code blocks are automatically highlighted and support line numbers:
```typescript filename="example.ts" lineNumbers
export function example() {
return 'Hello, World!';
}
```
## Images
You can include images in your blog posts:
```mdx filename="content/posts/example.mdx" lineNumbers

```
Or use the Image component for more control:
```mdx filename="content/posts/example.mdx" lineNumbers
import { Image } from '@/components/mdx-components';
```
## Building Content
Content Collections are automatically built during the Next.js build process. During development, they are rebuilt when files change.
## Querying Blog Posts
You can query blog posts in your application:
```typescript filename="app/blog/page.tsx" lineNumbers
import { allDocuments } from 'content-collections/generated';
export default function BlogPage() {
const posts = allDocuments('posts')
.filter((post) => post.published)
.sort((a, b) => {
const dateA = new Date(a.date);
const dateB = new Date(b.date);
return dateB.getTime() - dateA.getTime();
});
return (
{posts.map((post) => (
{post.title}
{post.excerpt}
))}
);
}
```
## Configuration
The blog collection is configured in `content-collections.ts`:
```typescript filename="content-collections.ts" lineNumbers
const posts = defineCollection({
name: 'posts',
directory: 'content/posts',
include: '**/*.{mdx,md}',
schema: z.object({
title: z.string(),
date: z.string(),
authorName: z.string(),
excerpt: z.string().optional(),
tags: z.array(z.string()),
published: z.boolean(),
content: z.string()
})
});
export default defineConfig({
collections: [posts]
});
```
## Best Practices
1. **Use descriptive filenames** - The filename becomes the URL slug
2. **Add descriptions** - Help with SEO and preview cards
3. **Use categories** - Organize related posts
4. **Set publication dates** - Control when posts appear
5. **Test locally** - Always preview posts before publishing
---
## Documentation
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/cms/documentation
**Description**: Write and organize product documentation with Fumadocs MDX.
The starter kit includes a Fumadocs documentation site at `/docs`. Its pages are local MDX files in `content/docs`, so the documentation stays versioned with the application code.
Both starter kit repositories use the same documentation structure. Do not
create an ORM-specific folder inside `content/docs`.
## How documentation is connected
| File | Responsibility |
| --- | --- |
| `content/docs/*.mdx` | Documentation content and page metadata |
| `content/docs/meta.json` | Sidebar groups, labels and page order |
| `source.config.ts` | Declares `content/docs` as the Fumadocs MDX source |
| `lib/marketing/docs/source.ts` | Loads the content and assigns the `/docs` base URL |
| `app/docs/layout.tsx` | Configures the documentation layout and navigation tree |
| `app/docs/[[...slug]]/page.tsx` | Renders each page and generates its metadata |
The kit already connects these files. You normally only need to edit `content/docs` when writing product documentation.
## Add a page
### Create the MDX file
Add a file directly under `content/docs`. Its path becomes the URL after `/docs`.
```mdx filename="content/docs/getting-started.mdx"
---
title: Getting started
description: Configure the application for local development.
icon: Rocket
---
## Prerequisites
Add your guide here.
```
This example is available at `/docs/getting-started`. The optional `icon` value must match an icon exported by Lucide React.
### Add the page to the sidebar
Add the filename without `.mdx` to the `pages` array in `content/docs/meta.json`:
```json filename="content/docs/meta.json"
{
"title": "Documentation",
"root": true,
"pages": ["index", "getting-started"]
}
```
Keep this array in the order you want readers to follow. Fumadocs also supports separators and external links in this file.
### Preview the page
Start the application and open the new route:
```bash
npm run dev
```
Visit `http://localhost:3000/docs/getting-started` and check the page on both desktop and mobile.
## Organize a section
For a larger topic, put its pages in a folder and add a `meta.json` inside that folder. The folder name becomes the URL segment.
```text
content/docs/
├── meta.json
└── billing/
├── meta.json
├── overview.mdx
└── webhooks.mdx
```
```json filename="content/docs/billing/meta.json"
{
"title": "Billing",
"pages": ["overview", "webhooks"]
}
```
The pages are then available at `/docs/billing/overview` and `/docs/billing/webhooks`. Add `billing` to the root `content/docs/meta.json` where that section should appear.
## Use the included MDX components
The page renderer registers Fumadocs components including `Callout`, `Cards`, `Tabs`, `Steps`, `Files` and `ImageZoom`. You can use them directly in an MDX page without importing them.
```mdx filename="content/docs/getting-started.mdx"
Copy `.env.example` to `.env` and provide the required values.
Use your local service credentials.Use credentials from the production project.
```
Standard fenced code blocks support syntax highlighting. Add a `filename` attribute when the file location helps the reader.
## Change the documentation UI
- Edit `app/docs/layout.tsx` to change the documentation shell or sidebar behavior.
- Edit `lib/marketing/docs/layout.config.tsx` to change shared layout options such as navigation links.
- Edit `app/docs/[[...slug]]/page.tsx` to register another MDX component or change page rendering.
- Edit `lib/marketing/docs/source.ts` only when changing how the content source is loaded.
Keep content changes in `content/docs` and layout changes in the application files above. This separation makes upgrades easier and keeps navigation generated from the same source as the pages.
## Validate before publishing
Run the same checks used for application changes:
```bash
npm run typecheck
npm run lint
npm run build
```
Also open every new documentation route locally. A successful build confirms that Fumadocs can compile the MDX, while the browser check catches navigation, layout and readability problems.
For advanced navigation and MDX options, see the [Fumadocs documentation](https://fumadocs.dev/docs/mdx).
---
## Overview
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/cms/overview
**Description**: Learn how to manage content using Content Collections and Fumadocs.
The starter kit uses [Content Collections](https://www.content-collections.dev/) for managing content and [Fumadocs](https://www.fumadocs.dev/) for rendering documentation. This provides a powerful, type-safe content management system that's easy to use and maintain.
Why Content Collections?
Content Collections is a great alternative to headless CMS platforms such as
Contentful or Prismic. It's powered by MDX, free, open-source, and saves
content directly in your repository. This means your content is
version-controlled and easy to manage.
## Features
- **Type-safe content** - Full TypeScript support with automatic type generation
- **MDX support** - Write content using Markdown with React components
- **Version control** - Content is stored in your repository, making it easy to track changes
- **Fast builds** - Content is compiled at build time for optimal performance
- **Developer-friendly** - Edit content using your favorite code editor
- **No database required** - Content is stored as files, not in a database
## Content Collections
Content Collections provides:
- **Schema validation** - Define schemas for your content using Zod
- **Automatic type generation** - TypeScript types are generated from your schemas
- **Query API** - Easy-to-use API for querying content
- **Transform functions** - Process and transform content during build
## Fumadocs
Fumadocs provides:
- **Beautiful UI** - Pre-built documentation UI components
- **Search** - Full-text search across your documentation
- **Dark mode** - Automatic theme switching
- **Responsive design** - Mobile-friendly layouts
- **Table of contents** - Automatically generated from headings
## Configuration
Content Collections is configured in `content-collections.ts`. The starter kit includes multiple collections:
```typescript filename="content-collections.ts" lineNumbers
import { defineCollection, defineConfig } from '@content-collections/core';
import { compileMDX } from '@content-collections/mdx';
import { z } from 'zod';
// Blog posts collection
const posts = defineCollection({
name: 'posts',
directory: 'content/posts',
include: '**/*.{mdx,md}',
schema: z.object({
title: z.string(),
date: z.string(),
authorName: z.string(),
excerpt: z.string().optional(),
tags: z.array(z.string()),
published: z.boolean(),
content: z.string()
}),
transform: async (document, context) => {
const body = await compileMDX(context, document);
return {
...document,
body,
path: document._meta.path.replace(/\.mdx?$/, '')
};
}
});
export default defineConfig({
collections: [posts]
});
```
## Building Content
Content Collections are automatically built during the Next.js build process. During development, they are rebuilt when files change.
## Querying Content
You can query content in your application:
```typescript filename="app/blog/page.tsx" lineNumbers
import { allDocuments } from 'content-collections/generated';
export default function BlogPage() {
const posts = allDocuments('posts')
.filter((post) => post.published)
.sort((a, b) => {
const dateA = new Date(a.date);
const dateB = new Date(b.date);
return dateB.getTime() - dateA.getTime();
});
return (
{posts.map((post) => (
{post.title}
{post.excerpt}
))}
);
}
```
## Best Practices
1. **Organize content** - Use clear directory structures
2. **Use schemas** - Define schemas for type safety
3. **Version control** - Commit content changes to git
4. **Test locally** - Always preview content before publishing
5. **Use MDX components** - Leverage React components in your content
---
## Common Commands
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/codebase/commands
**Description**: A practical reference for daily development, tests and local services.
Use these commands from the repository root. They are defined in
`package.json`, so prefer them over running the underlying tools directly.
## Everyday Development
| Command | Purpose |
| ------------------- | ------------------------------------------------- |
| `npm install` | Install project dependencies |
| `npm run dev` | Start the Next.js development server on port 3000 |
| `npm run build` | Create a production build |
| `npm run start` | Serve an existing production build |
| `npm run typecheck` | Check TypeScript without emitting files |
## Environment-aware commands
Next.js loads the root `.env` file for `dev`, `build` and `start`. The test
scripts use the repository's `with-dev-env` wrapper because Vitest and
Playwright also need those values outside the Next.js process:
```sh filename="Terminal" lineNumbers
npm run with-dev-env --
```
You normally do not need to call this wrapper yourself. Use it when running a
one-off tool that imports application modules depending on `lib/env.ts`.
## Code Quality
| Command | Purpose |
| ---------------------- | ------------------------------------ |
| `npm run format` | Check formatting with Oxfmt |
| `npm run format:write` | Apply safe formatting changes |
| `npm run lint` | Check lint rules with Oxlint |
| `npm run lint:write` | Apply safe lint fixes |
| `npm run check` | Run Oxlint and Oxfmt checks together |
Run the non-writing commands in CI. Review the diff after any command ending in
`:write` before committing its changes.
## Tests
| Command | Purpose |
| ------------------------- | ------------------------------------------------------- |
| `npm run test -- --run` | Run the unit test suite once and exit |
| `npm run test:watch` | Run unit tests in watch mode |
| `npm run test:coverage` | Run unit tests and collect coverage |
| `npm run test:db` | Include tests that require the configured test database |
| `npm run test:e2e:setup` | Install the Playwright browser |
| `npm run test:e2e` | Run Playwright end-to-end tests headlessly |
| `npm run test:e2e:headed` | Run Playwright while displaying the browser |
| `npm run test:e2e:ui` | Open Playwright's interactive test runner |
| `npm run test:e2e:debug` | Run Playwright with its inspector |
| `npm run e2e:ci` | Install Playwright browsers and run E2E tests for CI |
`test:db` and the Playwright commands load variables from the root `.env` file.
Use isolated test credentials and never point them at production services.
Running `npm run test` without `-- --run` can enter Vitest's interactive watch
workflow in a local terminal. Use the explicit one-shot command in scripts and
before commits so the process exits with a reliable status.
## Local Services
| Command | Purpose |
| ----------------------------------- | ---------------------------------------------- |
| `npm run docker:up` | Start the included PostgreSQL container |
| `npm run docker:down` | Stop the included containers |
| `npm run docker:logs` | Follow container logs |
| `npm run db:studio` | Open the Drizzle database browser |
| `npm run stripe:listen` | Forward Stripe CLI events to the local webhook |
| `npm run stripe:trigger -- ` | Ask Stripe CLI to emit a test event |
| `npm run email:dev` | Preview React Email templates on port 3001 |
Database migration commands differ between the starter kit variants. Follow
the [Drizzle database guide](/docs/starter-kits/pro-nextjs-drizzle/database)
before changing or applying a schema.
## 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 commandnpm run clean removes generated output, dependencies and ignored
files from the listed build directories. Commit or back up any ignored work
you intend to keep before running it.
---
## Dependencies
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/codebase/dependencies
**Description**: Learn how to manage dependencies in the starter kit.
We use [npm](https://www.npmjs.com/) as our package manager, which is the standard for Next.js projects.
About npm
npm is the default package manager for Node.js and comes pre-installed with
Node.js. It's widely supported and works seamlessly with Next.js and the
broader JavaScript ecosystem.
## Install all packages
To install all packages, run:
```sh filename="Terminal" lineNumbers
npm install
```
This is likely your first command when you download the starter kit.
## Add a package
To install a package as a dependency:
```sh filename="Terminal" lineNumbers
npm install
```
To install a package as a dev dependency:
```sh filename="Terminal" lineNumbers
npm install -D
```
## Remove a package
To remove a package:
```sh filename="Terminal" lineNumbers
npm uninstall
```
## Update packages
To update all packages to their latest versions:
```sh filename="Terminal" lineNumbers
npm run deps:update
```
## Check for outdated packages
To see which packages have updates available:
```sh filename="Terminal" lineNumbers
npm run deps:check
```
---
## Environment Variables
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/codebase/environment-variables
**Description**: Learn how environment variables are managed in the project.
The starter kit uses [`@t3-oss/env-nextjs`](https://env.t3.gg/) to manage environment variables with type safety and validation. All environment variables are defined in `lib/env.ts` with Zod schemas.
## Environment Variable Files
Create a `.env` file in the root directory (you can copy from `.env.example`):
```sh filename="Terminal" lineNumbers
cp .env.example .env
```
Security Note
Never commit `.env` to version control. It's already included in `.gitignore`.
Use the same variable names in every environment, but store the values in the
place that owns that environment:
| Environment | Where to set values | What to commit |
| ---------------------- | ------------------------------------------------ | ------------------------------------------ |
| Local development | Root `.env` file | Only `.env.example` with safe placeholders |
| Vercel or another host | The project's environment variable settings | Nothing containing production values |
| CI | The CI provider's encrypted secrets or variables | Workflow references to the variable names |
After changing a local value, restart the development server. After changing a
hosted value, redeploy the affected environment so Next.js can include any
build-time values in the new deployment.
## Server and Browser Variables
The `server` and `client` schemas in `lib/env.ts` are a security boundary:
- Server variables such as `DATABASE_URL`, `BETTER_AUTH_SECRET`,
`STRIPE_SECRET_KEY` and `RESEND_API_KEY` must never use the `NEXT_PUBLIC_`
prefix.
- Client variables must start with `NEXT_PUBLIC_`. Their values are included in
browser-accessible JavaScript and must not contain credentials or secrets.
- Adding a variable to `.env` does not add it to the validated application
configuration. Declare it in the matching schema and in `runtimeEnv` as shown
below.
Assume every public value is visible
Publishable Stripe keys, Price IDs, site URLs and Turnstile site keys can be
public. Stripe secret keys, webhook secrets, database credentials, Better Auth
secrets, Resend keys and Turnstile secret keys must remain server-only.
## Required Variables
The following environment variables are required for the application to run:
### Database
```env filename=".env"
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
```
Note
The POSTGRES_* variables (POSTGRES_USER, POSTGRES_PASSWORD,
POSTGRES_DB, POSTGRES_HOST, POSTGRES_PORT) are optional and have defaults.
Only DATABASE_URL is required.
### Authentication
```env filename=".env"
BETTER_AUTH_SECRET=paste-a-new-random-secret-here
```
Generate your own value
The authentication secret is named BETTER_AUTH_SECRET, not{' '}
AUTH_SECRET. Replace the development value copied from{' '}
.env.example before sharing or deploying the application. Every
environment should use its own secret.
## Optional Variables
Optional means the application can start without the integration. Once you
enable a feature, configure its complete variable set rather than adding one
key at a time.
| Feature | Configure together | If omitted |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
| AI chat | `OPENAI_API_KEY` | AI requests cannot reach OpenAI |
| Google sign-in | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | Google is not offered as a sign-in method |
| Email delivery | `EMAIL_FROM`, `RESEND_API_KEY` | Email-sending flows fail when invoked |
| Stripe billing | `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` and the Price IDs used by your configured plans or credits | Billing actions are unavailable |
| S3 storage | `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_ENDPOINT`, `NEXT_PUBLIC_IMAGES_BUCKET_NAME` | Uploads are unavailable |
| Turnstile | `TURNSTILE_SECRET_KEY`, `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | Captcha protection is disabled |
| Sentry source maps | `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_AUTH_TOKEN` | Builds do not upload source maps |
Keep paired values in sync
Configure both the server and browser value for Stripe and Turnstile. A
browser-only key can render an integration that the server cannot verify,
while a server-only key leaves the corresponding client flow unavailable.
### AI (OpenAI)
```env filename=".env"
OPENAI_API_KEY=sk-...
```
The shipped chat route uses the direct OpenAI provider. Its SDK reads
`OPENAI_API_KEY` from the server environment, so this variable is intentionally
not prefixed with `NEXT_PUBLIC_`. Remove the key if you disable the AI feature.
### Authentication (OAuth)
```env filename=".env"
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
```
### Billing (Stripe)
```env filename=".env"
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY=price_...
NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=price_...
NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=price_...
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER=price_...
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC=price_...
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PRO=price_...
```
### Email (Resend)
```env filename=".env"
EMAIL_FROM=noreply@example.com
RESEND_API_KEY=re_...
```
### Storage (S3)
```env filename=".env"
S3_ACCESS_KEY_ID=your-access-key
S3_SECRET_ACCESS_KEY=your-secret-key
S3_ENDPOINT=https://your-s3-compatible-endpoint.example
S3_REGION=your-provider-region
NEXT_PUBLIC_IMAGES_BUCKET_NAME=your-bucket-name
```
Use the endpoint and signing region supplied by your storage provider. The
storage client falls back to `auto` only when `S3_REGION` is omitted.
### Monitoring (Sentry)
```env filename=".env"
SENTRY_ORG=your-org
SENTRY_PROJECT=your-project
SENTRY_AUTH_TOKEN=your-auth-token
NEXT_PUBLIC_SENTRY_DSN=https://...@sentry.io/...
```
### Captcha (Cloudflare Turnstile)
```env filename=".env"
TURNSTILE_SECRET_KEY=your-secret-key
NEXT_PUBLIC_TURNSTILE_SITE_KEY=your-site-key
```
### Site Configuration
```env filename=".env"
NEXT_PUBLIC_SITE_URL=https://your-domain.com
NEXT_PUBLIC_LOG_LEVEL=info
```
## Type Safety
The project uses TypeScript and Zod to ensure type safety for environment variables. All variables are defined in `lib/env.ts` with validation schemas.
Type Safety
Environment variables are validated at build time and runtime. If a required
variable is missing or has an invalid type, the application will fail to start
with a clear error message.
## Adding New Variables
1. Add the variable to `lib/env.ts` in the appropriate schema (server or client)
2. Add the variable to `.env.example` (without sensitive values)
3. Add the variable to your `.env` file with the actual value
4. Add the variable to `runtimeEnv` in `lib/env.ts`
5. Restart your development server
### Example: Adding a Server Variable
```typescript filename="lib/env.ts" lineNumbers
server: {
// ... existing variables
MY_NEW_VAR: z.string().min(1),
},
```
```typescript filename="lib/env.ts" lineNumbers
runtimeEnv: {
// ... existing variables
MY_NEW_VAR: process.env.MY_NEW_VAR,
},
```
### Example: Adding a Client Variable
Client variables must be prefixed with `NEXT_PUBLIC_`:
```typescript filename="lib/env.ts" lineNumbers
client: {
// ... existing variables
NEXT_PUBLIC_MY_VAR: z.string().optional(),
},
```
```typescript filename="lib/env.ts" lineNumbers
runtimeEnv: {
// ... existing variables
NEXT_PUBLIC_MY_VAR: process.env.NEXT_PUBLIC_MY_VAR,
},
```
## Production
For production deployments, set environment variables in your hosting platform's dashboard (Vercel, Railway, etc.). Never commit production secrets to your repository.
### Skipping Validation
For Docker builds or CI/CD pipelines, you can skip environment variable validation:
```sh filename="Terminal" lineNumbers
SKIP_ENV_VALIDATION=true bun run build
```
This is useful when environment variables are provided at runtime rather than
build time. It only skips schema validation. It does not supply missing values,
so the related feature can still fail when used.
---
## Formatting & Linting
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/codebase/formatting-linting
**Description**: Learn how to format with Oxfmt and lint with Oxlint.
The starter kit uses [Oxfmt](https://oxc.rs/docs/guide/usage/formatter.html)
for formatting and [Oxlint](https://oxc.rs/docs/guide/usage/linter.html) for
linting. The tools have separate configuration and commands so a formatting
change never hides a lint failure.
Type-aware linting is enabled
Oxlint loads its TypeScript, React, import, accessibility and Next.js plugins
with type-aware analysis. Run the repository scripts from the project root so
the linter can resolve the local TypeScript configuration.
## Format and fix on save
The recommended VS Code workspace settings use the Oxc extension for both
formatting and safe lint fixes:
```json filename=".vscode/settings.json" lineNumbers
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.oxc": "always"
},
"oxc.fmt.configPath": ".oxfmtrc.json",
"oxc.fixKind": "safe_fix_or_suggestion",
"oxc.typeAware": true
}
```
Change `editor.formatOnSave` to `false` and remove the
`source.fixAll.oxc` action if you prefer to run the commands manually.
## Manual commands
Use non-writing commands in CI and before reviewing a change:
| Command | Purpose |
| ---------------- | ----------------------------------------- |
| `npm run format` | Check formatting with Oxfmt |
| `npm run lint` | Check lint rules with Oxlint |
| `npm run check` | Run linting and then the formatting check |
Use the writing variants when you intentionally want to change files:
| Command | Purpose |
| ---------------------- | -------------------------------------------- |
| `npm run format:write` | Format supported files with Oxfmt |
| `npm run lint:write` | Apply safe Oxlint fixes |
| `npm run check:write` | Apply lint fixes and then format the project |
Always review the resulting diff. Automated fixes can be valid while still
changing code in a way you did not intend.
## Configuration
Oxlint reads `.oxlintrc.json`. The shipped configuration enables TypeScript,
React, import, JSX accessibility, Next.js and Oxc rules. Generated output,
coverage, migration files and test reports are ignored.
Oxfmt reads `.oxfmtrc.json`. It defines the print width, quote and semicolon
style, import sorting and Tailwind CSS class sorting. Migration files and
generated or vendored output are excluded from formatting.
Keep tool exclusions in these configuration files rather than adding ad hoc
flags to package scripts. That keeps editor, local and CI behavior aligned.
## Editor integration
Install the
[Oxc VS Code extension](https://marketplace.visualstudio.com/items?itemName=oxc.oxc-vscode).
The repository already recommends it through `.vscode/extensions.json` and
sets it as the default formatter in `.vscode/settings.json`.
If VS Code still uses an older formatter:
1. Disable the older workspace formatter extension for this repository.
2. Run **Format Document With...** and choose **Oxc**.
3. Select **Configure Default Formatter** and choose **Oxc**.
4. Reload the editor after installing or upgrading the extension.
## Pre-commit behavior
The Husky pre-commit hook runs `lint-staged`. JavaScript and TypeScript files
receive safe Oxlint fixes followed by Oxfmt. JSON, CSS, Markdown and MDX files
are formatted with Oxfmt. Database migration files are deliberately excluded.
The pre-push hook runs `npm run typecheck`. These hooks are a fast guardrail,
not a replacement for the complete test and build checks in CI.
---
## Local Development
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/codebase/local-development
**Description**: Learn how to set up your local development environment.
This guide will help you set up your local development environment for the Pro Next.js Drizzle starter kit, including the necessary services like PostgreSQL.
## Prerequisites
To run the application locally, you need to have the following:
- [Node.js 22.21.1](https://nodejs.org/en), matching the version in `package.json`
- [npm](https://www.npmjs.com/) (comes with Node.js)
- [PostgreSQL](https://www.postgresql.org/download/) (v14 or later)
## Recommended Startup Order
Start only the services needed for the flow you are testing:
| Order | Service | Command | Local address | Required |
| ----- | ------------------------- | ----------------------- | ---------------------------------- | --------------------------------- |
| 1 | PostgreSQL 17 | `npm run docker:up` | `localhost:5432` | Yes |
| 2 | Next.js | `npm run dev` | `http://localhost:3000` | Yes |
| 3 | React Email preview | `npm run email:dev` | `http://localhost:3001` | Only when editing email templates |
| 4 | Stripe webhook forwarding | `npm run stripe:listen` | Forwards to `/api/webhooks/stripe` | Only when testing billing events |
The Stripe command requires the [Stripe CLI](https://docs.stripe.com/stripe-cli)
and an authenticated Stripe account. Copy the temporary `whsec_...` value it
prints into `STRIPE_WEBHOOK_SECRET`, then restart Next.js.
## Setting Up Local Services
### Option 1: Local PostgreSQL Installation
Install PostgreSQL on your machine and create a database:
```sh filename="Terminal" lineNumbers
createdb your_database_name
```
### Option 2: Docker Compose (Recommended)
The repository includes a `docker-compose.yml` file with PostgreSQL 17. It
creates a database named `database` with the password `password`.
## Starting the Services
1. Start the services using Docker Compose:
```sh filename="Terminal" lineNumbers
npm run docker:up
```
2. Verify that the services are running:
```sh filename="Terminal" lineNumbers
docker compose ps
```
## Environment Configuration
Start from the environment template shipped with the repository:
```sh filename="Terminal" lineNumbers
cp .env.example .env
```
The template already contains the local Docker database URL and
`NEXT_PUBLIC_SITE_URL`. Replace its example `BETTER_AUTH_SECRET` with a unique
value before starting the application:
Optional integrations may remain empty until you test them. Password signup
does require `RESEND_API_KEY` and `EMAIL_FROM` because new accounts must verify
their email. Use the [environment variables
guide](/docs/starter-kits/pro-nextjs-drizzle/codebase/environment-variables) to
select complete variable groups for email, Stripe, Google sign-in, AI, storage,
Turnstile and Sentry.
## Accessing the Services
- **PostgreSQL**:
- Host: localhost
- Port: 5432
- Username: postgres
- Password: password
- Database: database
## Running Database Migrations
After setting up your database, you have two options:
### Option 1: Push schema directly (for initial setup)
```sh filename="Terminal" lineNumbers
npm run db:push
```
### Option 2: Generate and apply migrations
```sh filename="Terminal" lineNumbers
npm run db:generate
npm run db:migrate
```
## Start Development Server
Start the development server:
```sh filename="Terminal" lineNumbers
npm run dev
```
Your application should now be running at `http://localhost:3000` with the local PostgreSQL database.
## Troubleshooting
### Database Connection Issues
If you're having trouble connecting to PostgreSQL:
1. Verify the database is running:
```sh filename="Terminal" lineNumbers
docker compose ps postgres
```
2. Check the logs:
```sh filename="Terminal" lineNumbers
docker compose logs postgres
```
3. Verify your `DATABASE_URL` in `.env` matches your database configuration
### Port Already in Use
If port 3000 is already in use, you can change it by setting the `PORT` environment variable:
```sh filename="Terminal" lineNumbers
PORT=3002 npm run dev
```
Port `3001` is reserved by the included React Email preview command. If you
change the application port, also update `NEXT_PUBLIC_SITE_URL`, OAuth callback
URLs and the target used by Stripe webhook forwarding.
## Stopping the Services
To stop all services:
```sh filename="Terminal" lineNumbers
npm run docker:down
```
To stop and remove all data (including volumes):
```sh filename="Terminal" lineNumbers
docker compose down -v
```
## Additional Resources
- [Docker Compose Documentation](https://docs.docker.com/compose/)
- [PostgreSQL Documentation](https://www.postgresql.org/docs/)
- [Next.js Documentation](https://nextjs.org/docs)
---
## Overview
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/codebase/overview
**Description**: Learn more about the codebase and how it is structured.
The Pro Next.js Drizzle starter kit is built as a single-repo Next.js application. This structure ensures efficient development and scalability, making it easy to manage all components of your application in one place.
---
## Updating the Kit
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/codebase/updating
**Description**: Safely merge Achromatic updates into a customized Drizzle project.
Achromatic ships updates through the private `pro-nextjs-drizzle` repository.
Keep your product repository as `origin` and add Achromatic as a separate
upstream source of updates.
## Add the Achromatic remote
Run this once from your project root:
```sh filename="Terminal" lineNumbers
git remote add achromatic https://github.com/achromaticlabs/pro-nextjs-drizzle.git
git fetch achromatic
```
Confirm that `origin` still points to your product repository:
```sh filename="Terminal" lineNumbers
git remote -v
```
Do not change the tracking branch for your product's main branch
to the Achromatic repository. Your commits should continue to push to your own
remote.
## Review an update before merging
Start with a clean working tree and fetch the latest release:
```sh filename="Terminal" lineNumbers
git status --short
git fetch achromatic
git log --oneline --decorate HEAD..achromatic/main
git diff --stat HEAD...achromatic/main
```
Read the [Achromatic changelog](/changelog) and inspect changes that touch
authentication, database migrations, billing, environment variables or
deployment before merging them.
## Merge on a dedicated branch
Create a branch from your current product state, then merge the upstream code:
```sh filename="Terminal" lineNumbers
git switch -c update/achromatic
git merge achromatic/main
```
Resolve conflicts in favor of your product requirements while preserving
security fixes and new committed migrations. Do not regenerate or delete
migration history just to make a merge clean.
After resolving conflicts:
```sh filename="Terminal" lineNumbers
npm install
npm run typecheck
npm run lint
npm run test
npm run build
```
Run the authenticated E2E suite against a disposable database when the update
changes authentication, organizations, billing, credits, settings or the admin
area:
```sh filename="Terminal" lineNumbers
npm run test:e2e:setup
npm run test:e2e
```
## Apply database changes safely
Review new migration files before applying them. Back up any database containing
data you need, then run the kit's deployment migration command in each target
environment:
```sh filename="Terminal" lineNumbers
npm run db:migrate
```
The Drizzle kit applies the committed migrations supplied with each update.
Generate a new migration only for schema changes made by your product, not for
migration files already supplied by Achromatic.
## Finish the update
Test the update branch in a preview environment. When it is ready, merge it into
your product's main branch using your normal review workflow.
If an update is too large to merge at once, review the upstream commits and
cherry-pick a focused security or dependency fix. Record skipped commits so the
same conflict is not investigated again during the next update.
---
## VS Code Setup
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/codebase/vscode
**Description**: Configure VS Code for the repository's formatter, linter, Tailwind CSS and TypeScript.
You can use any editor. The repository includes VS Code recommendations and
workspace settings so contributors use the same formatter, lint fixes and local
TypeScript version.
When you first open the repository, accept the recommended extensions from
`.vscode/extensions.json`. You can install them manually at any time.
## Oxc
The [Oxc extension](https://marketplace.visualstudio.com/items?itemName=oxc.oxc-vscode)
integrates Oxlint and Oxfmt. The workspace sets Oxc as the default formatter,
formats on save and applies safe lint fixes on save.
## Tailwind CSS IntelliSense
[Tailwind CSS IntelliSense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss)
adds completion, validation and hover previews for Tailwind classes. The
workspace points it to `app/globals.css`, which is the Tailwind CSS entry point.
## TypeScript
VS Code includes TypeScript language support. The workspace uses
`node_modules/typescript/lib` so editor diagnostics match the TypeScript
version installed by the repository.
If VS Code prompts you to choose a TypeScript version, select **Use Workspace
Version**.
## Included workspace behavior
The settings in `.vscode/settings.json`:
- format supported files with Oxfmt on save and paste
- apply safe Oxlint fixes on save
- enable type-aware Oxlint analysis
- use the local TypeScript SDK
- point Tailwind CSS IntelliSense at `app/globals.css`
- exclude generated Next.js output from search
- avoid auto-imports from unsupported Next.js entry points and `radix-ui`
Treat the workspace files as shared project configuration. Discuss changes
before committing personal editor preferences that affect every contributor.
---
## Configuration
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/configuration
**Description**: Learn how to configure your application using the configuration files.
The Pro Next.js Drizzle starter kit uses a modular configuration system that allows you to customize your application to your needs. Configuration is split into separate files in the `config/` directory, making it easy to manage different aspects of your application.
## Configuration Structure
Configuration files are located in the `config/` directory:
```text filename="Project Structure" lineNumbers
config/
├── app.config.ts # App-wide settings (name, themes, site sections)
├── auth.config.ts # Authentication settings (redirects, CORS, signup)
├── billing.config.ts # Billing and plans configuration
└── storage.config.ts # Storage bucket configuration
```
## Using Configuration
Configuration objects are exported from each file and can be imported where needed:
```typescript filename="lib/utils.ts" lineNumbers
import { appConfig } from '@/config/app.config';
export function getAppName() {
return appConfig.appName;
}
```
## Configuration Principles
### Type Safety
All configuration objects use TypeScript's `satisfies` keyword to ensure type safety while preserving literal types. This gives you autocomplete and type checking.
### Environment Variables
Configuration files can read from environment variables using the `env` object
from `@/lib/env`. This keeps sensitive values out of your code.
### Modular Design
Each configuration file focuses on a specific domain (app, auth, billing,
storage), making it easy to find and modify settings.
### Default Values
Configuration files provide sensible defaults, but you can override them to match your needs.
## Common Use Cases
### Disable Marketing Site
If you want to deploy only the SaaS application without the marketing site:
```typescript filename="config/app.config.ts" lineNumbers
export const appConfig = {
// ... other config
site: {
marketing: {
enabled: false // Disables marketing routes
},
saas: {
enabled: true
}
}
};
```
### Disable SaaS Application
If you want to deploy only the marketing site:
```typescript filename="config/app.config.ts" lineNumbers
export const appConfig = {
// ... other config
site: {
marketing: {
enabled: true
},
saas: {
enabled: false // Disables SaaS routes
}
}
};
```
### Gate the Starter Signup Page
Set `enableSignup` to `false` to hide signup links in the starter auth cards and
require a valid, pending invitation when someone opens the starter signup page:
```typescript filename="config/auth.config.ts" lineNumbers
export const authConfig = {
// ... other config
enableSignup: false // Gate the starter signup page by invitation
};
```
This setting is a UI and page-route gate. It does not block direct requests to
Better Auth's signup endpoint. Add server-side invitation validation in your
auth layer before describing the application as strictly invitation-only.
### Gate the Starter Organization Creation Path
To block non-admin organization creation through the starter's
`trpc.organization.create` procedure:
```typescript filename="config/app.config.ts" lineNumbers
export const appConfig = {
// ... other config
organizations: {
allowUserCreation: false // Guard the starter tRPC creation procedure
}
};
```
This setting does not configure Better Auth's organization endpoint. Passing
`allowUserToCreateOrganization: false` to the Better Auth `organization`
plugin disables creation through that endpoint for everyone. Use a function
that returns `true` for allowed users if you want to preserve an admin
exception.
## Next Steps
Explore the individual configuration files to learn more about each area:
- [App Configuration](/docs/starter-kits/pro-nextjs-drizzle/configuration/app) - App-wide settings
- [Authentication Configuration](/docs/starter-kits/pro-nextjs-drizzle/configuration/auth) - Auth settings
- [Billing Configuration](/docs/starter-kits/pro-nextjs-drizzle/configuration/billing) - Plans and pricing
- [Storage Configuration](/docs/starter-kits/pro-nextjs-drizzle/configuration/storage) - Storage buckets
---
## App Configuration
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/configuration/app
**Description**: Configure app name, site sections, themes, and organization settings.
The app configuration file (`config/app.config.ts`) contains application-wide settings including the app name, site sections, theme configuration, and organization settings.
## Configuration File
```typescript filename="config/app.config.ts" lineNumbers
import { getBaseUrl } from '@/lib/utils';
export const appConfig = {
appName: 'Acme',
description: `Acme's description`,
baseUrl: getBaseUrl(),
// Contact information (displayed on contact page)
contact: {
enabled: true,
email: 'hello@yourdomain.com',
phone: '(123) 456-7890',
address: '123 Main St, San Francisco, CA'
},
// Site sections - enable/disable major parts of the site
site: {
// Marketing website (landing page, blog, docs, etc.)
// When disabled, all marketing routes redirect to /dashboard
marketing: {
enabled: true
},
// SaaS application (dashboard, auth, etc.)
// When disabled, all /dashboard and /auth routes redirect to marketing homepage
saas: {
enabled: true
}
},
// Theme configuration
theme: {
// Default theme for new users: "light", "dark", or "system"
default: 'system' as const,
// Available themes users can choose from
available: ['light', 'dark'] as const
},
// Organization settings
organizations: {
// Guard non-admin access to the starter tRPC creation procedure
// This does not configure Better Auth's organization endpoint
allowUserCreation: true
},
// Pagination defaults
pagination: {
// Default page size for lists
defaultLimit: 20,
// Maximum allowed page size
maxLimit: 100
}
} satisfies AppConfig;
```
## Configuration Options
### App Information
- **`appName`**: The name of your application, displayed throughout the UI
- **`description`**: A brief description of your application
- **`baseUrl`**: The base URL of your application (automatically detected)
### Contact Information
The `contact` object configures contact information displayed on the contact page:
- **`enabled`**: Whether the contact form is enabled
- **`email`**: Contact email address
- **`phone`**: Contact phone number
- **`address`**: Physical address
### Site Sections
The `site` object controls which parts of your application are enabled:
- **`marketing.enabled`**: Enable/disable the marketing website (landing page, blog, docs)
- When disabled, all marketing routes redirect to `/dashboard`
- **`saas.enabled`**: Enable/disable the SaaS application (dashboard, auth)
- When disabled, all `/dashboard` and `/auth` routes redirect to marketing homepage
### Theme Configuration
The `theme` object controls theme settings:
- **`default`**: Default theme for new users (`"light"`, `"dark"`, or `"system"`)
- **`available`**: Array of themes users can choose from
### Organization Settings
The `organizations` object controls organization-related features:
- **`allowUserCreation`**: Whether the starter's
`trpc.organization.create` procedure accepts non-admin users
- When `false`, that procedure still accepts platform admins
- This does not configure Better Auth's organization endpoint
### Pagination
The `pagination` object sets default pagination values:
- **`defaultLimit`**: Default number of items per page
- **`maxLimit`**: Maximum allowed items per page
## Use Cases
### Deploy Marketing Site Only
To deploy only the marketing site without the SaaS application:
```typescript filename="config/app.config.ts" lineNumbers
export const appConfig = {
// ... other config
site: {
marketing: {
enabled: true
},
saas: {
enabled: false // Disables SaaS routes
}
}
};
```
### Deploy SaaS Application Only
To deploy only the SaaS application without the marketing site:
```typescript filename="config/app.config.ts" lineNumbers
export const appConfig = {
// ... other config
site: {
marketing: {
enabled: false // Disables marketing routes
},
saas: {
enabled: true
}
}
};
```
### Gate the Starter Organization Creation Path
To block non-admin creation through `trpc.organization.create`:
```typescript filename="config/app.config.ts" lineNumbers
export const appConfig = {
// ... other config
organizations: {
allowUserCreation: false // Guard the starter tRPC creation procedure
}
};
```
Passing `allowUserToCreateOrganization: false` to the Better Auth
`organization` plugin disables creation through that endpoint for everyone.
Use a function that returns `true` for allowed users if you want to preserve
the starter tRPC procedure's admin exception.
### Customize Theme Options
To customize available themes:
```typescript filename="config/app.config.ts" lineNumbers
export const appConfig = {
// ... other config
theme: {
default: 'dark' as const,
available: ['light', 'dark', 'system'] as const
}
};
```
## Type Definitions
The configuration uses TypeScript types for type safety:
```typescript filename="config/app.config.ts" lineNumbers
export type ContactConfig = {
enabled: boolean;
email: string;
phone: string;
address: string;
};
export type SiteConfig = {
marketing: {
enabled: boolean;
};
saas: {
enabled: boolean;
};
};
export type ThemeConfig = {
default: 'light' | 'dark' | 'system';
available: readonly ('light' | 'dark')[];
};
export type OrganizationsConfig = {
allowUserCreation: boolean;
};
export type PaginationConfig = {
defaultLimit: number;
maxLimit: number;
};
export type AppConfig = {
appName: string;
description: string;
baseUrl: string;
contact: ContactConfig;
site: SiteConfig;
theme: ThemeConfig;
organizations: OrganizationsConfig;
pagination: PaginationConfig;
};
```
## Using the Configuration
Import and use the configuration in your code:
```typescript filename="components/app-header.tsx" lineNumbers
import { appConfig } from "@/config/app.config";
export function AppHeader() {
return
{appConfig.appName}
;
}
```
```typescript filename="lib/pagination.ts" lineNumbers
import { appConfig } from '@/config/app.config';
export function getDefaultLimit() {
return appConfig.pagination.defaultLimit;
}
```
---
## Authentication Configuration
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/configuration/auth
**Description**: Configure authentication settings, redirects, and CORS.
The authentication configuration file (`config/auth.config.ts`) contains settings for authentication, session management, redirects, and CORS.
## Configuration File
```typescript filename="config/auth.config.ts" lineNumbers
import { env } from '@/lib/env';
import { getBaseUrl } from '@/lib/utils';
const origins = Array.from(
new Set(
[
getBaseUrl(),
env.NEXT_PUBLIC_SITE_URL,
env.NEXT_PUBLIC_VERCEL_URL
? `https://${env.NEXT_PUBLIC_VERCEL_URL}`
: undefined,
env.NEXT_PUBLIC_VERCEL_BRANCH_URL
? `https://${env.NEXT_PUBLIC_VERCEL_BRANCH_URL}`
: undefined,
env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL
? `https://${env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL}`
: undefined,
env.NEXT_PUBLIC_NODE_ENV === 'development'
? 'http://localhost:3000'
: undefined
].filter(Boolean) as string[]
)
);
export const authConfig = {
redirectAfterSignIn: '/dashboard',
redirectAfterLogout: '/',
sessionCookieMaxAge: 60 * 60 * 24 * 30,
verificationExpiresIn: 60 * 60 * 24 * 14,
minimumPasswordLength: 8,
trustedOrigins: origins,
// Controls signup links and the starter signup page
// This does not block Better Auth's signup endpoint
enableSignup: true,
enableSocialLogin: true,
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-drizzle/configuration/billing
**Description**: Configure plans, pricing, and credit packages.
The billing configuration file (`config/billing.config.ts`) contains settings for billing features, subscription plans, pricing, and credit packages for AI features.
## Configuration File
The billing configuration is extensive and includes:
- **Billing settings**: Enable/disable billing, default currency
- **Plans**: Subscription plans with features, limits, and pricing
- **Credit packages**: One-time credit purchases for AI features
- **Credit costs**: Per-model pricing for AI usage
```typescript filename="config/billing.config.ts" lineNumbers
import { env } from '@/lib/env';
export const billingConfig = {
// Enable/disable billing feature
enabled: true,
// Default currency
defaultCurrency: 'usd',
// Plans configuration
plans: {
// Free tier - no Stripe price needed
free: {
id: 'free',
name: 'Free',
description: 'Get started with basic features',
isFree: true,
features: [
'Up to 3 team members',
'Basic analytics',
'Community support',
'1 GB storage'
],
limits: {
maxMembers: 3,
maxStorage: 1 // GB
}
},
// Pro plan - main paid tier
pro: {
id: 'pro',
name: 'Pro',
description: 'For growing teams',
recommended: true,
features: [
'Unlimited team members',
'Advanced analytics',
'Priority support',
'100 GB storage',
'Custom integrations',
'API access'
],
limits: {
maxMembers: -1, // unlimited
maxStorage: 100 // GB
},
prices: [
{
id: 'pro_monthly',
stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY ?? '',
type: 'recurring',
interval: 'month',
intervalCount: 1,
amount: 2900, // $29.00 in cents
currency: 'usd',
seatBased: true, // Per-seat pricing
trialDays: 14
},
{
id: 'pro_yearly',
stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY ?? '',
type: 'recurring',
interval: 'year',
intervalCount: 1,
amount: 27800, // $278.00 in cents
currency: 'usd',
seatBased: true,
trialDays: 14
}
]
},
// Lifetime deal - one-time order
lifetime: {
id: 'lifetime',
name: 'Lifetime',
description: 'Pay once, use forever',
features: [
'All Pro features',
'Lifetime updates',
'Priority support for 1 year',
'100 GB storage'
],
limits: {
maxMembers: -1,
maxStorage: 100
},
prices: [
{
id: 'lifetime_once',
stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_LIFETIME ?? '',
type: 'one_time',
amount: 49900, // $499.00 in cents
currency: 'usd'
}
]
}
}
} satisfies BillingConfig;
```
Storage limits are not enforced
The current storage code does not read maxStorage or meter stored
bytes. Treat storage amounts as plan metadata until you add usage tracking and
server-side enforcement.
## Configuration Options
### Billing Settings
- **`enabled`**: Enable/disable billing feature (default: `true`)
- **`defaultCurrency`**: Default currency for pricing (default: `"usd"`)
### Plans
Each plan in the `plans` object has:
- **`id`**: Unique identifier for the plan
- **`name`**: Display name
- **`description`**: Plan description
- **`features`**: Array of feature strings
- **`limits`**: Plan limits (members, storage)
- **`prices`**: Array of price configurations
#### Plan Types
- **Free plans**: Set `isFree: true`, no prices needed
- **Paid plans**: Include `prices` array with Stripe price IDs
- **Enterprise plans**: Set `isEnterprise: true`, typically no prices (contact sales)
#### Price Configuration
Each price has:
- **`id`**: Unique price identifier
- **`stripePriceId`**: Stripe Price ID from your Stripe dashboard
- **`type`**: `"recurring"` or `"one_time"`
- **`amount`**: Price in cents
- **`currency`**: Currency code
- **`interval`**: For recurring: `"month"`, `"year"`, `"week"`, or `"day"`
- **`intervalCount`**: Number of intervals
- **`seatBased`**: Whether pricing is per-seat (optional)
- **`trialDays`**: Trial period in days (optional)
### Credit Packages
Credit packages are configured separately for one-time purchases:
```typescript filename="config/billing.config.ts" lineNumbers
export const creditPackages = [
{
id: 'credits_starter',
name: 'Starter',
description: 'Great for trying out AI features',
credits: 10_000,
bonusCredits: 0,
priceAmount: 999, // $9.99 in cents
currency: 'usd',
stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER ?? '',
popular: false
},
{
id: 'credits_basic',
name: 'Basic',
description: 'For regular AI usage',
credits: 50_000,
bonusCredits: 5_000, // 10% bonus
priceAmount: 3999, // $39.99
currency: 'usd',
stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC ?? '',
popular: true
}
];
```
### Credit Costs
Credit costs define pricing per AI model:
```typescript filename="config/billing.config.ts" lineNumbers
export const creditCosts = {
'gpt-4o-mini': {
input: 1, // credits per 1K input tokens
output: 6 // credits per 1K output tokens
},
'gpt-4o': {
input: 25,
output: 100
}
// ... more models
} as const;
```
## Use Cases
### Add a New Plan
To add a new subscription plan:
```typescript filename="config/billing.config.ts" lineNumbers
export const billingConfig = {
// ... other config
plans: {
// ... existing plans
business: {
id: 'business',
name: 'Business',
description: 'For larger teams',
features: [
'Everything in Pro',
'Advanced security',
'Dedicated support',
'500 GB storage'
],
limits: {
maxMembers: -1,
maxStorage: 500
},
prices: [
{
id: 'business_monthly',
stripePriceId: env.NEXT_PUBLIC_STRIPE_PRICE_BUSINESS_MONTHLY ?? '',
type: 'recurring',
interval: 'month',
intervalCount: 1,
amount: 9900, // $99.00
currency: 'usd',
seatBased: true
}
]
}
}
};
```
`NEXT_PUBLIC_STRIPE_PRICE_BUSINESS_MONTHLY` is a customization placeholder. It
is not defined by the shipped kit. Before using it, add the variable to the
client schema and `runtimeEnv` mapping in `lib/env.ts`, then add it to
`.env.example` and your deployment environment.
### Disable Billing
To disable billing entirely:
```typescript filename="config/billing.config.ts" lineNumbers
export const billingConfig = {
enabled: false
// ... other config
};
```
### Add Enterprise Plan
To add an enterprise plan (contact sales):
```typescript filename="config/billing.config.ts" lineNumbers
export const billingConfig = {
// ... other config
plans: {
// ... existing plans
enterprise: {
id: 'enterprise',
name: 'Enterprise',
description: 'For large organizations',
isEnterprise: true,
features: [
'Everything in Pro',
'Dedicated account manager',
'Custom SLA',
'Unlimited storage',
'SSO / SAML'
],
limits: {
maxMembers: -1,
maxStorage: -1
}
}
}
};
```
## Type Definitions
The configuration uses TypeScript types for type safety:
```typescript filename="config/billing.config.ts" lineNumbers
export type PriceConfig = {
id: string;
stripePriceId: string;
amount: number;
currency: string;
} & (
| {
type: 'recurring';
interval: 'month' | 'year' | 'week' | 'day';
intervalCount: number;
seatBased?: boolean;
trialDays?: number;
}
| {
type: 'one_time';
}
);
export type PlanLimits = {
maxMembers: number; // -1 for unlimited
maxStorage: number; // in GB, -1 for unlimited
};
export type Plan = FreePlan | PaidPlan | EnterprisePlan;
export type BillingConfig = {
enabled: boolean;
defaultCurrency: string;
plans: Record;
};
```
## Using the Configuration
Import and use the configuration in your code:
```typescript filename="lib/billing/plans.ts" lineNumbers
import { billingConfig } from '@/config/billing.config';
export function getPlanById(planId: string) {
return billingConfig.plans[planId];
}
export function getAllPlans() {
return Object.values(billingConfig.plans);
}
```
```typescript filename="lib/billing/credits.ts" lineNumbers
import { creditCosts, creditPackages } from '@/config/billing.config';
export function getCreditPackageById(id: string) {
return creditPackages.find((pkg) => pkg.id === id);
}
export function calculateCreditsForModel(
modelId: string,
inputTokens: number,
outputTokens: number
) {
const costs = creditCosts[modelId as keyof typeof creditCosts];
const inputCost = Math.ceil((inputTokens / 1000) * costs.input);
const outputCost = Math.ceil((outputTokens / 1000) * costs.output);
return inputCost + outputCost;
}
```
## Next Steps
For more information on billing, see:
- [Billing Overview](/docs/starter-kits/pro-nextjs-drizzle/billing/overview)
- [Plans](/docs/starter-kits/pro-nextjs-drizzle/billing/plans)
- [Subscriptions](/docs/starter-kits/pro-nextjs-drizzle/billing/subscriptions)
- [Credits](/docs/starter-kits/pro-nextjs-drizzle/billing/credits)
---
## Storage Configuration
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/configuration/storage
**Description**: Configure the image bucket used by the shipped storage integration.
The shipped configuration contains one bucket name for user avatars and organization logos.
## Current configuration
```typescript filename="config/storage.config.ts" lineNumbers
import { env } from '@/lib/env';
export const storageConfig = {
bucketNames: {
images: env.NEXT_PUBLIC_IMAGES_BUCKET_NAME ?? ''
}
} satisfies StorageConfig;
export type StorageConfig = {
bucketNames: {
images: string;
};
};
```
Set the value with:
```env filename=".env" lineNumbers
NEXT_PUBLIC_IMAGES_BUCKET_NAME="my-images-bucket"
```
The name is included in client-generated `/storage/{bucket}/{key}` URLs, so it is intentionally public configuration. Storage credentials must remain in the server-only `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY` variables.
## Adding another bucket
Additional document, video or tenant-specific buckets are a customization. Adding a property to `storageConfig` alone is not enough. You must also:
1. Add and map the environment variable in `lib/env.ts`.
2. Extend the `StorageConfig` type.
3. Decide which authenticated procedures may sign uploads for the bucket.
4. Add server-side object key and ownership rules.
5. Add an authorized download route for private data.
6. Configure provider credentials, CORS and lifecycle policies.
Do not add a private bucket to the shipped public `/storage/[...path]` handler unless its objects are intended to be accessible to anyone who knows their key.
## Related guides
- [Storage Overview](/docs/starter-kits/pro-nextjs-drizzle/storage/overview)
- [Setup](/docs/starter-kits/pro-nextjs-drizzle/storage/setup)
- [Upload Files](/docs/starter-kits/pro-nextjs-drizzle/storage/upload)
- [Access Files](/docs/starter-kits/pro-nextjs-drizzle/storage/access)
---
## Favicons & Icons
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/customization/favicons
**Description**: Learn how to generate and integrate favicons and app icons for your project.
## Generating a favicon
1. Visit [Favicon Generator](https://www.favicon-generator.org/).
2. Upload an image (recommended size: **at least 512×512px** for optimal resizing).
3. Click on **Create Favicon**
## Downloading
Click **Download** to save the generated files.
## Copying and overwriting
1. Select all downloaded files, **excluding** `browserconfig.xml` and `manifest.json`.
2. Copy and paste the files into the `public` directory.
## Updating metadata
Update the favicon references in `app/layout.tsx`:
```tsx filename="app/layout.tsx" lineNumbers
export const metadata = {
icons: {
icon: '/favicon.ico',
apple: '/apple-touch-icon.png'
}
// ...
};
```
Note that sometimes it takes time for the browser to reflect favicon changes.
Try clearing your browser cache or doing a hard refresh.
---
## Fonts
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/customization/fonts
**Description**: Learn how to change fonts using Google Fonts or custom fonts.
You can change the font in `app/layout.tsx`. The starter kit currently uses:
```tsx filename="app/layout.tsx" lineNumbers
import { Literata } from 'next/font/google';
import { GeistSans } from 'geist/font/sans';
const literata = Literata({
subsets: ['latin'],
variable: '--font-literata'
});
export default function RootLayout({ children }) {
return (
{/* ... */}
);
}
```
To change to a different Google Font, for example `Inter`:
```tsx filename="app/layout.tsx" lineNumbers
import { Inter } from 'next/font/google';
import { GeistSans } from 'geist/font/sans';
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter'
});
export default function RootLayout({ children }) {
return (
{/* ... */}
);
}
```
Or use a custom font:
```tsx filename="app/layout.tsx" lineNumbers
import localFont from 'next/font/local';
const customFont = localFont({
src: './fonts/custom-font.woff2',
display: 'swap'
});
```
## Font Variables
Font variables are automatically available via the `variable` prop. You can use them in your CSS:
```css filename="app/globals.css" lineNumbers
:root {
--font-sans: var(--font-geist-sans);
--font-literata: var(--font-literata);
}
```
Then use them in your components:
```tsx filename="components/example.tsx" lineNumbers
This uses Geist Sans
This uses Literata
```
---
## Naming & Branding
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/customization/naming
**Description**: Learn how to change the app name, description, and metadata throughout your application.
## App Name and Description
The app name and description are used throughout the application. Update them in the configuration file:
```typescript filename="config/app.config.ts" lineNumbers
export const appConfig = {
appName: 'Your App Name',
description: 'A fantastic SaaS to make your life easier.',
baseUrl: 'https://yourdomain.com'
// ...
};
```
This configuration is used in:
- Navigation and headers
- Email templates
- SEO metadata
- Social sharing
## Metadata
The metadata in `app/layout.tsx` automatically uses values from `appConfig`:
```typescript filename="app/layout.tsx" lineNumbers
import { appConfig } from '@/config/app.config';
export const metadata: Metadata = {
metadataBase: new URL(appConfig.baseUrl),
title: {
absolute: appConfig.appName,
default: appConfig.appName,
template: `%s | ${appConfig.appName}`
},
description: appConfig.description,
openGraph: {
type: 'website',
locale: 'en_US',
siteName: appConfig.appName,
title: appConfig.appName,
description: appConfig.description
},
twitter: {
card: 'summary_large_image',
title: appConfig.appName,
description: appConfig.description
}
};
```
## Package.json
Update the name and description in `package.json`:
```json filename="package.json" lineNumbers
{
"name": "your-app-name",
"version": "1.0.0",
"description": "Your app description",
"author": "Your Name",
"license": "MIT"
// ...
}
```
## Environment Variables
The `baseUrl` in `appConfig` uses `getBaseUrl()` which reads from `NEXT_PUBLIC_SITE_URL` if set, otherwise falls back to the request URL. You can set it in your `.env`:
```env filename=".env" lineNumbers
NEXT_PUBLIC_SITE_URL=https://yourdomain.com
```
## Email Branding
Update email templates to reflect your branding. Email templates are located in `lib/email/templates/`:
```typescript filename="lib/email/templates/welcome-email.tsx" lineNumbers
export function WelcomeEmail({ name }: { name: string }) {
return (
Welcome to Your App Name!Welcome to Your App Name!Hi {name},Welcome to Your App Name! We're excited to have you.
);
}
```
---
## Overview
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/customization/overview
**Description**: Learn how to customize your application to match your brand and requirements.
Customization is essential for making the starter kit your own. This section covers everything you need to personalize your application, from branding and theming to fonts and icons.
---
## Theming & Styling
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/customization/theming
**Description**: Learn how to customize colors, themes, and styling with Tailwind CSS and shadcn/ui.
The starter kit uses [Tailwind CSS](https://tailwindcss.com) for styling and [shadcn/ui](https://ui.shadcn.com) 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 (
);
}
);
Button.displayName = 'Button';
export { Button };
```
## Dark Mode
Dark mode is automatically handled by the theme system. Users can toggle between light and dark themes:
```tsx filename="components/theme-toggle.tsx" lineNumbers
'use client';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
);
}
```
## Global Styles
Customize global styles in `app/globals.css`:
```css filename="app/globals.css" lineNumbers
@import 'tailwindcss';
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
```
## Custom Themes
You can create custom themes by modifying the CSS variables:
```css filename="app/globals.css" lineNumbers
[data-theme='custom'] {
--primary: 142 76% 36%;
--primary-foreground: 355 100% 97%;
/* ... other custom colors */
}
```
Then apply the theme:
```tsx filename="app/layout.tsx" lineNumbers
{/* ... */}
```
---
## Database
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/database
**Description**: Learn how to manage your database, schema, and migrations with Drizzle ORM.
The Pro Next.js Drizzle starter kit uses **Drizzle ORM** with **PostgreSQL**. Drizzle provides a lightweight, fully type-safe way to interact with your database.
## Client Setup
The database client is initialized in `lib/db/client.ts` and exported from `lib/db/index.ts`. It uses the `postgres` driver for high performance.
```typescript filename="lib/db/client.ts" lineNumbers
import { drizzle } from 'drizzle-orm/node-postgres';
import { env } from '@/lib/env';
import * as schema from './schema';
export const db = drizzle(env.DATABASE_URL, {
schema
});
```
The client is then exported from `lib/db/index.ts`:
```typescript filename="lib/db/index.ts" lineNumbers
export * from './client';
export * from './schema';
```
## Schema Definition
Your database schema is defined in `lib/db/schema/`. We recommend splitting your schema into multiple files for better organization:
- `tables.ts`: Table definitions.
- `enums.ts`: Enum definitions.
- `relations.ts`: Relation definitions.
### Example Table Definition
```typescript filename="lib/db/schema/tables.ts" lineNumbers
import {
boolean,
index,
pgTable,
text,
timestamp,
uuid
} from 'drizzle-orm/pg-core';
import { organizationTable } from './organization';
export const leadTable = pgTable(
'lead',
{
id: uuid('id').primaryKey().defaultRandom(),
organizationId: uuid('organization_id')
.notNull()
.references(() => organizationTable.id, { onDelete: 'cascade' }),
firstName: text('first_name').notNull(),
lastName: text('last_name').notNull(),
email: text('email').notNull(),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow()
},
(table) => [index('lead_organization_id_idx').on(table.organizationId)]
);
```
## Migrations
### Commands
| Command | Description |
| --------------------- | --------------------------------------------- |
| `npm run db:generate` | Generate migration from schema changes |
| `npm run db:migrate` | Apply pending migrations |
| `npm run db:studio` | Open Drizzle Studio GUI |
| `npm run db:push` | Push schema directly (dev only, no migration) |
### Migration Workflow
1. **Edit schema** in `lib/db/schema/tables.ts`.
2. **Generate migration**: `npm run db:generate`.
3. **Review migration** in `lib/db/migrations/`.
4. **Apply migration**: `npm run db:migrate`.
## Multi-Tenancy
**Critical**: Always filter by `organizationId` for tenant data to ensure data isolation.
```typescript filename="trpc/routers/lead-router.ts" lineNumbers
const leads = await db.query.leadTable.findMany({
where: eq(leadTable.organizationId, ctx.organization.id)
});
```
## Transactions
Use transactions for related operations that must be atomic.
```typescript filename="lib/actions/widget.ts" lineNumbers
const result = await db.transaction(async (tx) => {
await tx
.delete(subscriptionItemTable)
.where(eq(subscriptionItemTable.subscriptionId, subId));
const items = await tx
.insert(subscriptionItemTable)
.values(newItems)
.returning();
return items;
});
```
---
## Client
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/database/client
**Description**: Learn how to use basic database operations with the Drizzle database client.
The database client is powered by Drizzle, a type-safe and lightweight ORM. Drizzle provides a functional approach to database interactions, allowing you to query, create, update, and delete records with full TypeScript support.
This guide outlines core operations with the database client, such as querying, creating, updating, and deleting records. For in-depth details and advanced usage, visit the [Drizzle ORM documentation](https://orm.drizzle.team/docs/overview).
## Select records
To retrieve records from the database, you can use the `db.select()` method provided by Drizzle.
Here's an example of how to query all records from a table:
```typescript filename="query-records.ts" lineNumbers
import { desc } from 'drizzle-orm';
import { db } from '@/lib/db';
import { userTable } from '@/lib/db/schema';
const allUsers = await db
.select()
.from(userTable)
.orderBy(desc(userTable.createdAt));
```
You can also filter records using the `where` clause:
```typescript filename="query-filtered-records.ts" lineNumbers
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { userTable } from '@/lib/db/schema';
const user = await db
.select()
.from(userTable)
.where(eq(userTable.email, 'user@example.com'));
```
To limit the number of results, use the `limit` method:
```typescript filename="query-limited-records.ts" lineNumbers
import { desc } from 'drizzle-orm';
import { db } from '@/lib/db';
import { userTable } from '@/lib/db/schema';
const recentUsers = await db
.select()
.from(userTable)
.orderBy(desc(userTable.createdAt))
.limit(10);
```
## Create record
To insert a new record into the database, you can use the `db.insert()` method and specify the table and values to be saved:
```typescript filename="insert-record.ts" lineNumbers
import { db } from '@/lib/db';
import { userTable } from '@/lib/db/schema';
const user = await db
.insert(userTable)
.values({
name: 'John Doe',
email: 'john.doe@gmail.com'
})
.returning();
```
By default, Drizzle returns the full row. You can modify the return behavior using `returning({})` to select specific fields.
## Update record
To update an existing record, use the `db.update()` method, specifying the table, conditions, and data to update:
```typescript filename="update-record.ts" lineNumbers
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { userTable } from '@/lib/db/schema';
const updatedUser = await db
.update(userTable)
.set({
name: 'John Doe Updated',
email: 'john.doe.updated@gmail.com'
})
.where(eq(userTable.id, 'some-uuid'))
.returning();
```
You can use `returning({})` to limit the returned fields and make the operation more efficient.
## Delete record
To delete a record from the database, use the `db.delete()` method and specify the table and conditions:
```typescript filename="delete-record.ts" lineNumbers
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { userTable } from '@/lib/db/schema';
const deletedUser = await db
.delete(userTable)
.where(eq(userTable.id, 'some-uuid'))
.returning();
```
This will remove the record from the database permanently. You can use or omit `returning({})` to limit the fields returned after the deletion if needed.
---
## Migrations
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/database/migrations
**Description**: Learn how to manage database migrations with Drizzle.
Migrations are a way to version control your database schema changes. They allow you to track, apply, and rollback database changes in a controlled and reproducible manner.
## Migration Workflow
The typical migration workflow consists of three steps:
### 1. Generate Migration
After updating your schema files, generate a migration:
```sh filename="Terminal" lineNumbers
npm run db:generate
```
This command:
- Analyzes your schema files in `lib/db/schema/`
- Compares them with the current database state
- Generates SQL migration files in `lib/db/migrations/`
- Creates a migration metadata file
Migration Files
Migration files are stored in lib/db/migrations/ and should be
committed to version control. Each migration has a unique name and contains
the SQL statements needed to apply the changes.
### 2. Review Migration
Before applying, review the generated migration file to ensure it contains the expected changes:
```sql filename="lib/db/migrations/xxxx_add_phone_field.sql" lineNumbers
ALTER TABLE "users" ADD COLUMN "phone" varchar(32);
```
You can edit the migration file if needed, but be careful - only modify the SQL if you understand the implications.
### 3. Apply Migration
To apply the migration to your database, run:
```sh filename="Terminal" lineNumbers
npm run db:migrate
```
This command:
- Executes the migration SQL against your database
- Updates the migration history table
- Ensures your database schema matches your code
## Migration Commands
### Generate Migration
Generate a new migration from schema changes:
```sh filename="Terminal" lineNumbers
npm run db:generate
```
This creates migration files but doesn't apply them to the database.
### Apply Migration
Apply pending migrations to the database:
```sh filename="Terminal" lineNumbers
npm run db:migrate
```
This runs all pending migrations in order.
### Push Changes (Development Only)
For rapid prototyping, push schema changes directly without creating a migration:
```sh filename="Terminal" lineNumbers
npm run db:push
```
Warningdb:push is useful for development but should not be used in
production. Always use migrations (db:generate and{' '}
db:migrate) for production deployments.
### Regenerate Migrations
If you need to regenerate migrations (e.g., after restoring from a backup):
```sh filename="Terminal" lineNumbers
npm run db:regenerate
```
This restores the migration files from the main branch and regenerates new migrations based on your current schema.
## Production Migrations
For production deployments, follow these steps:
1. **Generate migrations locally** - Run `npm run db:generate` after schema changes
2. **Review migrations** - Check the generated SQL files
3. **Test on staging** - Apply migrations to a staging database first
4. **Commit migrations** - Commit migration files to version control
5. **Deploy** - Run `npm run db:migrate` as part of your deployment process
Best Practice
Always test migrations on a staging database that mirrors production before
deploying to production.
## Migration Best Practices
1. **Always generate migrations** for production deployments
2. **Review migration files** before applying them
3. **Test migrations** on a staging database first
4. **Commit migration files** to version control
5. **Never edit existing migrations** - create new ones instead
6. **Use descriptive migration names** - The migration name should describe what it does
7. **Keep migrations small** - Break large changes into multiple migrations
8. **Don't delete migrations** - Even if you rollback, keep the migration files
## Migration History
Drizzle tracks migration history in a special table. You can view which migrations have been applied by checking your database.
The migration history table stores:
- Migration name
- Applied timestamp
- Migration SQL
## Troubleshooting
### Migration Fails
If a migration fails:
1. **Check the error message** - It usually indicates what went wrong
2. **Review the migration SQL** - Ensure the SQL is correct
3. **Check database state** - Verify the current database schema
4. **Fix the migration** - Edit the migration file if needed
5. **Re-run** - Try applying the migration again
### Migration Already Applied
If you see an error that a migration is already applied:
1. **Check migration history** - Verify which migrations have been applied
2. **Skip if safe** - If the migration was already applied, you can skip it
3. **Regenerate if needed** - Use `npm run db:regenerate` if migrations are out of sync
### Schema Out of Sync
If your schema files don't match your database:
1. **Review schema files** - Ensure they're up to date
2. **Check migration history** - See which migrations have been applied
3. **Generate new migration** - Run `npm run db:generate` to create a migration that brings the database in sync
4. **Apply migration** - Run `npm run db:migrate` to apply the changes
## Advanced Topics
### Custom Migration SQL
You can write custom SQL in migration files for complex changes:
```sql filename="lib/db/migrations/xxxx_custom_migration.sql" lineNumbers
-- Custom migration SQL
ALTER TABLE "users" ADD COLUMN "full_name" varchar(255);
UPDATE "users" SET "full_name" = "name" || ' ' || "last_name";
```
### Data Migrations
Migrations can also include data transformations:
```sql filename="lib/db/migrations/xxxx_data_migration.sql" lineNumbers
-- Data migration example
UPDATE "users" SET "status" = 'active' WHERE "status" IS NULL;
```
### Rollback Migrations
While Drizzle doesn't have built-in rollback support, you can:
1. **Create a new migration** - Write a migration that reverses the changes
2. **Manual rollback** - Manually revert the database changes
3. **Restore from backup** - Restore the database to a previous state
## Migration Files Structure
Migration files are organized as follows:
```text filename="lib/db/migrations/" lineNumbers
lib/db/migrations/
├── meta/
│ ├── _journal.json # Migration journal
│ └── 0000_snapshot.json # Schema snapshot
├── 0001_initial.sql # Initial migration
├── 0002_add_users.sql # Add users table
└── 0003_add_phone_field.sql # Add phone field
```
Each migration file contains the SQL needed to apply that specific change.
---
## Overview
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/database/overview
**Description**: Learn how to interact with the database in the starter kit.
The database serves as the backbone for storing data, handling queries and making sure users get what they need fast.
## Drizzle
The starter kit uses Drizzle as its data access solution.
Why choose Drizzle?
Drizzle is a fast and efficient ORM built for relational databases like
PostgreSQL and MySQL. It prioritizes type safety, flexibility and performance,
making it a strong choice for modern applications.
## Database driver
The project uses PostgreSQL as the default database provider, ensuring seamless integration. Drizzle also supports MySQL, SQLite, and other relational databases.
If you want to use a different database than PostgreSQL, you will need to configure the database provider in the database client configuration.
For a comprehensive list of supported database drivers, visit [Drizzle's Documentation](https://orm.drizzle.team/docs/get-started).
## Drizzle Studio
Drizzle's visual database editor allows you to view and edit your database records. You can open it with:
```sh filename="Terminal" lineNumbers
npm run db:studio
```
Make sure `.env` has a correct `DATABASE_URL` defined.
---
## Schema
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/database/schema
**Description**: Learn how to update your database schema and migrate changes with Drizzle.
The database schema is defined in Drizzle schema files located in `lib/db/schema/`. The schema is organized into multiple files for better maintainability, with each file representing a domain or feature.
## Schema Structure
The schema is typically organized as follows:
```text filename="lib/db/schema/" lineNumbers
lib/db/schema/
├── index.ts # Main schema export
├── users.ts # User table definition
├── organizations.ts # Organization tables
└── ... # Other domain schemas
```
## Updating the Schema
To update your database schema, edit the appropriate schema file. More information about Drizzle schema definitions can be found in the [Drizzle documentation](https://orm.drizzle.team/docs/schemas).
### Example: Adding a Field
For example, to add a new `phone` field to the `users` table:
```typescript filename="lib/db/schema/users.ts" lineNumbers
import { pgTable, varchar } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: varchar('id', { length: 255 }).primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
name: varchar('name', { length: 255 }),
phone: varchar('phone', { length: 32 }) // New field
// ... other fields
});
```
The field is defined as an optional string with a maximum length of 32 characters. Now you need to create a migration to apply this change to the database.
## Migration Workflow
Drizzle uses migrations to track and apply database schema changes. The migration workflow consists of three steps:
### 1. Generate Migration
Create a new migration by running:
```sh filename="Terminal" lineNumbers
npm run db:generate
```
This command:
- Analyzes your schema files
- Compares them with the current database state
- Generates SQL migration files in `lib/db/migrations/`
- Creates a migration metadata file
Migration Files
Migration files are stored in lib/db/migrations/ and are version
controlled. Each migration has a unique name and contains the SQL statements
needed to apply the changes.
### 2. Review Migration
Before applying, review the generated migration file to ensure it contains the expected changes:
```sql filename="lib/db/migrations/xxxx_add_phone_field.sql" lineNumbers
ALTER TABLE "users" ADD COLUMN "phone" varchar(32);
```
### 3. Apply Migration
To apply the migration to your database, run:
```sh filename="Terminal" lineNumbers
npm run db:migrate
```
This command:
- Executes the migration SQL against your database
- Updates the migration history table
- Ensures your database schema matches your code
## Alternative: Push Changes (Development Only)
For rapid prototyping during development, you can push schema changes directly without creating a migration:
```sh filename="Terminal" lineNumbers
npm run db:push
```
Warningdb:push is useful for development but should not be used in
production. Always use migrations (db:generate and{' '}
db:migrate) for production deployments.
## Migration Best Practices
1. **Always generate migrations** for production deployments
2. **Review migration files** before applying them
3. **Test migrations** on a staging database first
4. **Commit migration files** to version control
5. **Never edit existing migrations** - create new ones instead
## Schema Relationships
Drizzle supports defining relationships between tables. For example:
```typescript filename="lib/db/schema/users.ts" lineNumbers
import { relations } from 'drizzle-orm';
import { organizations } from './organizations';
export const usersRelations = relations(users, ({ many }) => ({
organizations: many(organizations)
}));
```
For more information on relationships, see the [Drizzle relations documentation](https://orm.drizzle.team/docs/relations).
## Regenerating Migrations
If you need to regenerate migrations (e.g., after restoring from a backup), you can use:
```sh filename="Terminal" lineNumbers
npm run db:regenerate
```
This restores the migration files from the main branch and regenerates new migrations based on your current schema.
---
## Studio
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/database/studio
**Description**: Learn how to use Drizzle Studio to view and interact with your database.
Drizzle Studio is a visual database editor that allows you to view and edit your database records directly in your browser.
## Start Drizzle Studio
To start Drizzle Studio, run the following command from the root of your project:
```sh filename="Terminal" lineNumbers
npm run db:studio
```
Drizzle Studio will open at https://local.drizzle.studio
Make sure your `DATABASE_URL` is correctly set in your `.env` file before starting Drizzle Studio.
## Using Drizzle Studio
Drizzle Studio allows you to:
- **View all your database tables and data** - Browse through all tables and see their records
- **Edit records directly in the browser** - Update, create, or delete records without writing SQL
- **Run queries and see results** - Execute SQL queries and view results
- **Inspect your database schema** - See the structure of your tables, columns, and relationships
## Features
### Browse Tables
Navigate through all your database tables using the sidebar. Click on any table to view its data.
### Edit Records
- **Add new records** - Click the "Add" button to create new records
- **Edit existing records** - Click on any cell to edit its value
- **Delete records** - Select records and delete them
### Run Queries
Use the query editor to run custom SQL queries against your database. This is useful for:
- Testing complex queries
- Debugging data issues
- Performing bulk operations
### Inspect Schema
View the schema of your tables, including:
- Column names and types
- Primary keys and foreign keys
- Indexes
- Constraints
## Alternative Database Tools
While Drizzle Studio is convenient, you can also use other database GUI tools:
- [TablePlus](https://tableplus.com/) - Modern database management tool
- [DBeaver](https://dbeaver.io/) - Universal database tool
- [pgAdmin](https://www.pgadmin.org/) - PostgreSQL administration tool
- [Postico](https://eggerapps.at/postico2/) - PostgreSQL client for macOS
## Troubleshooting
### Studio won't start
If Drizzle Studio won't start, check:
1. **Database connection** - Ensure `DATABASE_URL` is set correctly in `.env`
2. **Port availability** - Make sure port `4983` is not already in use
3. **Database running** - Verify your database server is running
### Can't see tables
If you can't see your tables in Drizzle Studio:
1. **Check schema** - Ensure your schema files are correct
2. **Run migrations** - Make sure all migrations have been applied
3. **Refresh** - Try refreshing the browser
---
## Deployment
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/deployment
**Description**: Learn how to deploy your Pro Next.js Drizzle application to production.
We recommend deploying your application to **Vercel** for the most direct
Next.js workflow, but you can use any provider that supports Node.js and
PostgreSQL.
## Prerequisites
Before deploying, ensure you have:
- A GitHub repository with your project code.
- A PostgreSQL database (Neon, Supabase, Railway, etc.).
- The provider accounts required by the features you keep, such as Stripe,
Resend or an S3-compatible storage service.
- A unique production authentication secret.
## Deploying to Vercel
1. Push your code to a GitHub repository.
2. Import the project into [Vercel](https://vercel.com).
3. Add the required environment variables for Production. Add them to Preview
only when preview deployments should connect to separate preview services.
4. Apply the committed database migrations once from CI or a one-off release
task.
5. Deploy the same commit that you validated locally.
### Environment Variables
Start with the complete
[environment variable guide](/docs/starter-kits/pro-nextjs-drizzle/codebase/environment-variables).
The minimum application and database values are:
```bash filename="Vercel Settings" lineNumbers
BETTER_AUTH_SECRET="generate-a-unique-production-secret"
DATABASE_URL="postgresql://user:pass@host:5432/db?sslmode=require"
NEXT_PUBLIC_SITE_URL="https://your-app.com"
```
Add provider variables only for the integrations you enable. Never copy live
secrets into variables prefixed with `NEXT_PUBLIC_`.
## Database Migrations
Both kits expose the same production migration command:
```sh filename="Terminal" lineNumbers
npm run db:migrate
```
Run it once against the production `DATABASE_URL` before the new application
revision receives traffic. A CI release job or a one-off task on your hosting
provider is suitable. Do not add migrations to `npm run build` and do not run
them from every application replica at startup.
Keep migration files in version control and review them with the code that
depends on the schema change. Use development migration commands only while
authoring a migration locally.
## SSL and Database Connections
Most production database providers (like Neon or Supabase) require SSL. Ensure your `DATABASE_URL` includes `?sslmode=require`.
```ini filename=".env" lineNumbers
DATABASE_URL="postgresql://user:pass@ep-xxx.region.aws.neon.tech/neondb?sslmode=require"
```
## Post-Deployment Checklist
Treat the first production deployment as a release, not only a successful
build. Complete each applicable check before sending customers to the app.
### Application and database
- [ ] Run `npm run typecheck`, `npm run lint`, `npm run test:unit -- --run` and `npm run build` against the release commit. The explicit `--run` keeps Vitest non-interactive on a developer machine and in CI.
- [ ] Run production migrations once and confirm the expected schema exists.
- [ ] Confirm the production database has backups and a tested restore procedure.
- [ ] Verify `NEXT_PUBLIC_SITE_URL` exactly matches the canonical production origin.
- [ ] Verify the custom domain, HTTPS certificate, redirects, `robots.txt` and sitemap.
### Authentication and email
- [ ] Create a new account and complete email verification on the production domain.
- [ ] Complete password reset and confirm its link returns to the production app.
- [ ] Update Google OAuth origins and callback URLs for the production domain.
- [ ] Confirm `EMAIL_FROM` uses a verified domain and replies go to a monitored address.
- [ ] Test organization invitations with a second email address.
### Billing
- [ ] Replace every Stripe test key and Price ID with its live-mode value.
- [ ] Configure the production webhook endpoint at `https://your-domain.com/api/webhooks/stripe`.
- [ ] Subscribe the endpoint only to events handled by the shipped webhook route.
- [ ] Complete a real or controlled live-mode purchase, then verify the local order or subscription state.
- [ ] Open the customer portal and verify cancellation or plan-change behavior for your product.
### Storage, monitoring and operations
- [ ] Restrict storage credentials to the required bucket and object operations.
- [ ] Upload and display an avatar or organization logo from the production domain.
- [ ] Confirm Sentry receives a controlled test error without exposing secrets or personal data.
- [ ] Confirm production logs use the intended level and do not contain credentials or tokens.
- [ ] Add uptime monitoring for the application and any business-critical webhook path.
- [ ] Document who receives billing, authentication and infrastructure alerts.
Features you have disabled do not need their provider checks. Do not configure
production credentials for integrations the application does not use.
---
## Docker
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/deployment/docker
**Description**: Learn how to deploy your application as a Docker container.
Deploying your application as a Docker container gives you control over the server environment, better privacy, potential cost savings, and flexibility to customize your setup. It can also improve performance compared to serverless platforms by removing cold starts.
## Setup Next.js for Docker Deployment
Configure Next.js to build as a standalone app for containerization. Update your `next.config.ts`:
```typescript filename="next.config.ts" lineNumbers
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
// ... other config
output: 'standalone'
};
export default nextConfig;
```
## Create Dockerfile
Create a `Dockerfile` in the root of your project:
```dockerfile filename="Dockerfile" lineNumbers
FROM node:22.21.1-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build the application
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy the standalone build
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]
```
## Create .dockerignore
Create a `.dockerignore` file in the root:
```text filename=".dockerignore" lineNumbers
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git
.env*.local
```
## Build and Run Locally
Test your Docker image locally:
```sh filename="Terminal" lineNumbers
docker build -t my-app .
docker run -p 3000:3000 --env-file .env my-app
```
## Deploy to Any Platform
You can now deploy this Docker image to any platform that supports Docker:
- **Fly.io** - See [Fly.io deployment guide](/docs/starter-kits/pro-nextjs-drizzle/deployment/fly)
- **Railway** - See [Railway deployment guide](/docs/starter-kits/pro-nextjs-drizzle/deployment/railway)
- **Render** - See [Render deployment guide](/docs/starter-kits/pro-nextjs-drizzle/deployment/render)
- **AWS ECS/Fargate** - Use AWS container services
- **Google Cloud Run** - Serverless container platform
- **DigitalOcean App Platform** - Managed container hosting
- **Your own server** - Deploy to any VPS with Docker
## Environment Variables
Make sure to set all required environment variables when running the container:
```sh filename="Terminal" lineNumbers
docker run -p 3000:3000 \
-e DATABASE_URL="postgresql://..." \
-e BETTER_AUTH_SECRET="..." \
-e NEXT_PUBLIC_SITE_URL="https://your-app.com" \
my-app
```
Or use an environment file:
```sh filename="Terminal" lineNumbers
docker run -p 3000:3000 --env-file .env.production my-app
```
## Database Migrations
Generate and review migration files during development:
```sh filename="Terminal" lineNumbers
npm run db:generate
```
Commit the generated files in `lib/db/migrations/` with the schema change. In
production, apply those committed migrations once as a release step:
```sh filename="Terminal" lineNumbers
npm run db:migrate
```
Run the command in CI before replacing the application containers or use a
one-off migration task provided by your container platform. Wait for it to
succeed before directing traffic to the new revision.
Keep migrations out of the image buildA
Docker build should not connect to or mutate a production database. Avoid
running migrations in the Dockerfile. Also avoid starting the migration
command independently in every application replica because several containers
may start at the same time.
## Troubleshooting
### SSL Errors
If you encounter SSL errors like `ERR_SSL_PACKET_LENGTH_TOO_LONG`, ensure your `DATABASE_URL` includes SSL parameters:
```ini filename=".env" lineNumbers
DATABASE_URL="postgresql://user:pass@host:5432/db?sslmode=require"
```
### Port Configuration
Make sure the port in your Dockerfile matches your Next.js configuration and the port you expose when running the container.
### Build Failures
If the build fails, check:
- Node.js version is 22.21.1, matching `package.json`
- All dependencies are properly installed
- Every environment variable required by the application build is available
- `output: 'standalone'` remains enabled in `next.config.ts`
For the complete container workflow, including runtime secrets, health checks
and reverse proxies, read
[Self-Host a Next.js SaaS With Docker](/blog/self-host-nextjs-saas-with-docker).
---
## Fly.io
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/deployment/fly
**Description**: Learn how to deploy your application to Fly.io.
[Fly.io](https://fly.io) is a platform for running full-stack apps and databases close to your users. It is well suited to Docker-based deployments. Review [Fly.io pricing](https://fly.io/docs/about/pricing/) before provisioning resources.
## Why Fly.io?
- **Global edge network** - Deploy close to your users
- **Docker-based** - Full control over your container
- **Usage-based pricing** - Choose resources for your application's requirements
- **Simple scaling** - Scale up or down easily
- **Database support** - Can provision PostgreSQL
## Prerequisites
Before deploying to Fly.io, you need to:
1. **Set up Docker** - Follow the [Docker deployment guide](/docs/starter-kits/pro-nextjs-drizzle/deployment/docker) to create a Dockerfile
2. **Install Fly CLI** - Install the [Fly CLI](https://fly.io/docs/flyctl/install/)
## Deploying to Fly.io
### 1. Create Fly.io Account
Sign up for a free account at [fly.io](https://fly.io).
### 2. Login to Fly CLI
```sh filename="Terminal" lineNumbers
fly auth login
```
### 3. Launch Your App
From your project root, run:
```sh filename="Terminal" lineNumbers
fly launch
```
The CLI will:
- Detect your Dockerfile
- Ask for an app name
- Ask if you want to set up a PostgreSQL database
- Create a `fly.toml` configuration file
### 4. Configure fly.toml
The generated `fly.toml` should look like this:
```toml filename="fly.toml" lineNumbers
app = "your-app-name"
primary_region = "iad"
[build]
[env]
PORT = "3000"
[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 0
processes = ["app"]
[[vm]]
memory = "256mb"
cpu_kind = "shared"
cpus = 1
```
### 5. Set Environment Variables
Set your environment variables:
```sh filename="Terminal" lineNumbers
fly secrets set DATABASE_URL="postgresql://..."
fly secrets set BETTER_AUTH_SECRET="..."
fly secrets set NEXT_PUBLIC_SITE_URL="https://your-app.fly.dev"
fly secrets set STRIPE_SECRET_KEY="..."
fly secrets set RESEND_API_KEY="..."
```
Or set multiple at once:
```sh filename="Terminal" lineNumbers
fly secrets set DATABASE_URL="..." BETTER_AUTH_SECRET="..." NEXT_PUBLIC_SITE_URL="..."
```
### 6. Deploy
Deploy your application:
```sh filename="Terminal" lineNumbers
fly deploy
```
Your app will be available at `https://your-app.fly.dev`.
## Database Migrations
Run migrations after deployment:
```sh filename="Terminal" lineNumbers
fly ssh console -C "npm run db:migrate"
```
Or use `db:push` for development:
```sh filename="Terminal" lineNumbers
fly ssh console -C "npm run db:push"
```
Or add to your Dockerfile's entrypoint script.
## Provision Database
If you didn't provision a database during `fly launch`:
```sh filename="Terminal" lineNumbers
fly postgres create --name your-app-db
fly postgres attach your-app-db
```
This will automatically set the `DATABASE_URL` secret.
## Custom Domain
To use a custom domain:
1. Add your domain:
```sh filename="Terminal" lineNumbers
fly domains add your-domain.com
```
2. Follow DNS configuration instructions
3. Update `NEXT_PUBLIC_SITE_URL` secret
## Scaling
Scale your app:
```sh filename="Terminal" lineNumbers
# Scale to 2 instances
fly scale count 2
# Scale memory
fly scale vm shared-cpu-1x --memory 512
```
## Monitoring
View logs and metrics:
```sh filename="Terminal" lineNumbers
# View logs
fly logs
# View metrics
fly status
```
## Troubleshooting
### SSL Errors
If you encounter SSL errors, ensure your `DATABASE_URL` includes SSL parameters:
```ini filename=".env" lineNumbers
DATABASE_URL="postgresql://user:pass@host:5432/db?sslmode=require"
```
### Build Failures
- Check build logs: `fly logs`
- Verify Dockerfile is correct
- Ensure all dependencies are installed
### Database Connection
- Verify `DATABASE_URL` secret is set: `fly secrets list`
- Check database is attached: `fly postgres list`
- Ensure database is in the same region
---
## Netlify
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/deployment/netlify
**Description**: Learn how to deploy your application to Netlify.
[Netlify](https://www.netlify.com/) is a popular platform for deploying web applications. While it's optimized for static sites, you can deploy Next.js applications using Netlify's Next.js runtime.
## Why Netlify?
- **Easy deployment** - Connect GitHub and deploy automatically
- **Free tier available** - Great for getting started
- **Automatic HTTPS** - SSL certificates included
- **Edge functions** - Run serverless functions at the edge
- **Preview deployments** - Automatic previews for PRs
## Deploying to Netlify
### 1. Create Netlify Account
Sign up for a free account at [netlify.com](https://www.netlify.com/).
### 2. Create New Site
1. Click **"Add new site"** → **"Import an existing project"**
2. Connect your Git provider (GitHub, GitLab, or Bitbucket)
3. Select your repository
### 3. Configure Build Settings
Netlify will auto-detect Next.js, but verify these settings:
- **Build command**: `npm run build`
- **Publish directory**: `.next`
- **Framework preset**: Next.js
For Next.js standalone output, you may need to adjust:
- **Build command**: `npm run build && npm run export` (if using static export)
- Or use Netlify's Next.js runtime (recommended)
### 4. Add Environment Variables
Add the deployment variables. `DATABASE_URL` and `BETTER_AUTH_SECRET` are
required. Add the Stripe and Resend variables when those features are enabled:
1. Go to **Site settings** → **Environment variables**
2. Add variables from your `.env`:
```env filename="Netlify Environment Variables" lineNumbers
DATABASE_URL=postgresql://...
BETTER_AUTH_SECRET=...
NEXT_PUBLIC_SITE_URL=https://your-app.netlify.app
STRIPE_SECRET_KEY=...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=...
STRIPE_WEBHOOK_SECRET=...
NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY=price_...
NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=price_...
NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=price_...
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER=price_...
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC=price_...
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PRO=price_...
RESEND_API_KEY=...
EMAIL_FROM=...
```
### 5. Deploy
Click **"Deploy site"** and Netlify will:
- Install dependencies
- Build your application
- Deploy to their CDN
Your app will be available at `https://your-app.netlify.app`.
## Netlify Configuration
Create a `netlify.toml` in your project root:
```toml filename="netlify.toml" lineNumbers
[build]
command = "npm run build"
publish = ".next"
[build.environment]
NODE_VERSION = "22.21.1"
[[plugins]]
package = "@netlify/plugin-nextjs"
```
## Database Migrations
Netlify doesn't support running migrations during build. You have a few options:
1. **Run migrations manually** before deploying
2. **Use a build plugin** to run migrations
3. **Run migrations via API route** (not recommended for production)
## Custom Domain
To use a custom domain:
1. Go to **Domain settings** → **Add custom domain**
2. Follow DNS configuration instructions
3. Update `NEXT_PUBLIC_SITE_URL` environment variable
## Functions Region
For better performance, select the region closest to your database:
1. Go to **Site configuration** → **Build & deploy** → **Functions**
2. Select the **Functions region** closest to your database
3. Redeploy your site
## Preview Deployments
Netlify automatically creates preview deployments for:
- Pull requests
- Branch pushes
- Merge commits
Each preview gets its own URL for testing.
## Environment Variables by Context
Netlify supports different environment variables for:
- **Production** - Production deployments
- **Deploy previews** - Preview deployments
- **Branch deploys** - Branch-specific deployments
## Troubleshooting
### Build Failures
- Check build logs in the Netlify dashboard
- Verify Node.js version (set in `netlify.toml`)
- Ensure all dependencies are in `package.json`
### Function Timeouts
- Netlify Functions have a 10-second timeout on free tier
- Upgrade to Pro for longer timeouts
- Optimize your API routes
### Database Connection Issues
- Verify `DATABASE_URL` is set correctly
- Check if database requires SSL (add `?sslmode=require`)
- Ensure database allows connections from Netlify's IPs
### Environment Variables
- Verify variables are set in the correct context
- Redeploy after adding new variables
- Check for typos in variable names
---
## Overview
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/deployment/overview
**Description**: Learn how to deploy your applications.
You can deploy the app to any hosting provider that supports Node.js. Since Next.js is developed by Vercel, deploying to Vercel offers the most seamless and optimized developer experience.
## Choose a hosting model
The starter kit ships as one Next.js service. It does not include a separately
deployed API server or persistent worker process.
| Model | Good fit | You operate |
| ----------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| Vercel or another serverless Next.js host | Automatic previews, managed scaling and the smallest operations surface | Environment variables, database migrations and provider configuration |
| A managed container platform | A portable image, longer-running requests and more runtime control | Image builds, health checks, scaling and release migrations |
| A self-hosted container or VPS | Infrastructure control and predictable host resources | TLS, reverse proxy, patching, restarts, monitoring, backups and capacity |
Choose based on the operations you are prepared to own, not only the initial
deployment cost. If the product needs durable background work, use a managed
background provider or operate a separate worker service. Do not depend on a
Next.js web instance remaining alive after an HTTP response.
Apply committed database migrations once from CI or a one-off release task.
Starting multiple web replicas must not race to apply the same migration.
## Production checklist
Complete these steps before sending production traffic to a new environment:
1. Create an empty PostgreSQL database and set its production `DATABASE_URL`.
2. Add the required server and browser variables from the
[environment variable guide](/docs/starter-kits/pro-nextjs-drizzle/codebase/environment-variables).
3. Replace the development `BETTER_AUTH_SECRET` with a unique production value.
4. Add `https://yourdomain.com/api/auth/callback/google` to the Google OAuth
client if Google sign-in is enabled.
5. Configure the Stripe webhook endpoint at
`https://yourdomain.com/api/webhooks/stripe` if billing is enabled.
6. Apply the committed database migrations once as a release step:
```sh filename="Terminal" lineNumbers
npm run db:migrate
```
7. Build the same revision that will be deployed:
```sh filename="Terminal" lineNumbers
npm run build
```
8. Verify sign-in, email delivery, organization access and one billing flow in
the deployed environment before announcing the release.
## Public launch checklist
A successful build only proves that the application compiled. Complete this
second pass before directing customers to it:
- Connect the final domain, set `NEXT_PUBLIC_SITE_URL` to its HTTPS URL and
redeploy so generated links and authentication callbacks use that origin.
- Replace the starter name, logo, contact details, legal text and sample
marketing content with your own product information.
- Verify the sender domain and review the authentication and invitation email
templates using real inboxes outside your company domain.
- Enable automated database backups and perform a restore rehearsal before the
database contains customer data.
- Configure provider budgets and alerts for every usage-based service,
including OpenAI, email, storage and monitoring.
- Test a failed payment, canceled subscription and Stripe webhook retry in test
mode, not only a successful checkout.
- Confirm that a non-admin user cannot open admin routes or another
organization's resources.
- Check the privacy policy and terms against the data and providers your
deployed product actually uses.
Give preview deployments their own database and provider credentials. Never
point an untrusted branch or pull request at the production database, Stripe
account or billable AI project.
## Control third-party spend
Treat every server-side provider key as access to a billable account. Use a
separate provider project or account for each environment so a development or
demo incident cannot consume the production budget.
For the included AI chat:
1. Set an OpenAI project budget and provider-side usage alerts before adding
`OPENAI_API_KEY` to production.
2. Keep model selection restricted to the allowlist in
`config/billing.config.ts`.
3. Configure application credits deliberately. Organization credits limit what
the product permits, but they do not replace the OpenAI project budget.
4. Do not fund an unrestricted key for an anonymous public demo. Disable live
generation or add a durable per-user and per-IP limiter first.
5. Monitor provider usage after launch and keep a documented way to revoke the
key quickly.
The starter kits check organization credit balance before AI generation and
deduct actual usage afterward. They do not ship a generic distributed
request-frequency limiter. Add one backed by shared durable storage before
exposing a billable endpoint to untrusted traffic.
Run the migration command once in CI or as a one-off release task. Do not run
it independently from every application container when several replicas may
start at the same time.
To learn more about deployment, explore the following guide:
---
## Railway
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/deployment/railway
**Description**: Learn how to deploy your application to Railway.
[Railway](https://railway.com/) is a modern platform that makes it easy to deploy full-stack applications. It offers a generous free tier and seamless integration with GitHub.
## Why Railway?
- **Simple deployment** - Connect your GitHub repo and deploy in minutes
- **Free tier available** - Great for testing and MVPs
- **Automatic HTTPS** - SSL certificates handled automatically
- **Database included** - Can provision PostgreSQL directly
- **Environment variables** - Easy management through the dashboard
## Deploying to Railway
### 1. Create Railway Account
Sign up for a free account at [railway.com](https://railway.com/).
### 2. Create New Project
1. Click **"New Project"** in the Railway dashboard
2. Select **"Deploy from GitHub repo"**
3. Connect your GitHub account if prompted
4. Select your repository
### 3. Configure Build Settings
Railway will auto-detect Next.js, but you can verify these settings:
- **Build Command**: `npm run build`
- **Start Command**: `npm start`
- **Root Directory**: `/` (root of your project)
### 4. Add Environment Variables
Add the deployment variables in the Railway dashboard. `DATABASE_URL` and
`BETTER_AUTH_SECRET` are required. Add the Stripe and Resend variables when
those features are enabled:
1. Go to your project → **Variables** tab
2. Add variables from your `.env`:
```env filename="Railway Environment Variables" lineNumbers
DATABASE_URL=postgresql://...
BETTER_AUTH_SECRET=...
NEXT_PUBLIC_SITE_URL=https://your-app.railway.app
STRIPE_SECRET_KEY=...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=...
STRIPE_WEBHOOK_SECRET=...
NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY=price_...
NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=price_...
NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=price_...
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER=price_...
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC=price_...
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PRO=price_...
RESEND_API_KEY=...
EMAIL_FROM=...
```
### 5. Provision Database (Optional)
Railway can provision a PostgreSQL database for you:
1. Click **"New"** → **"Database"** → **"Add PostgreSQL"**
2. Railway will automatically set the `DATABASE_URL` environment variable
3. Configure the pre-deploy migration command before releasing the web service
### 6. Deploy
Railway will automatically:
- Install dependencies
- Build your application
- Deploy to their infrastructure
Your app will be available at `https://your-app.railway.app`.
## Database Migrations
Keep the build command as `npm run build`. In the web service settings, set the
**Pre-Deploy Command** to:
```sh filename="Railway Pre-Deploy Command" lineNumbers
npm run db:migrate
```
Railway runs this command in a separate container after the build and before
the new deployment starts. The command receives the service environment
variables, including `DATABASE_URL`. A non-zero exit stops the deployment.
For a controlled one-off migration, you can run the same script from a trusted
local checkout with Railway's production variables:
```sh filename="Terminal" lineNumbers
railway run npm run db:migrate
```
Do not append migrations to `npm run build` and do not run them from every web
replica at startup.
## Custom Domain
To use a custom domain:
1. Go to **Settings** → **Domains**
2. Click **"Add Domain"**
3. Follow the DNS configuration instructions
4. Update `NEXT_PUBLIC_SITE_URL` to your custom domain
## Environment-Specific Variables
Railway supports environment-specific variables:
- **Production** - Used for production deployments
- **Preview** - Used for preview deployments (from PRs)
- **Development** - Used for local development with Railway CLI
## Monitoring
Railway provides:
- **Logs** - View real-time application logs
- **Metrics** - CPU, memory, and network usage
- **Deployments** - View deployment history
## Troubleshooting
### Build Failures
- Check build logs in the Railway dashboard
- Ensure all dependencies are in `package.json`
- Verify Node.js 22.21.1 is active, matching the version in `package.json`
### Database Connection Issues
- Verify `DATABASE_URL` is set correctly
- Check if database requires SSL (add `?sslmode=require`)
- Ensure database is accessible from Railway's IPs
### Environment Variables Not Loading
- Verify variables are set in the correct environment
- Check for typos in variable names
- Redeploy after adding new variables
---
## Render
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/deployment/render
**Description**: Learn how to deploy your application to Render.
[Render](https://render.com) is a managed cloud platform for deploying web
services and PostgreSQL databases from a Git repository.
## Why Render?
- **Git-based deployment** - Build automatically from your production branch
- **Managed HTTPS** - Connect a custom domain with managed TLS
- **Database support** - Provision PostgreSQL or connect an external database
- **Release controls** - Run migrations with a pre-deploy command on supported
plans
## Deploying to Render
### 1. Create Render Account
Sign up for a free account at [render.com](https://render.com).
### 2. Create New Web Service
1. Click **"New +"** in the Render dashboard
2. Select **"Web Service"**
3. Choose **"Build and deploy from a Git repository"**
4. Connect your GitHub account if prompted
5. Select your repository
### 3. Configure Service
Set the following configuration:
- **Name**: Your application name
- **Region**: Choose closest to your users
- **Branch**: `main` or your production branch
- **Root Directory**: `/` (leave empty if root)
- **Runtime**: `Node`
- **Build Command**: `npm run build`
- **Start Command**: `npm start`
### 4. Add Environment Variables
Add the deployment variables. `DATABASE_URL` and `BETTER_AUTH_SECRET` are
required. Add the Stripe and Resend variables when those features are enabled:
1. Scroll to **"Environment Variables"** section
2. Add variables from your `.env`:
```env filename="Render Environment Variables" lineNumbers
DATABASE_URL=postgresql://...
BETTER_AUTH_SECRET=...
NEXT_PUBLIC_SITE_URL=https://your-app.onrender.com
STRIPE_SECRET_KEY=...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=...
STRIPE_WEBHOOK_SECRET=...
NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY=price_...
NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=price_...
NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=price_...
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER=price_...
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC=price_...
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PRO=price_...
RESEND_API_KEY=...
EMAIL_FROM=...
```
### 5. Select Plan
Choose a plan that supports the uptime, compute and deployment features your
application needs. A testing plan can be suitable for evaluation, but use an
always-on service for production and confirm that your selected plan supports
the pre-deploy migration command described below.
### 6. Deploy
Click **"Create Web Service"** and Render will:
- Install dependencies
- Build your application
- Deploy to their infrastructure
Your app will be available at `https://your-app.onrender.com`.
## Database Migrations
Keep the build command as `npm run build`. On a paid Render service, set the
**Pre-Deploy Command** to:
```sh filename="Render Pre-Deploy Command" lineNumbers
npm run db:migrate
```
Render runs this after a successful build and before the new revision goes
live. If your plan does not support pre-deploy commands, run the same command
once from a trusted release environment against the production `DATABASE_URL`
before deploying the application revision.
Do not use `npm run db:push` in production and do not run migrations from every
web-service replica at startup.
## Provision Database (Optional)
Render can provision a PostgreSQL database:
1. Click **"New +"** → **"PostgreSQL"**
2. Configure database settings
3. Render will automatically set `DATABASE_URL`
4. Link the database to your web service
## Custom Domain
To use a custom domain:
1. Go to **Settings** → **Custom Domains**
2. Add your domain
3. Follow DNS configuration instructions
4. Update `NEXT_PUBLIC_SITE_URL` to your custom domain
## Auto-Deploy
Render automatically deploys when you push to your connected branch. You can:
- Enable/disable auto-deploy in settings
- Set up manual deploys
- Configure deploy hooks
## Monitoring
Render provides:
- **Logs** - Real-time application logs
- **Metrics** - CPU, memory usage
- **Events** - Deployment history
## Troubleshooting
### Service Sleeping (Free Tier)
The free tier service sleeps after 15 minutes of inactivity. To prevent this:
- Upgrade to a paid plan
- Use a service like [UptimeRobot](https://uptimerobot.com) to ping your app
### Build Failures
- Check build logs in the Render dashboard
- Verify Node.js 22.21.1 is selected, matching the version in `package.json`
- Ensure all dependencies are in `package.json`
### Database Connection Issues
- Verify `DATABASE_URL` is set correctly
- Check if database requires SSL (add `?sslmode=require`)
- Ensure database and web service are in the same region
### Environment Variables
- Verify variables are set correctly
- Redeploy after adding new variables
- Check for typos in variable names
---
## Vercel
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/deployment/vercel
**Description**: Learn how to deploy on Vercel.
Deploy the Pro Next.js Drizzle starter kit as a standard Next.js project on [Vercel](https://vercel.com). The repository is a single application, so it does not require monorepo root-directory or custom framework settings.
## Deploying to Vercel
Vercel is the easiest way to deploy Next.js apps. It's the company behind Next.js and has first-class support for Next.js.
### Setup Vercel account
To host your project on Vercel you first have to [create an account](https://vercel.com/signup).
### Connect your git repository
After signing up you will be prompted to import a git repository. Select the git provider of your project and connect your git account with Vercel.
Now you will see a list of all your projects. Select the project you want to deploy and click on the **Import** button.
### Configure project
In the **Configure Project** view expand the **Environment Variables** section and add the following variables one by one (you can copy them from the `.env` file in your projects root too):
```env filename="Vercel Environment Variables" lineNumbers
NEXT_PUBLIC_SITE_URL=
DATABASE_URL=
BETTER_AUTH_SECRET=
STRIPE_SECRET_KEY=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY=
NEXT_PUBLIC_STRIPE_PRICE_PRO_YEARLY=
NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_STARTER=
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_BASIC=
NEXT_PUBLIC_STRIPE_PRICE_CREDITS_PRO=
RESEND_API_KEY=
EMAIL_FROM=
```
`DATABASE_URL` and `BETTER_AUTH_SECRET` are required. `NEXT_PUBLIC_SITE_URL` is optional and should contain your stable production URL when set. The Stripe and Resend variables are only required when you enable those features. Copy every price variable referenced by your deployed `billingConfig`; the names above match the shipped configuration.
Then click the **Deploy** button and your project will be deployed.
## Environment Variables
Make sure to add all required environment variables in the Vercel Dashboard. You can add them during the initial setup or later in the project settings under the **Environment Variables** tab.
## Apply the database migrations
The application build does not apply database migrations. Generate and commit each migration while developing, then apply the committed migrations once against the production database before the new deployment receives traffic:
```sh filename="Terminal" lineNumbers
npm run db:migrate
```
Run this from a controlled CI release job or a one-off local session with the production `DATABASE_URL`. Do not add it to the Vercel build command because concurrent builds or replicas must not race to change the schema.
Use a pooled connection string intended for serverless workloads when your database provider offers one. Keep the application and database in nearby regions to reduce query latency. If the database firewall requires fixed source addresses, configure a supported secure connection method with the provider rather than assuming every Vercel function has a stable outbound IP.
## Build Settings
Vercel will automatically detect Next.js and configure the build settings. The starter kit includes the necessary build configuration:
```json filename="package.json" lineNumbers
{
"scripts": {
"build": "next build",
"postinstall": "fumadocs-mdx"
}
}
```
Vercel runs the `next build` script. Drizzle infers schema types from the
TypeScript source. This build does not run `drizzle-kit generate`. When you
change the database schema, generate and commit the migration files before
deploying.
Leave Vercel's framework preset set to **Next.js** and use the repository root. A failed environment validation identifies a missing required variable; do not bypass it with `SKIP_ENV_VALIDATION` for an ordinary Vercel deployment.
## Webhooks
If you're using Stripe webhooks, make sure to configure the webhook endpoint in your Stripe dashboard to point to your Vercel deployment URL:
```text filename="Webhook URL" lineNumbers
https://your-app.vercel.app/api/webhooks/stripe
```
After adding the endpoint, subscribe only to the events handled by `app/api/webhooks/stripe/route.ts`. Use Stripe test mode to verify a checkout and webhook delivery before switching to live keys.
## Verify the deployment
Before sending users to the application:
1. Open the production URL and confirm it uses HTTPS and the final domain.
2. Create an account, verify its email and sign in again.
3. Test an organization invitation with a second account.
4. Exercise each enabled integration, including one Stripe test checkout and one upload.
5. Confirm the Vercel function logs do not contain credentials or tokens.
6. Enable Web Analytics and Speed Insights separately in the Vercel dashboard if you want to use the components already included in the root layout.
---
## Email
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/email
**Description**: Learn how to configure and send emails with Resend and React Email.
The Pro Next.js Drizzle starter kit uses [Resend](https://resend.com/) for sending emails and [React Email](https://react.email/) for creating beautiful email templates.
## Configuration
### Environment Variables
Add the following environment variables to your `.env` file:
```ini filename=".env" lineNumbers
RESEND_API_KEY=re_...
EMAIL_FROM=noreply@yourdomain.com
```
### Getting Your Resend API Key
1. Create an account at [Resend](https://resend.com/)
2. Navigate to the API Keys section in your dashboard
3. Create a new API key
4. Copy the API key and add it to your `.env` file
### Domain Setup
To send emails from your own domain:
1. Add your domain in the Resend dashboard
2. Verify your domain by adding the required DNS records
3. Update `EMAIL_FROM` to use your verified domain (e.g., `noreply@yourdomain.com`)
## Sending Emails
The starter kit includes an `EmailService` class that handles all email operations:
```typescript filename="lib/actions/send-verification.ts" lineNumbers
import { emailService } from '@/lib/email';
import { getBaseUrl } from '@/lib/utils';
// Use the email service to send verification emails
await emailService.sendVerifyEmailAddressEmail({
recipient: 'user@example.com',
name: 'John Doe',
verificationLink: `${getBaseUrl()}/verify-email?token=${token}`
});
```
Or use the generic `sendEmail` method for custom emails:
```typescript filename="lib/actions/send-custom.ts" lineNumbers
import { emailService } from '@/lib/email';
await emailService.sendEmail({
recipient: 'user@example.com',
subject: 'Welcome!',
html: '
Welcome to our platform!
',
text: 'Welcome to our platform!'
});
```
## Email Templates
The starter kit uses [React Email](https://react.email/) to create email templates using `.tsx` files.
Why choose React Email?
React Email allows us to leverage Tailwind and React components, while
ensuring consistent email styling across various email clients. It's really
easy to write consistent email templates.
### Creating Email Templates
Create email templates in the `lib/email/templates/` directory:
```tsx filename="lib/email/templates/welcome-email.tsx" lineNumbers
import type * as React from 'react';
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text
} from '@react-email/components';
import { Tailwind } from '@react-email/tailwind';
export type WelcomeEmailProps = {
name: string;
welcomeLink: string;
};
function WelcomeEmail({
name,
welcomeLink
}: WelcomeEmailProps): React.JSX.Element {
return (
Welcome to our platform!
Welcome!
Hello {name},
Thanks for joining us. We're excited to have you on board!
);
}
// Preview props for React Email preview
WelcomeEmail.PreviewProps = {
name: 'John Doe',
welcomeLink: 'https://example.com/dashboard'
} satisfies WelcomeEmailProps;
export default WelcomeEmail;
export { WelcomeEmail };
```
The email service already includes methods for all pre-built templates. See the [Email Templates](/docs/starter-kits/pro-nextjs-drizzle/email/templates) documentation for details on creating custom templates.
## Previewing Emails
You can preview your email templates during development. See the [React Email Preview](/docs/starter-kits/pro-nextjs-drizzle/email/react-email-preview) documentation for details.
## Available Templates
The starter kit includes pre-built templates for:
- Email verification (`sendVerifyEmailAddressEmail`)
- Password reset (`sendPasswordResetEmail`)
- Organization invitations (`sendOrganizationInvitationEmail`)
- Email address change confirmation (`sendConfirmEmailAddressChangeEmail`)
- Revoked invitations (`sendRevokedInvitationEmail`)
- Payment failed notifications (`sendPaymentFailedEmail`)
- Subscription canceled (`sendSubscriptionCanceledEmail`)
- Trial ending soon (`sendTrialEndingSoonEmail`)
- Contact form submissions (`sendContactFormEmail`)
All templates are located in `lib/email/templates/` and can be customized to match your brand.
---
## Configuration
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/email/configuration
**Description**: Learn how to configure Resend and set up email sending.
The starter kit uses [Resend](https://resend.com/) for sending emails. Resend is a modern email API designed for developers, offering excellent deliverability and a simple integration.
## Setup
### 1. Create a Resend Account
1. Go to [Resend](https://resend.com/) and create an account
2. Navigate to the **API Keys** section in your dashboard
3. Click **Create API Key**
4. Give it a name (e.g., "Production" or "Development")
5. Copy the API key (starts with `re_`)
### 2. Configure Environment Variables
Add the following environment variables to your `.env` file:
```env filename=".env" lineNumbers
RESEND_API_KEY=re_...
EMAIL_FROM=noreply@yourdomain.com
```
Security Note
Never commit your API keys to version control. Always use environment
variables and ensure .env is in your .gitignore.
### 3. Domain Setup
To send emails from your own domain:
1. Go to **Domains** in your Resend dashboard
2. Click **Add Domain**
3. Enter your domain (e.g., `yourdomain.com`)
4. Add the required DNS records to verify your domain:
- **SPF Record** - Authorizes Resend to send emails
- **DKIM Record** - Signs emails for authentication
- **DMARC Record** (optional) - Email authentication policy
5. Wait for domain verification (usually a few minutes)
6. Update `EMAIL_FROM` to use your verified domain:
```env filename=".env" lineNumbers
EMAIL_FROM=noreply@yourdomain.com
```
Using Resend's Domain
For testing, you can use Resend's default domain:{' '}
onboarding@resend.dev. However, for production, always use your
own verified domain for better deliverability.
## Email Functions
The email functions in `lib/email` automatically use your environment variables.
### Basic Usage
```typescript filename="lib/email/example.ts" lineNumbers
import { sendEmail } from '@/lib/email';
await sendEmail({
recipient: 'user@example.com',
subject: 'Welcome!',
html: '
Welcome to our platform!
',
text: 'Welcome to our platform!'
});
```
### Using Pre-built Templates
The email module exports functions for all pre-built templates:
```typescript filename="lib/actions/signup.ts" lineNumbers
import { sendVerifyEmailAddressEmail } from '@/lib/email';
await sendVerifyEmailAddressEmail({
recipient: user.email,
name: user.name,
verificationLink: `${getBaseUrl()}/verify-email?token=${token}`
});
```
## Retry Logic
The email service includes automatic retry logic with exponential backoff:
- **Max Attempts**: 3 total attempts (the initial attempt and up to 2 retries)
- **Base Delay**: 1 second
- **Max Delay**: 10 seconds
- **Exponential Backoff**: Delay doubles with each retry
Permanent errors (invalid email, auth failure) are not retried.
## Error Handling
The email functions handle errors gracefully:
```typescript filename="lib/email/example.ts" lineNumbers
import { sendEmail } from '@/lib/email';
try {
await sendEmail({
recipient: 'user@example.com',
subject: 'Test',
html: '
Test
',
text: 'Test'
});
} catch (error) {
// Error is logged automatically
// Permanent errors are not retried
// Transient errors are retried automatically
}
```
## Production Configuration
For production deployments:
1. **Use a verified domain** - Always use your own domain, not Resend's default
2. **Set up SPF/DKIM** - Ensure DNS records are properly configured
3. **Monitor deliverability** - Check Resend dashboard for bounce rates
4. **Set up webhooks** (optional) - Track email events (delivered, bounced, etc.)
### Environment Variables in Production
Add your environment variables in your hosting platform:
- **Vercel**: Project Settings → Environment Variables
- **Railway**: Variables tab
- **Other platforms**: Follow their environment variable documentation
## Testing
Test templates and delivery separately. A correct preview does not prove that
Resend can authenticate your sender or deliver a message.
### Preview templates locally
Run `npm run email:dev`, then open `http://localhost:3001`. This renders the
components in `lib/email/templates/` with their `PreviewProps` without sending
email or calling Resend. See [React Email Preview](/docs/starter-kits/pro-nextjs-drizzle/email/react-email-preview).
### Verify provider delivery
1. Set `RESEND_API_KEY` to a development API key.
2. Set `EMAIL_FROM` to an address on a verified domain. For an initial Resend
test, `onboarding@resend.dev` can only send to the email address associated
with your Resend account.
3. Start the application and trigger a real product flow such as email
verification, password reset or an organization invitation.
4. Confirm the request succeeds, the message appears in the Resend dashboard
and the recipient receives it.
5. Open the generated link and confirm that it uses the correct application URL
for the environment you are testing.
Use a real product flow
The repository does not include a standalone email test script. Triggering a
shipped flow verifies the template, provider configuration and generated URL
together.
### Before production
- Use separate Resend API keys for development and production.
- Verify the production sending domain and set `EMAIL_FROM` to that domain.
- Add both email variables to the production deployment environment.
- Exercise verification, password reset and invitation delivery after deploy.
- Review failed requests, bounces and provider limits in the Resend dashboard.
## Best Practices
1. **Always use your own domain** - Better deliverability and branding
2. **Set up DNS records correctly** - SPF, DKIM and DMARC
3. **Monitor bounce rates** - Remove invalid email addresses
4. **Use templates** - Consistent branding and easier maintenance
5. **Handle errors gracefully** - Log errors and notify admins
6. **Test before production** - Preview templates, then exercise real product
flows against the deployed environment
---
## Overview
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/email/overview
**Description**: Understand the shipped Resend and React Email integration.
The starter kit uses [Resend](https://resend.com/) for delivery and [React Email](https://react.email/) for typed email templates. Both editions ship the same email API under `lib/email/`.
## How email flows through the application
1. A product flow calls an exported function from `lib/email/emails.ts`.
2. That function renders a React Email component to HTML and plain text.
3. `lib/email/resend.ts` sends both versions through Resend.
4. Transient failures are retried and final failures are logged.
This separation keeps provider credentials out of templates and gives each message a typed input contract.
## Connected product emails
The following messages are connected to shipped application flows:
| Flow | Trigger |
| ----------------------- | ------------------------------------------------------ |
| Verify email address | Password signup through Better Auth |
| Password reset | Better Auth password-reset request |
| Confirm email change | Better Auth email-change request |
| Organization invitation | Creating an invitation through the organization plugin |
| Contact form | A successful contact-form submission |
| Payment failed | A handled Stripe invoice failure webhook |
| Subscription canceled | A handled Stripe subscription deletion webhook |
| Trial ending | A handled Stripe trial-ending webhook |
| Dispute received | A handled Stripe dispute webhook |
`lib/email/templates/revoked-invitation-email.tsx` and its sending function are included for customization, but the shipped invitation-revocation action does not call it automatically. Wire it into that action if your product should notify the recipient.
## Send a custom email
Use the generic transport for a one-off message:
```typescript filename="lib/actions/send-custom.ts" lineNumbers
import { sendEmail } from '@/lib/email';
await sendEmail({
recipient: 'user@example.com',
subject: 'Welcome!',
html: '
Welcome to our platform!
',
text: 'Welcome to our platform!'
});
```
For a reusable product email, create a typed React Email template and an exported rendering function in `lib/email/emails.ts`. This guarantees that HTML and plain-text versions are generated consistently.
The React Email preview proves that a component renders. Exercise the real
product flow with a development Resend key to verify sender authentication,
generated links and delivery.
---
## React Email Preview
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/email/react-email-preview
**Description**: Learn how to preview email templates using React Email's preview server.
## Start the preview server
To preview email templates, run:
```sh filename="Terminal" lineNumbers
npm run email:dev
```
The preview server runs at http://localhost:3001 and
automatically detects all email templates in lib/email/templates/
.
## How it works
React Email's preview server automatically:
- Scans the `lib/email/templates/` directory
- Detects all email template files
- Uses the `PreviewProps` exported from each template for preview data
- Provides a web interface to preview all templates
## Email Development
React Email allows you to develop email templates using React components, making it easy to create responsive and beautiful emails that work across email clients.
---
## Email Templates
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/email/templates
**Description**: Learn how to create and use React Email templates.
The starter kit uses [React Email](https://react.email/) to create email templates using `.tsx` files. React Email allows you to leverage Tailwind CSS and React components while ensuring consistent email styling across various email clients.
## Why React Email?
Why choose React Email?
React Email allows us to leverage Tailwind and React components, while
ensuring consistent email styling across various email clients. It's really
easy to write consistent email templates.
## Creating Email Templates
Create email templates in the `lib/email/templates/` directory:
```tsx filename="lib/email/templates/welcome-email.tsx" lineNumbers
import type * as React from 'react';
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text
} from '@react-email/components';
import { Tailwind } from '@react-email/tailwind';
export type WelcomeEmailProps = {
name: string;
welcomeLink: string;
};
function WelcomeEmail({
name,
welcomeLink
}: WelcomeEmailProps): React.JSX.Element {
return (
Welcome to our platform!
Welcome!
Hello {name},
Thanks for joining us. We're excited to have you on board!
);
}
// Preview props for React Email preview
WelcomeEmail.PreviewProps = {
name: 'John Doe',
welcomeLink: 'https://example.com/dashboard'
} satisfies WelcomeEmailProps;
export default WelcomeEmail;
export { WelcomeEmail };
```
## Add a Sending Function
The repository does not use an email service class. Add a focused exported
function to `lib/email/emails.ts`, following the same pattern as the shipped
authentication and billing emails:
```typescript filename="lib/email/emails.ts" lineNumbers
import { render } from '@react-email/render';
import { sendEmail } from './resend';
import type { WelcomeEmailProps } from './templates/welcome-email';
export async function sendWelcomeEmail(
input: WelcomeEmailProps & { recipient: string }
): Promise {
const { WelcomeEmail } = await import('./templates/welcome-email');
const component = WelcomeEmail(input);
const html = await render(component);
const text = await render(component, { plainText: true });
await sendEmail({
recipient: input.recipient,
subject: 'Welcome to our platform!',
html,
text
});
}
```
`lib/email/index.ts` already re-exports `lib/email/emails.ts`, so the new
function becomes available from `@/lib/email` without adding another export.
## Using Email Templates
Send emails using the email service:
```typescript filename="lib/actions/send-welcome.ts" lineNumbers
import { sendWelcomeEmail } from '@/lib/email';
export async function handleUserCreated(user: { email: string; name: string }) {
await sendWelcomeEmail({
recipient: user.email,
name: user.name,
welcomeLink: 'https://yourdomain.com/dashboard'
});
}
```
In application code, build links with the same trusted base-URL helper used by
the shipped authentication flows. Do not construct email links from an
unvalidated request `Host` header.
## Available Components
React Email provides many components for building emails:
- **Container** - Main wrapper
- **Section** - Content sections
- **Heading** - Headings (h1-h6)
- **Text** - Paragraph text
- **Button** - Call-to-action buttons
- **Link** - Hyperlinks
- **Image** - Images
- **Hr** - Horizontal rules
- **Code** - Inline code
- **CodeBlock** - Code blocks
See the [React Email documentation](https://react.email/docs/components/html) for a complete list.
## Styling
### Using Tailwind
React Email supports Tailwind CSS:
```tsx filename="lib/email/templates/example.tsx" lineNumbers
Hello World
```
### Inline Styles
You can also use inline styles:
```tsx filename="lib/email/templates/example.tsx" lineNumbers
Hello World
```
## Preview Props
Each template should export `PreviewProps` for the React Email preview:
```typescript filename="lib/email/templates/welcome-email.tsx" lineNumbers
WelcomeEmail.PreviewProps = {
name: 'John Doe',
welcomeLink: 'https://example.com/dashboard'
} satisfies WelcomeEmailProps;
```
## Previewing Emails
You can preview your email templates during development. See the [React Email Preview](/docs/starter-kits/pro-nextjs-drizzle/email/react-email-preview) documentation for details.
### Running the Preview Server
```bash filename="Terminal" lineNumbers
npm run email:dev
```
This starts a local server at `http://localhost:3001` where you can preview all your email templates.
## Available Templates
The starter kit includes the following email templates:
- **Verify Email Address** - Email verification link
- **Password Reset** - Password reset instructions
- **Organization Invitation** - Invite users to organizations
- **Payment Failed** - Notify about failed payments
- **Subscription Canceled** - Notify about canceled subscriptions
- **Trial Ending Soon** - Remind about trial expiration
- **Contact Form** - Contact form submissions
- **Revoked Invitation** - Notify about revoked invitations
- **Email Address Change** - Confirm email address change
- **Dispute Received** - Alert administrators when Stripe reports a dispute
All templates are located in `lib/email/templates/` and can be customized to match your brand.
## Customizing Templates
### Update Branding
Update the logo and colors in your templates:
```tsx filename="lib/email/templates/welcome-email.tsx" lineNumbers
{/* ... rest of template */}
```
### Add Custom Styles
Create a shared styles file:
```typescript filename="lib/email/styles.ts" lineNumbers
export const emailStyles = {
primaryColor: '#000000',
secondaryColor: '#666666',
borderRadius: '4px',
fontFamily: 'Arial, sans-serif'
};
```
Use in templates:
```tsx filename="lib/email/templates/example.tsx" lineNumbers
import { emailStyles } from '../styles';
;
```
## Best Practices
1. **Use Preview Props** - Always define preview props for development
2. **Test across clients** - Test emails in Gmail, Outlook, Apple Mail
3. **Keep it simple** - Avoid complex layouts that break in email clients
4. **Use Tailwind** - Leverage Tailwind for consistent styling
5. **Include plain text** - Always provide a plain text version
6. **Mobile responsive** - Ensure emails look good on mobile devices
7. **Accessible** - Use semantic HTML and alt text for images
---
## FAQ
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/faq
**Description**: Frequently asked questions about the starter kit.
## What is a starter kit?
A starter kit is a pre-built, fullstack foundation designed to help you create scalable, production-ready web applications quickly and efficiently. It includes all the essential tools, components and best practices for building modern SaaS platforms, so you can focus on developing your unique features instead of spending time on generic setup tasks.
## What is the difference between a starter kit and boilerplate?
In everyday conversations the terms are often used interchangeably. Feel free to use whichever one you prefer!
## How do I update the starter kit?
Keep your product repository as `origin`, add the private Achromatic repository
as a separate remote and merge updates on a dedicated branch. Review migrations,
environment changes and security-sensitive code before applying an update. See
the [updating guide](/docs/starter-kits/pro-nextjs-drizzle/codebase/updating) for
the complete workflow.
---
## Folder Structure
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/folder-structure
**Description**: An overview of the project's organization and file structure.
The Pro Next.js Drizzle starter kit follows a clean and logical structure designed for scalability and ease of maintenance in a single-repo setup.
## Directory Descriptions
### `app/`
Next.js App Router directory containing all routes and pages. Uses route groups `(marketing)` and `(saas)` to organize public and protected pages.
### `components/`
React components organized by feature. The `ui/` subdirectory contains reusable UI primitives from shadcn/ui.
### `config/`
Application configuration files. Each feature has its own configuration file for easy management.
### `content/`
MDX content for blog posts, documentation and legal pages. Uses Content Collections for type-safe content management.
### `hooks/`
Custom React hooks for shared functionality like session management, storage and theming.
### `lib/`
Core business logic and service libraries. Each subdirectory represents a major feature:
- **`auth/`**: Better Auth setup and utilities
- **`billing/`**: Stripe integration and billing logic
- **`db/`**: Drizzle ORM schema and client
- **`email/`**: Email service and React Email templates
- **`storage/`**: File storage service (S3-compatible)
### `schemas/`
Zod validation schemas for form validation and API input validation. Organized by feature domain.
### `trpc/`
tRPC API layer providing end-to-end type safety. Routers are organized by feature, and the context provides session and organization scoping.
### `types/`
Shared TypeScript type definitions used across the application.
## Highlights
- **`(marketing)` & `(saas)` Route Groups**: Clearly separates public-facing pages from the protected dashboard application.
- **`lib/` Directory**: Centralizes all core services like database, authentication and billing, making them easy to test and maintain.
- **`schemas/` Directory**: Centralizes all Zod validation schemas for consistent validation across the application.
- **`trpc/` Directory**: Contains your entire API layer, ensuring end-to-end type safety with React Query integration.
- **`components/ui/`**: Houses reusable UI primitives, following the shadcn/ui pattern.
- **`hooks/` Directory**: Custom React hooks for shared functionality, reducing code duplication.
---
## Introduction
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle
**Description**: Set up, understand and customize the Drizzle edition of the Achromatic Next.js SaaS starter kit.
## Start here
Achromatic is a production-grade Next.js SaaS starter kit built as a single,
approachable application. This documentation explains what the repository ships,
how its systems fit together and where to customize them safely.
## What ships
The repository includes working implementations for:
- **Next.js 16 and React 19** with the App Router, Server Components and streaming.
- **Drizzle ORM and PostgreSQL** with schemas, migrations and local Docker setup.
- **Better Auth** with email and password authentication, Google OAuth, email verification, password recovery, TOTP two-factor authentication and administration.
- **Organizations** with invitations, roles, permissions and organization-scoped data.
- **Stripe billing** with subscriptions, one-time purchases, credits, per-seat billing, a customer portal and webhook synchronization.
- **Email** with Resend, React Email templates and local template previews.
- **AI** with a working Vercel AI SDK chatbot, persistence and credit consumption.
- **Image storage** with S3-compatible presigned uploads for avatars and organization logos.
- **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-drizzle/folder-structure)
and [codebase overview](/docs/starter-kits/pro-nextjs-drizzle/codebase/overview)
before moving large features.
## Choose the right database kit
Both repositories ship the same product features and user experience. Choose
Drizzle when your team prefers Drizzle ORM. If you are still deciding, use the
[starter kit chooser](/docs/starter-kits) before selecting the repository that
will become the foundation for your product.
## Recommended path
1. Complete [Setup](/docs/starter-kits/pro-nextjs-drizzle/setup) without product customizations.
2. Confirm the application, database, signup and email verification flows work locally.
3. Read [Configuration](/docs/starter-kits/pro-nextjs-drizzle/configuration) and replace the product identity.
4. Configure only the integrations your first release needs.
5. Run the test and quality checks before changing architecture.
6. Use the [deployment guide](/docs/starter-kits/pro-nextjs-drizzle/deployment) as a production checklist.
Starting from a verified baseline makes later failures much easier to isolate.
## Scope of this documentation
Use these guides as the source of truth for the code Achromatic ships: repository
paths, package scripts, configuration files, environment variable names and how
the included features are connected. Database-specific pages are kept separate
where schema or migration workflows differ between editions.
Use the upstream documentation when you need the complete API of an underlying
library:
- [Next.js documentation](https://nextjs.org/docs) for framework behavior and App Router APIs.
- [Better Auth documentation](https://www.better-auth.com/docs) for plugin APIs and authentication concepts.
- [Stripe documentation](https://docs.stripe.com/) for account configuration, Checkout and webhook behavior.
- The official Drizzle ORM documentation for queries, schema syntax and advanced database features.
Check the versions pinned in `package.json` before following a newly published
upstream example. If an upstream guide conflicts with this documentation, first
confirm that it targets the same installed version and the same runtime.
---
## Blog
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/marketing/blog
**Description**: Learn how to write blog posts using Content Collections.
The starter kit uses [Content Collections](https://www.content-collections.dev/) for managing blog content. All blog posts are written using `.mdx` files, which combine markdown with React components.
Why choose Content Collections?
Content Collections is a powerful, type-safe content management solution. It's
powered by MDX, free, open-source and saves content directly in your
repository.
The blog is configured in `content-collections.ts` and uses [Fumadocs](https://www.fumadocs.dev/) for rendering documentation-style content.
## Add a new blog post
To create a new blog post, follow these steps:
1. **Create a new file**
Navigate to the `content/posts` directory and create a new `.mdx` file.
The file name will act as the URL slug for the post. For example:
- File name: `hello-world.mdx`
- URL: `https://your-app.com/blog/hello-world`
2. **Add metadata**
At the top of the `.mdx` file, include a frontmatter block. This block contains key metadata about your post, written in a YAML-like format enclosed by three dashes (`---`). Here's an example:
```mdx filename="content/posts/hello-world.mdx" lineNumbers
---
title: How to create a blog post
date: 2025-01-20T12:00:00.000Z
authorName: John Doe
authorImage: /authors/john.jpg
authorLink: https://example.com
excerpt: A short description of your blog post.
tags: [Innovation, Tutorial]
published: true
content: |
Your blog post content goes here...
---
```
### Frontmatter Fields
The blog post schema supports the following fields:
- `title` (required) - The title of the blog post
- `date` (required) - ISO 8601 date string for publication date
- `authorName` (required) - Name of the author
- `authorImage` (optional) - URL to author's image
- `authorLink` (optional) - Link to author's profile
- `excerpt` (optional) - A short description/excerpt of the post
- `tags` (required) - Array of tag strings
- `published` (required) - Boolean to control visibility
- `image` (optional) - Featured image URL
- `content` (required) - The full content of the post
## Using MDX Components
You can use React components directly in your MDX files. The starter kit provides several custom components:
```mdx filename="content/posts/example.mdx" lineNumbers
---
title: Example Post
date: 2025-01-20T12:00:00.000Z
authorName: John Doe
tags: [Example]
published: true
content: |
# My Blog Post
This is a callout component!
Regular markdown content here.
---
```
## Code Blocks
Code blocks are automatically highlighted and support line numbers:
```typescript filename="example.ts" lineNumbers
export function example() {
return 'Hello, World!';
}
```
## Images
You can include images in your blog posts:
```mdx filename="content/posts/example.mdx" lineNumbers

```
Or use the Image component for more control:
```mdx filename="content/posts/example.mdx" lineNumbers
import { Image } from '@/components/mdx-components';
```
## Building Content
Content Collections are automatically built during the Next.js build process. During development, they are rebuilt when files change.
## Querying Blog Posts
You can query blog posts in your application:
```typescript filename="app/blog/page.tsx" lineNumbers
import { allDocuments } from 'content-collections/generated';
export default function BlogPage() {
const posts = allDocuments('posts')
.filter((post) => post.published)
.sort((a, b) => {
const dateA = new Date(a.date);
const dateB = new Date(b.date);
return dateB.getTime() - dateA.getTime();
});
return (
{posts.map((post) => (
{post.title}
{post.excerpt}
))}
);
}
```
## Configuration
The blog collection is configured in `content-collections.ts`:
```typescript filename="content-collections.ts" lineNumbers
const posts = defineCollection({
name: 'posts',
directory: 'content/posts',
include: '**/*.{mdx,md}',
schema: z.object({
title: z.string(),
date: z.string(),
authorName: z.string(),
excerpt: z.string().optional(),
tags: z.array(z.string()),
published: z.boolean(),
content: z.string()
})
});
export default defineConfig({
collections: [posts]
});
```
## Best Practices
1. **Use descriptive filenames** - The filename becomes the URL slug
2. **Add descriptions** - Help with SEO and preview cards
3. **Use categories** - Organize related posts
4. **Set publication dates** - Control when posts appear
5. **Test locally** - Always preview posts before publishing
---
## Contact Page
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/marketing/contact
**Description**: Learn how to customize the contact page and configure contact information.
The contact page (`/contact`) provides a way for visitors to get in touch with your team. It includes a contact form and displays your contact information.
## Page Structure
The contact page is located at `app/contact/page.tsx` and includes:
- **Contact Hero Section** - Headline and description
- **Contact Form** - Form for visitors to send messages
- **FAQ Section** - Frequently asked questions
## Configuration
Contact information is configured in `config/app.config.ts`:
```typescript filename="config/app.config.ts" lineNumbers
export const appConfig = {
// ... other config
contact: {
enabled: true,
email: 'hello@yourdomain.com',
phone: '(123) 456-7890',
address: '123 Main St, San Francisco, CA'
}
};
```
### Contact Form
The contact form is handled by the `ContactHeroSection` component. To customize the form:
1. **Update form fields** - Edit the form component in `components/sections/contact-hero-section.tsx`
2. **Configure email** - Set up email sending in your email service (see [Email documentation](/docs/starter-kits/pro-nextjs-drizzle/email/overview))
3. **Add validation** - Add client and server-side validation as needed
## Customization
### Update Contact Content
Edit the `ContactHeroSection` component:
```typescript filename="components/sections/contact-hero-section.tsx" lineNumbers
export function ContactHeroSection(): React.JSX.Element {
return (
Get in Touch
We'd love to hear from you
{/* Contact form */}
);
}
```
### Display Contact Information
Contact information from `app.config.ts` is automatically displayed. You can customize how it's shown by editing the contact section component.
## Email Integration
To send emails when the contact form is submitted:
1. **Create a tRPC endpoint** - Handle form submission server-side
2. **Use email service** - Send emails using your configured email provider
3. **Add validation** - Validate form data before sending
See the [Email documentation](/docs/starter-kits/pro-nextjs-drizzle/email/overview) for more details.
## SEO
The contact page includes structured data (JSON-LD) for:
- ContactPage schema
- WebPage schema
- Breadcrumb schema
## Best Practices
1. **Clear contact options** - Provide multiple ways to get in touch
2. **Quick response** - Set expectations for response time
3. **Form validation** - Validate all form fields
4. **Spam protection** - Consider adding reCAPTCHA or similar
5. **Confirmation message** - Show a success message after submission
---
## Documentation
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/marketing/documentation
**Description**: Write and organize product documentation with Fumadocs MDX.
The starter kit includes a Fumadocs documentation site at `/docs`. Its pages are local MDX files in `content/docs`, so the documentation stays versioned with the application code.
Both starter kit repositories use the same documentation structure. Do not
create an ORM-specific folder inside `content/docs`.
## How documentation is connected
| File | Responsibility |
| --- | --- |
| `content/docs/*.mdx` | Documentation content and page metadata |
| `content/docs/meta.json` | Sidebar groups, labels and page order |
| `source.config.ts` | Declares `content/docs` as the Fumadocs MDX source |
| `lib/marketing/docs/source.ts` | Loads the content and assigns the `/docs` base URL |
| `app/docs/layout.tsx` | Configures the documentation layout and navigation tree |
| `app/docs/[[...slug]]/page.tsx` | Renders each page and generates its metadata |
The kit already connects these files. You normally only need to edit `content/docs` when writing product documentation.
## Add a page
### Create the MDX file
Add a file directly under `content/docs`. Its path becomes the URL after `/docs`.
```mdx filename="content/docs/getting-started.mdx"
---
title: Getting started
description: Configure the application for local development.
icon: Rocket
---
## Prerequisites
Add your guide here.
```
This example is available at `/docs/getting-started`. The optional `icon` value must match an icon exported by Lucide React.
### Add the page to the sidebar
Add the filename without `.mdx` to the `pages` array in `content/docs/meta.json`:
```json filename="content/docs/meta.json"
{
"title": "Documentation",
"root": true,
"pages": ["index", "getting-started"]
}
```
Keep this array in the order you want readers to follow. Fumadocs also supports separators and external links in this file.
### Preview the page
Start the application and open the new route:
```bash
npm run dev
```
Visit `http://localhost:3000/docs/getting-started` and check the page on both desktop and mobile.
## Organize a section
For a larger topic, put its pages in a folder and add a `meta.json` inside that folder. The folder name becomes the URL segment.
```text
content/docs/
├── meta.json
└── billing/
├── meta.json
├── overview.mdx
└── webhooks.mdx
```
```json filename="content/docs/billing/meta.json"
{
"title": "Billing",
"pages": ["overview", "webhooks"]
}
```
The pages are then available at `/docs/billing/overview` and `/docs/billing/webhooks`. Add `billing` to the root `content/docs/meta.json` where that section should appear.
## Use the included MDX components
The page renderer registers Fumadocs components including `Callout`, `Cards`, `Tabs`, `Steps`, `Files` and `ImageZoom`. You can use them directly in an MDX page without importing them.
```mdx filename="content/docs/getting-started.mdx"
Copy `.env.example` to `.env` and provide the required values.
Use your local service credentials.Use credentials from the production project.
```
Standard fenced code blocks support syntax highlighting. Add a `filename` attribute when the file location helps the reader.
## Change the documentation UI
- Edit `app/docs/layout.tsx` to change the documentation shell or sidebar behavior.
- Edit `lib/marketing/docs/layout.config.tsx` to change shared layout options such as navigation links.
- Edit `app/docs/[[...slug]]/page.tsx` to register another MDX component or change page rendering.
- Edit `lib/marketing/docs/source.ts` only when changing how the content source is loaded.
Keep content changes in `content/docs` and layout changes in the application files above. This separation makes upgrades easier and keeps navigation generated from the same source as the pages.
## Validate before publishing
Run the same checks used for application changes:
```bash
npm run typecheck
npm run lint
npm run build
```
Also open every new documentation route locally. A successful build confirms that Fumadocs can compile the MDX, while the browser check catches navigation, layout and readability problems.
For advanced navigation and MDX options, see the [Fumadocs documentation](https://fumadocs.dev/docs/mdx).
---
## Landing Page
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/marketing/landing
**Description**: Learn how to customize the landing page and its sections.
The landing page (`/`) is the main homepage of your marketing site. It showcases your product with multiple sections designed to convert visitors into customers.
## Page Structure
The landing page is located at `app/page.tsx` and consists of the following sections:
- **Hero Section** - Main headline, value proposition and call-to-action buttons
- **Pre-built Section** - Highlights key features and benefits
- **Features Section** - Detailed feature list
- **Auth Section** - Authentication capabilities showcase
- **Multi-tenancy Section** - Organization management features
- **Billing Section** - Payment and subscription features
- **Code Section** - Developer experience highlights
- **Trusted Section** - Social proof and testimonials
- **Pricing Section** - Pricing preview with call-to-action
- **FAQ Section** - Frequently asked questions
## Customization
### Update Hero Content
Edit the `HeroSection` component to change the headline, description and CTA buttons:
```typescript filename="components/sections/hero-section.tsx" lineNumbers
export function HeroSection(): React.JSX.Element {
return (
Your Headline
Your value proposition
);
}
```
### Modify Sections
Each section is a separate component in `components/sections/`. You can:
- **Reorder sections** - Change the order in `app/page.tsx`
- **Remove sections** - Comment out or delete unused sections
- **Add custom sections** - Create new section components and add them to the page
- **Customize styling** - Update Tailwind classes in each section component
### Update Metadata
Modify the page metadata in `app/page.tsx`:
```typescript filename="app/page.tsx" lineNumbers
export const metadata: Metadata = {
title: 'Your App Name',
description: 'Your app description'
// ... other metadata
};
```
## SEO
The landing page includes structured data (JSON-LD) for:
- Website schema
- Software application schema
- FAQ schema
- Site navigation schema
These are automatically generated and help with search engine optimization.
## Best Practices
1. **Clear value proposition** - Make it immediately clear what your product does
2. **Strong CTAs** - Use action-oriented button text (e.g., "Get Started", "Start Free Trial")
3. **Social proof** - Include testimonials, logos or usage statistics
4. **Mobile responsive** - Ensure all sections work well on mobile devices
5. **Fast loading** - Optimize images and use Next.js Image component
---
## Overview
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/marketing/overview
**Description**: Learn about all marketing pages and how to manage content using Content Collections and Fumadocs.
The starter kit includes a comprehensive set of marketing pages to cover every touchpoint of your SaaS. These pages are organized in the `(marketing)` route group and include landing pages, blog, documentation, pricing, contact and legal pages.
## Marketing Pages
The starter kit includes the following marketing pages:
- **Landing Page** (`/`) - Main homepage showcasing your product with hero section, features, pricing preview and call-to-action
- **Blog** (`/blog`) - Integrated blogging system using Content Collections, statically generated at build time
- **Documentation** (`/docs`) - Documentation pages using Content Collections and Fumadocs
- **Pricing** (`/pricing`) - Responsive pricing tables with plan comparisons and conversion-optimized design
- **Contact** (`/contact`) - Contact form with email integration
- **Legal Pages** - Terms of Service, Privacy Policy and Cookie Policy (create these pages as needed)
All marketing pages are located in the `app/(marketing)/` directory and can be enabled/disabled via the `app.config.ts` file.
## Content Management
For blog posts and documentation, the starter kit uses [Content Collections](https://www.content-collections.dev/) for managing content and [Fumadocs](https://www.fumadocs.dev/) for rendering documentation. This provides a powerful, type-safe content management system that's easy to use and maintain.
Why Content Collections?
Content Collections is a powerful, type-safe content management solution. It's
powered by MDX, free, open-source and saves content directly in your
repository. This means your content is version-controlled and easy to manage.
### Features
- **Type-safe content** - Full TypeScript support with automatic type generation
- **MDX support** - Write content using Markdown with React components
- **Version control** - Content is stored in your repository, making it easy to track changes
- **Fast builds** - Content is compiled at build time for optimal performance
- **Developer-friendly** - Edit content using your favorite code editor
- **No database required** - Content is stored as files, not in a database
## Content Collections
Content Collections provides:
- **Schema validation** - Define schemas for your content using Zod
- **Automatic type generation** - TypeScript types are generated from your schemas
- **Query API** - Easy-to-use API for querying content
- **Transform functions** - Process and transform content during build
## Fumadocs
Fumadocs provides:
- **Beautiful UI** - Pre-built documentation UI components
- **Search** - Full-text search across your documentation
- **Dark mode** - Automatic theme switching
- **Responsive design** - Mobile-friendly layouts
- **Table of contents** - Automatically generated from headings
## Configuration
Content Collections is configured in `content-collections.ts`. The starter kit includes multiple collections:
```typescript filename="content-collections.ts" lineNumbers
import { defineCollection, defineConfig } from '@content-collections/core';
import { compileMDX } from '@content-collections/mdx';
import { z } from 'zod';
// Blog posts collection
const posts = defineCollection({
name: 'posts',
directory: 'content/posts',
include: '**/*.{mdx,md}',
schema: z.object({
title: z.string(),
date: z.string(),
authorName: z.string(),
excerpt: z.string().optional(),
tags: z.array(z.string()),
published: z.boolean(),
content: z.string()
}),
transform: async (document, context) => {
const body = await compileMDX(context, document);
return {
...document,
body,
path: document._meta.path.replace(/\.mdx?$/, '')
};
}
});
export default defineConfig({
collections: [posts]
});
```
## Building Content
Content Collections are automatically built during the Next.js build process. During development, they are rebuilt when files change.
## Querying Content
You can query content in your application:
```typescript filename="app/blog/page.tsx" lineNumbers
import { allDocuments } from 'content-collections/generated';
export default function BlogPage() {
const posts = allDocuments('posts')
.filter((post) => post.published)
.sort((a, b) => {
const dateA = new Date(a.date);
const dateB = new Date(b.date);
return dateB.getTime() - dateA.getTime();
});
return (
{posts.map((post) => (
{post.title}
{post.excerpt}
))}
);
}
```
## Best Practices
1. **Organize content** - Use clear directory structures
2. **Use schemas** - Define schemas for type safety
3. **Version control** - Commit content changes to git
4. **Test locally** - Always preview content before publishing
5. **Use MDX components** - Leverage React components in your content
---
## Pricing Page
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/marketing/pricing
**Description**: Learn how to customize the pricing page and integrate it with your billing configuration.
The pricing page (`/pricing`) displays your subscription plans and pricing information. It's integrated with your billing configuration and automatically displays plans from `config/billing.config.ts`.
## Page Structure
The pricing page is located at `app/pricing/page.tsx` and includes:
- **Pricing Hero Section** - Headline and description for the pricing page
- **Pricing Section** - Displays all plans from your billing configuration
- **FAQ Section** - Frequently asked questions about pricing
## Integration with Billing Config
The pricing page automatically reads plans from `config/billing.config.ts`. Plans are displayed based on:
- **Plan visibility** - Plans with `hidden: true` are not shown
- **Plan order** - Plans are displayed in the order they appear in the config
- **Recommended plans** - Plans with `recommended: true` are highlighted
- **Enterprise plans** - Plans with `isEnterprise: true` show a "Contact Sales" button
## Customization
### Update Pricing Content
Edit the `PricingHeroSection` component:
```typescript filename="components/sections/pricing-hero-section.tsx" lineNumbers
export function PricingHeroSection(): React.JSX.Element {
return (
Choose Your Plan
Select the perfect plan for your needs
);
}
```
### Customize Plan Display
The `PricingSection` component automatically renders plans from your billing config. To customize how plans are displayed, edit:
```typescript filename="components/sections/pricing-section.tsx" lineNumbers
// Customize plan card styling, features display, etc.
```
### Update FAQ
Modify the FAQ section in `app/pricing/page.tsx`:
```typescript filename="app/pricing/page.tsx" lineNumbers
const faqSchema = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: [
{
'@type': 'Question',
name: 'Your question?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Your answer.'
}
}
]
};
```
## SEO
The pricing page includes structured data (JSON-LD) for:
- Product schema
- Breadcrumb schema
- FAQ schema
## Best Practices
1. **Clear pricing** - Make prices and features easy to understand
2. **Highlight recommended plan** - Use the `recommended` flag to guide users
3. **Show value** - Include feature comparisons and benefits
4. **Mobile friendly** - Ensure pricing tables work on all screen sizes
5. **Clear CTAs** - Use action-oriented button text
---
## Observability
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/observability
**Description**: Monitor your application's performance and track errors with Sentry and Vercel Analytics.
The Pro Next.js Drizzle starter kit includes structured logging and optional
integrations for error, traffic and performance monitoring. External services
still require your own accounts and project configuration.
## Structured Logging
The kit includes a high-performance logging system based on **Pino**.
### Usage
Use the `logger` to record events with structured metadata.
```typescript filename="lib/actions/billing.ts" lineNumbers
import { logger } from '@/lib/logger';
logger.info({ userId, amount }, 'Payment processed successfully');
```
### Log Levels
You can control the verbosity of logs via the `NEXT_PUBLIC_LOG_LEVEL` environment variable.
```env filename=".env" lineNumbers
NEXT_PUBLIC_LOG_LEVEL="debug" # trace, debug, info, warn, error, fatal
```
Available log levels (from most to least verbose):
- `trace` - Very detailed debugging information
- `debug` - Debug information
- `info` - General informational messages (default)
- `warn` - Warning messages
- `error` - Error messages
- `fatal` - Critical errors
---
## Logging
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/observability/logging
**Description**: Learn how to use Pino for structured logging in your application.
The starter kit uses [Pino](https://getpino.io/), a high-performance structured logging library for Node.js. Pino is one of the fastest logging libraries available and provides excellent performance with minimal overhead.
## Basic Usage
Import and use the default logger:
```typescript filename="lib/actions/billing.ts" lineNumbers
import { logger } from '@/lib/logger';
// Simple message
logger.info('Payment processed successfully');
// With structured data
logger.info(
{ userId, amount, transactionId },
'Payment processed successfully'
);
// Error logging
logger.error({ error, userId }, 'Failed to process payment');
```
## Log Levels
Pino supports six log levels, from most to least verbose:
- `trace` - Very detailed debugging information
- `debug` - Debug information
- `info` - General informational messages (default)
- `warn` - Warning messages
- `error` - Error messages
- `fatal` - Critical errors
### Setting Log Level
Control the verbosity of logs via the `NEXT_PUBLIC_LOG_LEVEL` environment variable:
```env filename=".env.local" lineNumbers
NEXT_PUBLIC_LOG_LEVEL="debug" # trace, debug, info, warn, error, fatal
```
The default log level is `info`. In production, you typically want `info` or `warn` to reduce noise.
## Grouped Loggers
Create loggers for specific modules or features using the `LoggerFactory`:
```typescript filename="lib/billing/payment-processor.ts" lineNumbers
import { LoggerFactory } from '@/lib/logger/factory';
const logger = LoggerFactory.getLogger('Billing');
logger.info({ userId, amount }, 'Processing payment');
// Output: [INFO] Billing: Processing payment
```
Predefined groups include:
- `Billing` - Payment and subscription related logs
- `Auth` - Authentication related logs
- `Webhook` - Webhook processing logs
- `Database` - Database operation logs
- `API` - API request logs
- `Organization` - Organization management logs
- `User` - User management logs
- `Email` - Email sending logs
- `Storage` - File storage logs
You can also create custom groups:
```typescript filename="lib/features/analytics.ts" lineNumbers
import { LoggerFactory } from '@/lib/logger/factory';
const logger = LoggerFactory.getLogger('Analytics');
logger.info({ event: 'page_view', page: '/dashboard' }, 'Page viewed');
```
## Request Context
The logger can merge request context from the `AsyncLocalStorage` helpers in
`lib/logger/context.ts`. The repositories do not install a global request
wrapper, so ordinary logger calls include only the fields you pass unless your
code runs inside `runWithRequestContext` or `runWithAdditionalContext`.
The context type supports:
- `requestId` - Unique request identifier
- `userId` - Current user ID
- `userEmail` - Current user email
- `userRole` - Current user role
- `organizationId` - Active organization ID
- `userAgent` - User agent string
- `ip` - Client IP address
- `endpoint` - API endpoint
- `method` - HTTP method
- `trpcProcedure` - tRPC procedure name
- `trpcType` - tRPC call type (query/mutation)
- `webhookType` - Webhook event type
- `sessionId` - Session ID
Initialize the context at a request boundary before relying on automatic
enrichment:
```typescript filename="app/api/users/route.ts" lineNumbers
import { headers } from 'next/headers';
import { logger } from '@/lib/logger';
import { runWithRequestContext } from '@/lib/logger/server';
export async function GET() {
const requestHeaders = await headers();
return runWithRequestContext(
{
requestId: requestHeaders.get('x-request-id') ?? undefined,
userAgent: requestHeaders.get('user-agent') ?? undefined,
endpoint: '/api/users',
method: 'GET'
},
async () => {
logger.info({ action: 'list_users' }, 'Fetching user list');
return Response.json({ users: [] });
}
);
}
```
The shipped tRPC middleware does not use this storage wrapper. On failed tRPC
procedures it explicitly logs the procedure, duration, request metadata and any
available user or organization fields. Successful procedure calls are not
logged automatically.
User email, IP address and user-agent values are supported context fields. Add
only what you need, define a retention policy and never log session cookies,
authorization headers, passwords, API keys or payment details.
## Development vs Production
In development, logs are formatted with colors and readable output:
```
[INFO] Billing: Payment processed successfully (userId=123 amount=29.99 transactionId=txn_abc)
```
In production, logs are output as JSON for easy parsing by log aggregation services:
```json
{
"level": 30,
"time": 1234567890,
"group": "Billing",
"msg": "Payment processed successfully",
"userId": "123",
"amount": 29.99,
"transactionId": "txn_abc"
}
```
## Examples
### Server Actions
```typescript filename="app/actions/create-user.ts" lineNumbers
'use server';
import { logger } from '@/lib/logger';
export async function createUser(email: string, name: string) {
try {
logger.info({ email, name }, 'Creating new user');
// ... create user logic ...
logger.info({ userId: user.id, email }, 'User created successfully');
return { success: true, user };
} catch (error) {
logger.error({ error, email }, 'Failed to create user');
throw error;
}
}
```
### API Routes
```typescript filename="app/api/webhooks/stripe/route.ts" lineNumbers
import { logger } from '@/lib/logger';
export async function POST(request: Request) {
const body = await request.json();
logger.info(
{
webhookType: body.type,
eventId: body.id
},
'Received Stripe webhook'
);
// ... process webhook ...
logger.info({ webhookType: body.type }, 'Webhook processed successfully');
return new Response('OK', { status: 200 });
}
```
### tRPC Procedures
```typescript filename="trpc/routers/user.ts" lineNumbers
import { createTRPCRouter, protectedProcedure } from '@/trpc/init';
import { z } from 'zod';
import { logger } from '@/lib/logger';
export const userRouter = createTRPCRouter({
update: protectedProcedure
.input(z.object({ name: z.string() }))
.mutation(async ({ ctx, input }) => {
logger.info({ userId: ctx.user.id, name: input.name }, 'Updating user');
// ... update logic ...
logger.info({ userId: ctx.user.id }, 'User updated successfully');
return user;
})
});
```
### Error Handling
```typescript filename="lib/utils/error-handler.ts" lineNumbers
import { logger } from '@/lib/logger';
export function handleError(error: unknown, context?: Record) {
if (error instanceof Error) {
logger.error(
{
error: error.message,
stack: error.stack,
...context
},
'Error occurred'
);
} else {
logger.error({ error, ...context }, 'Unknown error occurred');
}
}
```
## Best Practices
1. **Use appropriate log levels**: Use `info` for normal operations, `warn` for recoverable issues, and `error` for failures.
2. **Include structured data**: Always include relevant context in your logs:
```typescript
// ✅ Good
logger.info({ userId, orderId, amount }, 'Order processed');
// ❌ Less useful
logger.info('Order processed');
```
3. **Use grouped loggers**: Create loggers for different modules to make logs easier to filter and search.
4. **Don't log sensitive data**: Avoid logging passwords, tokens, or other sensitive information.
5. **Initialize request context deliberately**: Wrap the request before expecting automatic enrichment, or pass identifiers explicitly at the log call.
6. **Log at appropriate times**: Log important state changes, errors, and significant events, but avoid excessive logging that can impact performance.
## Integration with Log Aggregation Services
The JSON output in production is compatible with popular log aggregation services:
- **Vercel Logs**: Automatically captured in Vercel deployments
- **Datadog**: Can parse JSON logs
- **LogRocket**: Supports structured logging
- **CloudWatch**: AWS CloudWatch Logs
- **Google Cloud Logging**: Supports JSON logs
## Next Steps
- Learn about [Sentry](/docs/starter-kits/pro-nextjs-drizzle/observability/sentry) for error tracking
- Check out [Vercel Analytics](/docs/starter-kits/pro-nextjs-drizzle/observability/vercel) for traffic monitoring
- Explore [Speed Insights](/docs/starter-kits/pro-nextjs-drizzle/observability/speed-insights) for performance monitoring
---
## Overview
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/observability/overview
**Description**: Monitor your application's performance and track errors.
The Pro Next.js Drizzle starter kit includes structured logging and optional
integrations for error, traffic and performance monitoring. Installing code is
only the first step: Sentry and Vercel services must be enabled and verified in
the environments where you expect them to collect data.
## What works after setup
| Capability | Included in the repository | Required activation |
| --------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------ |
| Structured logs | Pino logger, grouped loggers and request context | Choose `NEXT_PUBLIC_LOG_LEVEL` and configure a log destination if needed |
| Sentry | Client, server and edge instrumentation plus build config | Add a Sentry project and DSN; add build credentials for source maps |
| Vercel Analytics | `` in the root layout | Enable Web Analytics for the deployed Vercel project |
| Vercel Speed Insights | `` in the root layout | Enable Speed Insights for the deployed Vercel project |
Leaving a provider unconfigured should not stop the application from running,
but that provider will not give you useful production telemetry.
## Verify production monitoring
After deploying, prove that each enabled signal reaches its destination:
1. Write a uniquely named structured log and find it in the deployment logs.
2. Send a controlled test exception to Sentry and confirm that its stack trace
resolves to the original source.
3. Visit two or three production routes, then confirm page views appear in
Vercel Analytics.
4. Load a public page from a real browser and confirm Speed Insights begins
collecting field data. This data may not appear immediately.
5. Remove the test exception after verification and configure alerts for
business-critical failures such as authentication, checkout and webhooks.
Use separate Sentry environments or projects for preview and production.
Development noise and deliberate test failures should not trigger production
incident alerts.
---
## Sentry
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/observability/sentry
**Description**: Learn how to configure and use Sentry for error tracking and performance monitoring.
The starter kit includes a Sentry integration for client, server and edge error
tracking. It remains inactive in development and when
`NEXT_PUBLIC_SENTRY_DSN` is not configured.
## Setup
### 1. Create a Sentry Account
1. Go to [sentry.io](https://sentry.io/welcome/) and create an account
2. Create a new project (select **Next.js** as the platform)
3. Copy your **DSN** (Data Source Name)
### 2. Configure Environment Variables
Add your Sentry credentials to your `.env` file:
```env filename=".env" lineNumbers
NEXT_PUBLIC_SENTRY_DSN="https://xxxxx@xxxxx.ingest.sentry.io/xxxxx"
SENTRY_ORG="your-org"
SENTRY_PROJECT="your-project"
SENTRY_AUTH_TOKEN="sntrys_xxxxx"
```
Getting Your Auth Token
Create an auth token in Sentry: Settings →{' '}
Auth Tokens → Create New Token. Grant it{' '}
project:read and project:releases scopes.
### 3. Verify Configuration
Sentry is automatically configured in `instrumentation.ts`, `instrumentation-server.ts`, `instrumentation-edge.ts`, and `instrumentation-client.ts`. The configuration includes:
- Automatic error capture for client and server
- Performance monitoring
- Production source map uploads through `withSentryConfig` in `next.config.ts`
- User and request context for tRPC procedures
## Automatic Instrumentation
Sentry is pre-configured to automatically capture:
### Client-Side
- Unhandled JavaScript exceptions
- Unhandled promise rejections
- React component errors (via Error Boundaries)
- Performance metrics (Core Web Vitals)
- User session replays (optional)
### Server-Side
- API route errors
- Server component errors
- Server action errors
- tRPC procedure errors (via Sentry tRPC middleware)
- Database query errors
## tRPC Integration
The starter kit uses the **Sentry tRPC middleware** to automatically capture errors and performance metrics from all tRPC procedures. The middleware is configured in your tRPC setup and automatically:
- Captures all tRPC procedure errors with full context
- Tracks procedure execution time and performance
- Associates errors with the procedure path and input parameters
- Includes user context when available
The middleware is automatically applied to all procedures, so you don't need to manually instrument your tRPC endpoints.
The shipped tRPC middleware uses attachRpcInput: true and adds
the authenticated user's ID and email to the Sentry scope. Server
instrumentation also sets sendDefaultPii: true. Remove
unnecessary input fields, disable default PII or scrub events in{' '}
beforeSend before processing real customer data. Never send
passwords, tokens, payment details or other secrets to Sentry.
## Manual Error Tracking
### Capture Errors
You can manually capture errors in your code:
```typescript filename="lib/actions/example.ts" lineNumbers
import * as Sentry from '@sentry/nextjs';
try {
// Your code
} catch (error) {
Sentry.captureException(error, {
tags: {
section: 'billing'
},
extra: {
userId: user.id,
amount: 100
}
});
throw error;
}
```
### Capture Messages
Log important events:
```typescript filename="lib/actions/example.ts" lineNumbers
import * as Sentry from '@sentry/nextjs';
Sentry.captureMessage('Payment processed', {
level: 'info',
tags: {
feature: 'billing'
}
});
```
### Set User Context
Associate errors with users:
```typescript filename="lib/auth/session.ts" lineNumbers
import * as Sentry from '@sentry/nextjs';
export function setSentryUser(user: { id: string; email: string }) {
Sentry.setUser({
id: user.id,
email: user.email
});
}
```
## Performance Monitoring
Sentry automatically tracks:
- **Page Load Performance** - Time to first byte, first contentful paint
- **API Route Performance** - Response times for API routes
- **Core Web Vitals** - LCP, FID, CLS metrics
### Custom Performance Monitoring
Track custom operations using `startSpan`:
```typescript filename="lib/actions/example.ts" lineNumbers
import * as Sentry from '@sentry/nextjs';
await Sentry.startSpan(
{
name: 'Process Payment',
op: 'payment'
},
async () => {
// Your payment processing code
await processPayment();
}
);
```
## Source Maps
Source maps are automatically uploaded during build to provide readable stack traces in production.
### Configuration
Source maps are configured by `withSentryConfig` in `next.config.ts`. During a
production Vercel or CI build, the configuration:
1. Generates source maps during build
2. Uploads them to Sentry (if `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, and `SENTRY_PROJECT` are set)
3. Associates them with releases
### Releases
Releases help you track which version of your code caused an error. They are automatically configured in the instrumentation files:
```typescript filename="instrumentation-server.ts" lineNumbers
import { init } from '@sentry/nextjs';
import { env } from '@/lib/env';
init({
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NODE_ENV
// Release is automatically set by @sentry/nextjs from Vercel environment variables
});
```
## Session Replay
Session Replay is already enabled in `instrumentation-client.ts` when Sentry is
active. The shipped sample rates capture 10% of ordinary sessions and 100% of
sessions containing an error:
```typescript filename="instrumentation-client.ts" lineNumbers
import { init, replayIntegration } from '@sentry/nextjs';
init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
integrations: [replayIntegration()],
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0
});
```
Privacy Note
Session Replay can capture sensitive data. Review Sentry's masking defaults,
add product-specific masking rules and reduce or disable the sample rates if
replay is not appropriate for your application.
## Filtering Errors
The starter kit already includes comprehensive error filtering in `instrumentation-server.ts`, `instrumentation-edge.ts`, and `instrumentation-client.ts`. You can customize it:
```typescript filename="instrumentation-server.ts" lineNumbers
import { init } from '@sentry/nextjs';
import { env } from '@/lib/env';
init({
dsn: env.NEXT_PUBLIC_SENTRY_DSN,
beforeSend(event) {
// Filter out specific errors
const exception = event.exception?.values?.[0];
if (exception?.value?.includes('ResizeObserver')) {
return null; // Don't send this error
}
return event;
}
});
```
## Environment-Specific Configuration
Sentry is automatically disabled in development. The configuration in `instrumentation-server.ts` and `instrumentation-edge.ts` includes:
- Automatic disabling in development mode
- Sample rate of 0.1 (10%) for performance traces
- Error filtering for common noisy errors (TRPCError NOT_FOUND, ChunkLoadError, network errors, ResizeObserver errors, browser extension errors)
## Best Practices
1. **Set appropriate sample rates** - Use lower sample rates in production to reduce costs
2. **Filter sensitive data** - Don't send passwords, tokens, or PII
3. **Use tags** - Add tags to categorize errors (e.g., `feature: "billing"`)
4. **Set user context** - Always set user context for better error tracking
5. **Monitor performance** - Track slow operations and optimize them
6. **Review errors regularly** - Set up alerts for critical errors
---
## Vercel Speed Insights
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/observability/speed-insights
**Description**: Learn how to use Vercel Speed Insights for real-time performance monitoring.
The starter kit already renders **Vercel Speed Insights** from the root layout. Enable it in Vercel to collect performance measurements from real visits to your deployed application.
## What is already configured
The repository includes `@vercel/speed-insights` and renders its Next.js component in `app/layout.tsx`:
```tsx filename="app/layout.tsx" lineNumbers
import { SpeedInsights } from '@vercel/speed-insights/next';
// Inside the root layout body
;
```
No environment variable is required.
## Enable Speed Insights
1. Open your project in the [Vercel dashboard](https://vercel.com/dashboard).
2. Select **Speed Insights** in the project sidebar.
3. Enable Speed Insights.
4. Deploy the application again.
5. Visit several pages on the deployed application, then check the Speed Insights dashboard.
Expect real-user data
Speed Insights is not a synthetic benchmark. Results appear as people use the
deployed application, so a new or low-traffic project may take time to show a
representative score.
## Read the dashboard
Start with the page and device filters, then inspect the metrics that explain the experience:
- **LCP** measures how quickly the main content becomes visible.
- **INP** measures responsiveness across user interactions.
- **CLS** measures unexpected layout movement.
- **FCP** measures when the first content is painted.
- **TTFB** helps identify slow initial server responses.
Use field data to identify a slow page or device class. Reproduce the problem locally with browser performance tools before changing code.
## Control usage
The default component sends all supported measurements. If usage becomes material, reduce the sample rate in the existing root-layout integration:
```tsx filename="app/layout.tsx" lineNumbers
```
A value of `0.5` samples approximately half of eligible page views. Keep the default while traffic is low so early measurements are not unnecessarily sparse.
## Verify the integration
If no data appears:
1. Confirm Speed Insights is enabled for the correct Vercel project.
2. Confirm the latest deployment contains ``.
3. Visit the deployed application with tracking protection or ad blockers disabled.
4. Check that a reverse proxy or Content Security Policy is not blocking Vercel's collection requests.
5. Allow time for real visits to produce data.
See the [Vercel Speed Insights quickstart](https://vercel.com/docs/speed-insights/quickstart) for current provider setup details and the [usage guide](https://vercel.com/docs/speed-insights/managing-usage) before changing the sample rate.
---
## Vercel Analytics
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/observability/vercel
**Description**: Learn how to use Vercel Analytics for real-time traffic data.
The starter kit already renders **Vercel Web Analytics** from the root layout. You only need to enable the service for your Vercel project and deploy the application.
## What is already configured
The repository includes `@vercel/analytics` and renders its Next.js component in `app/layout.tsx`:
```tsx filename="app/layout.tsx" lineNumbers
import { Analytics } from '@vercel/analytics/next';
// Inside the root layout body
;
```
This records page views after Web Analytics is enabled. It does not require an environment variable.
## Enable Web Analytics
1. Open your project in the [Vercel dashboard](https://vercel.com/dashboard).
2. Select **Analytics** in the project sidebar.
3. Enable Web Analytics.
4. Deploy the application again so Vercel can add the analytics routes to the deployment.
5. Visit the deployed application, then return to the Analytics dashboard to confirm that data arrives.
Dashboard activation is required
The package and component are already present, but they do not replace
enabling Web Analytics for the Vercel project.
## Track a product event
Automatic page views answer traffic questions. Add custom events only for product actions that matter, such as completing onboarding or starting checkout:
```tsx filename="components/checkout-button.tsx" lineNumbers
'use client';
import { track } from '@vercel/analytics';
export function CheckoutButton() {
return (
);
}
```
Do not send email addresses, names or other personal data as event properties.
## Verify the integration
If the dashboard remains empty:
1. Confirm Web Analytics is enabled for the correct Vercel project.
2. Confirm the deployment was created after activation.
3. Visit the production deployment with tracking protection or ad blockers disabled.
4. Check that requests to `/_vercel/insights/*` are not blocked by a reverse proxy or Content Security Policy.
5. Confirm `app/layout.tsx` still renders ``.
Use [Vercel's Web Analytics troubleshooting guide](https://vercel.com/docs/analytics/troubleshooting) for provider-specific diagnostics.
## Privacy and consent
Web Analytics is designed without cookies, but privacy obligations depend on your users, configuration and jurisdiction. Document the service in your privacy notice and review [Vercel's privacy guidance](https://vercel.com/docs/analytics/privacy-policy) before launch. Do not treat the default integration as a substitute for legal review.
---
## Configure
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/organizations/configure
**Description**: Learn how to configure organizations in your application.
Organizations are configured in the Better Auth setup in `lib/auth/index.ts`. The organization plugin is already enabled by default.
## Organization Plugin Configuration
The organization plugin is configured in `lib/auth/index.ts`:
```typescript filename="lib/auth/index.ts" lineNumbers
import { organization } from 'better-auth/plugins';
export const auth = betterAuth({
// ... other config
plugins: [
organization({
// Organization configuration
})
]
});
```
## Default Behavior
By default, organizations are:
- **Enabled** - Users can create and join organizations
- **Optional** - Users don't need to be in an organization to use the app
- **Visible** - Organization selection is shown in the UI
- **User-creatable** - Users can create new organizations
## Customizing Organization Behavior
### Require Organization
To require users to be in an organization to access the application, you can add middleware or route protection:
```typescript filename="middleware.ts" lineNumbers
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth/server';
export async function middleware(request: NextRequest) {
const session = await getSession();
if (!session) {
return redirect('/auth/sign-in');
}
// Check if user has an active organization
if (!session.session.activeOrganizationId) {
// Redirect to organization creation/selection
return redirect('/dashboard/onboarding');
}
}
```
### Hide Organization Selection
If you want to build a single-tenant application where users should only be members of one organization, you can hide the organization switcher in your UI components.
### Disable Organization Creation
To block regular users from creating organizations through the starter's tRPC
procedure, change the shipped app configuration:
```typescript filename="config/app.config.ts" lineNumbers
export const appConfig = {
// ... other config
organizations: {
allowUserCreation: false
}
};
```
The `trpc.organization.create` procedure enforces this setting for non-admin
users. Platform admins can still create organizations through that procedure.
The setting does not configure Better Auth's organization endpoint. Passing
`allowUserToCreateOrganization: false` to that plugin blocks everyone through
the direct endpoint. Use a function for an endpoint policy that still permits
selected users such as platform admins.
## Shipped Organization Hooks
The Better Auth organization plugin is configured in `lib/auth/index.ts`. The
shipped integration provides a custom invitation email callback and hooks that
synchronize subscription seats after membership changes:
```typescript filename="lib/auth/index.ts" lineNumbers
organization({
sendInvitationEmail: async ({ email, inviter, id, organization }) => {
// Check plan limits, build the invitation URL and send the email.
},
organizationHooks: {
afterAddMember: async ({ organization }) => {
await syncOrganizationSeats(organization.id);
},
afterRemoveMember: async ({ organization }) => {
await syncOrganizationSeats(organization.id);
},
afterAcceptInvitation: async ({ organization }) => {
await syncOrganizationSeats(organization.id);
}
}
});
```
The repositories do not pass `memberRoles`, `invitation.expiresIn` or a
top-level `hooks` object to this plugin. Add only options supported by the
installed Better Auth version.
## Invite-Only Organizations
There is no single invite-only organization switch. The starter includes
member invitations and the `allowUserCreation` setting, but you must compose
and enforce the policy your product needs.
For an invite-only organization setup:
1. **Disable organization creation** - Remove or restrict the create organization functionality
2. **Require invitations** - Only allow users to join via invitations
3. **Control invitations** - Only allow admins/owners to send invitations
The Better Auth organization plugin supplies the invitation workflow. Your
application must still enforce who may create organizations and send
invitations on every server-side path.
## Best Practices
1. **Use organization slugs** - Use URL-friendly slugs for organization identification
2. **Validate membership** - Always verify user membership before allowing access
3. **Scope data** - Always scope data queries by organization ID
4. **Handle edge cases** - Handle cases where users have no organizations
5. **Role-based access** - Use roles to control what users can do
---
## Overview
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/organizations/overview
**Description**: Learn how organizations work in the starter kit.
Organizations are a way to share data between users. Organizations can have members with different roles and permissions.
In the starter kit, organizations are handled by [Better Auth](https://better-auth.com) and therefore you have full control over the organization management and all data is stored in the database.
## How Organizations Work
Organizations enable multi-tenancy in your application. Users can:
- **Create organizations** - Users can create their own organizations
- **Join organizations** - Users can be invited to join organizations
- **Switch between organizations** - Users can be members of multiple organizations
- **Have different roles** - Users can have different roles in different organizations
## Active Organization
The active organization is stored in the Better Auth session. The `activeOrganizationId` is available in `session.activeOrganizationId` and can be accessed using Better Auth's hooks and APIs.
This approach provides several benefits:
1. **Session-based** - The active organization persists across page navigations
2. **Simple access** - Use `authClient.useActiveOrganization()` on the client or `getSession()` on the server
3. **Automatic scoping** - tRPC's `protectedOrganizationProcedure` automatically uses the active organization
4. **Consistent state** - The active organization is managed by Better Auth and stays in sync
## Roles
Organizations support the following roles:
- **Owner** - Full control over the organization
- **Admin** - Can manage members and organization settings
- **Member** - Can access organization data
A user can have different roles in different organizations.
---
## Store Data
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/organizations/store-data
**Description**: Learn how to store data for organizations in your application.
When working with organizations, you typically want to store data that belongs to each organization and can be accessed by organization members.
## Database Schema
### Add Organization ID to Your Tables
Add an `organizationId` field to tables that should be scoped to organizations:
```typescript filename="lib/db/schema/posts.ts" lineNumbers
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
import { organizations } from './organizations';
import { users } from './users';
export const postsTable = pgTable('posts', {
id: text('id').primaryKey(),
title: text('title').notNull(),
content: text('content').notNull(),
authorId: text('authorId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
organizationId: text('organizationId')
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull()
});
export const postsRelations = relations(postsTable, ({ one }) => ({
author: one(users, {
fields: [postsTable.authorId],
references: [users.id]
}),
organization: one(organizations, {
fields: [postsTable.organizationId],
references: [organizations.id]
})
}));
```
This allows:
- All members of an organization to access the posts
- The author to be tracked separately
- Data to be properly scoped to organizations
## Creating Organization-Scoped Data
### Using tRPC
Use `protectedOrganizationProcedure` to automatically scope data to the active organization:
```typescript filename="trpc/routers/posts.ts" lineNumbers
import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init';
import { z } from 'zod';
import { assertUserIsOrgMember } from '@/lib/auth/server';
import { db } from '@/lib/db';
import { postsTable } from '@/lib/db/schema';
export const postsRouter = createTRPCRouter({
create: protectedOrganizationProcedure
.input(
z.object({
title: z.string().min(1),
content: z.string().min(1)
})
)
.mutation(async ({ input, ctx }) => {
// ctx.organization is guaranteed to exist
// User membership is already verified
const [post] = await db
.insert(postsTable)
.values({
title: input.title,
content: input.content,
authorId: ctx.user.id,
organizationId: ctx.organization.id
})
.returning();
return post;
})
});
```
### Verifying Membership
If you need to verify membership manually:
```typescript filename="lib/auth/verify-membership.ts" lineNumbers
import { assertUserIsOrgMember } from '@/lib/auth/server';
export async function verifyMembership(organizationId: string, userId: string) {
// This will throw an error if user is not a member
const { organization, membership } = await assertUserIsOrgMember(
organizationId,
userId
);
return { organization, membership };
}
```
## Querying Organization Data
### List Organization Posts
Query posts for the active organization:
```typescript filename="trpc/routers/posts.ts" lineNumbers
list: protectedOrganizationProcedure.query(async ({ ctx }) => {
// Automatically scoped to ctx.organization.id
const posts = await db.query.postsTable.findMany({
where: eq(postsTable.organizationId, ctx.organization.id),
orderBy: desc(postsTable.createdAt),
});
return posts;
}),
```
### Get Single Post
Get a single post, ensuring it belongs to the organization:
```typescript filename="trpc/routers/posts.ts" lineNumbers
getById: protectedOrganizationProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const post = await db.query.postsTable.findFirst({
where: and(
eq(postsTable.id, input.id),
eq(postsTable.organizationId, ctx.organization.id)
),
});
if (!post) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Post not found",
});
}
return post;
}),
```
## Client-Side Usage
### Creating Posts
Create posts from the UI:
```tsx filename="components/create-post-form.tsx" lineNumbers
'use client';
import { trpc } from '@/trpc/client';
import { useForm } from 'react-hook-form';
import { useActiveOrganization } from '@/hooks/use-active-organization';
export function CreatePostForm() {
const { activeOrganization } = useActiveOrganization();
const utils = trpc.useUtils();
const createPost = trpc.posts.create.useMutation({
onSuccess: () => {
utils.posts.list.invalidate();
}
});
const onSubmit = async (data: { title: string; content: string }) => {
if (!activeOrganization) {
throw new Error('No active organization');
}
await createPost.mutateAsync(data);
};
return ;
}
```
### Listing Posts
List posts for the active organization:
```tsx filename="components/posts-list.tsx" lineNumbers
'use client';
import { trpc } from '@/trpc/client';
export function PostsList() {
const { data: posts, isLoading } = trpc.posts.list.useQuery();
if (isLoading) return
Loading...
;
if (!posts?.length) return
No posts found
;
return (
{posts.map((post) => (
{post.title}
{post.content}
))}
);
}
```
## Updating Organization Data
### Update with Permission Check
Only allow admins to update organization-scoped data:
```typescript filename="trpc/routers/posts.ts" lineNumbers
update: protectedOrganizationProcedure
.input(
z.object({
id: z.string(),
title: z.string().optional(),
content: z.string().optional(),
})
)
.mutation(async ({ input, ctx }) => {
// Check if user is admin or owner
const isAdmin =
ctx.membership.role === "admin" || ctx.membership.role === "owner";
const post = await db.query.postsTable.findFirst({
where: and(
eq(postsTable.id, input.id),
eq(postsTable.organizationId, ctx.organization.id)
),
});
if (!post) {
throw new TRPCError({ code: "NOT_FOUND" });
}
// Only author or admin can update
if (post.authorId !== ctx.user.id && !isAdmin) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only edit your own posts or be an admin",
});
}
const [updatedPost] = await db
.update(postsTable)
.set({
title: input.title,
content: input.content,
updatedAt: new Date(),
})
.where(eq(postsTable.id, input.id))
.returning();
return updatedPost;
}),
```
## Best Practices
1. **Always scope by organizationId** - Never query without organization scope
2. **Use protectedOrganizationProcedure** - Automatically handles scoping
3. **Verify membership** - Always verify user is a member before operations
4. **Check permissions** - Verify roles before allowing modifications
5. **Cascade deletes** - Use `onDelete: "cascade"` for organization-scoped data
6. **Index organizationId** - Add database indexes on `organizationId` for performance
---
## Use Organizations
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/organizations/use-organizations
**Description**: Learn how to use organizations in your application.
In the starter kit, the active organization is stored in the Better Auth session. The active organization ID is available in `session.activeOrganizationId` and can be accessed using Better Auth's hooks and APIs.
## How Active Organization Works
The active organization is managed by Better Auth and stored in the user's session. When a user switches organizations, the `activeOrganizationId` in the session is updated, and this organization becomes available throughout your application.
## Client-Side Usage
### Using Better Auth's Hook
Use `authClient.useActiveOrganization()` to access the active organization:
```tsx filename="components/organization-content.tsx" lineNumbers
'use client';
import { authClient } from '@/lib/auth/client';
export function OrganizationContent() {
const { data: activeOrganization, isPending } =
authClient.useActiveOrganization();
if (isPending) {
return
Loading...
;
}
if (!activeOrganization) {
return
No active organization found
;
}
return (
{activeOrganization.name}
);
}
```
### Switching Organizations
Switch organizations using `authClient.organization.setActive()`:
```tsx filename="components/organization-switcher.tsx" lineNumbers
'use client';
import { useRouter } from 'next/navigation';
import { authClient } from '@/lib/auth/client';
export function OrganizationSwitcher() {
const router = useRouter();
const handleSwitch = async (organizationId: string) => {
// Set the active organization in Better Auth session
await authClient.organization.setActive({
organizationId
});
// Navigate to the organization dashboard
router.push('/dashboard/organization');
};
return (
);
}
```
### Getting Active Organization from Session
You can also access the active organization ID directly from the session:
```tsx filename="components/example.tsx" lineNumbers
'use client';
import { useSession } from '@/hooks/use-session';
export function Example() {
const { session } = useSession();
const activeOrganizationId = session?.activeOrganizationId;
return
Active Org ID: {activeOrganizationId}
;
}
```
## Server-Side Usage
### Get Active Organization from Session
Get the active organization from the session:
```tsx filename="app/(saas)/dashboard/organization/page.tsx" lineNumbers
import { getOrganizationById, getSession } from '@/lib/auth/server';
export default async function OrganizationPage() {
const session = await getSession();
if (!session?.session.activeOrganizationId) {
return
;
}
```
### Get Organization by ID
Get organization data for a specific organization ID:
```typescript filename="lib/organization/get-organization.ts" lineNumbers
import { getOrganizationById } from '@/lib/auth/server';
export async function getOrganization(organizationId: string) {
const organization = await getOrganizationById(organizationId);
return organization;
}
```
### Using in tRPC
The active organization is automatically available in `protectedOrganizationProcedure`:
```typescript filename="trpc/routers/organization/index.ts" lineNumbers
import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
export const organizationRouter = createTRPCRouter({
get: protectedOrganizationProcedure.query(async ({ ctx }) => {
// ctx.organization is guaranteed to exist
// ctx.membership contains the user's role
return {
organization: ctx.organization,
role: ctx.membership.role
};
}),
update: protectedOrganizationProcedure
.input(z.object({ name: z.string() }))
.mutation(async ({ input, ctx }) => {
// Only allow admins/owners to update
if (ctx.membership.role !== 'admin' && ctx.membership.role !== 'owner') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only admins can update organizations'
});
}
// Update organization using Better Auth API
await authClient.organization.update({
organizationId: ctx.organization.id,
name: input.name
});
return { success: true };
})
});
```
## Organization Switching
### Client-Side Switching
Switch organizations using Better Auth's API:
```tsx filename="components/organization-switcher.tsx" lineNumbers
'use client';
import { useRouter } from 'next/navigation';
import { authClient } from '@/lib/auth/client';
export function OrganizationSwitcher() {
const router = useRouter();
const handleSwitch = async (organizationId: string) => {
try {
// Set the active organization in Better Auth session
await authClient.organization.setActive({
organizationId
});
// Navigate to the organization dashboard
router.push('/dashboard/organization');
} catch (error) {
console.error('Failed to switch organization:', error);
}
};
return (
);
}
```
### Server-Side Switching
Update the active organization in the session:
```typescript filename="app/api/organization/switch/route.ts" lineNumbers
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { assertUserIsOrgMember, getSession } from '@/lib/auth/server';
export async function POST(request: Request) {
const { organizationId } = await request.json();
const session = await getSession();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Verify user is member of organization
await assertUserIsOrgMember(organizationId, session.user.id);
// Update active organization in session
await auth.api.setActiveOrganization({
headers: await headers(),
body: {
organizationId
}
});
return NextResponse.json({ success: true });
}
```
## Listing User's Organizations
Get all organizations a user is a member of:
```typescript filename="trpc/routers/organization/index.ts" lineNumbers
import { createTRPCRouter, protectedProcedure } from '@/trpc/init';
import { asc, eq, getTableColumns } from 'drizzle-orm';
import { db } from '@/lib/db';
import { memberTable, organizationTable } from '@/lib/db/schema';
export const organizationRouter = createTRPCRouter({
list: protectedProcedure.query(async ({ ctx }) => {
const organizations = await db
.select({
...getTableColumns(organizationTable),
membersCount: db
.$count(
memberTable,
eq(memberTable.organizationId, organizationTable.id)
)
.as('membersCount')
})
.from(organizationTable)
.innerJoin(
memberTable,
eq(organizationTable.id, memberTable.organizationId)
)
.where(eq(memberTable.userId, ctx.user.id))
.orderBy(asc(organizationTable.createdAt));
return organizations.map((org) => ({
...org,
slug: org.slug || ''
}));
})
});
```
Client-side:
```tsx filename="components/organizations-list.tsx" lineNumbers
'use client';
import { trpc } from '@/trpc/client';
export function OrganizationsList() {
const { data: organizations, isLoading } = trpc.organization.list.useQuery();
if (isLoading) return
Loading...
;
return (
{organizations?.map((org) => (
{org.name}
))}
);
}
```
## Best Practices
1. **Always check membership** - Verify user is a member before allowing access
2. **Use protectedOrganizationProcedure** - Automatically handles organization scoping
3. **Handle loading states** - Show loading indicators while fetching organization
4. **Handle missing organizations** - Provide fallback UI when no organization is active
5. **Validate permissions** - Check roles before allowing actions
6. **Use session-based approach** - The active organization is stored in the session, not the URL
---
## Favicons & Icons
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/recipes/favicons
**Description**: Learn how to generate and integrate favicons and app icons for your project.
## Generating a favicon
1. Visit [Favicon Generator](https://www.favicon-generator.org/).
2. Upload an image (recommended size: **at least 512×512px** for optimal resizing).
3. Click on **Create Favicon**
## Downloading
Click **Download** to save the generated files.
## Copying and overwriting
1. Select all downloaded files, **excluding** `browserconfig.xml` and `manifest.json`.
2. Copy and paste the files into the `public` directory.
## Updating metadata
Update the favicon references in `app/layout.tsx`:
```tsx filename="app/layout.tsx" lineNumbers
export const metadata = {
icons: {
icon: '/favicon.ico',
apple: '/apple-touch-icon.png'
}
// ...
};
```
Note that sometimes it takes time for the browser to reflect favicon changes.
Try clearing your browser cache or doing a hard refresh.
---
## Fonts
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/recipes/fonts
**Description**: Learn how to change fonts using Google Fonts or custom fonts.
You can change the font in `app/layout.tsx`. The starter kit currently uses:
```tsx filename="app/layout.tsx" lineNumbers
import { Literata } from 'next/font/google';
import { GeistSans } from 'geist/font/sans';
const literata = Literata({
subsets: ['latin'],
variable: '--font-literata'
});
export default function RootLayout({ children }) {
return (
{/* ... */}
);
}
```
To change to a different Google Font, for example `Inter`:
```tsx filename="app/layout.tsx" lineNumbers
import { Inter } from 'next/font/google';
import { GeistSans } from 'geist/font/sans';
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter'
});
export default function RootLayout({ children }) {
return (
{/* ... */}
);
}
```
Or use a custom font:
```tsx filename="app/layout.tsx" lineNumbers
import localFont from 'next/font/local';
const customFont = localFont({
src: './fonts/custom-font.woff2',
display: 'swap'
});
```
## Font Variables
Font variables are automatically available via the `variable` prop. You can use them in your CSS:
```css filename="app/globals.css" lineNumbers
:root {
--font-sans: var(--font-geist-sans);
--font-literata: var(--font-literata);
}
```
Then use them in your components:
```tsx filename="components/example.tsx" lineNumbers
This uses Geist Sans
This uses Literata
```
---
## Naming & Branding
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/recipes/naming
**Description**: Learn how to change the app name, description, and metadata throughout your application.
## App Name and Description
The app name and description are used throughout the application. Update them in the configuration file:
```typescript filename="config/app.config.ts" lineNumbers
export const appConfig = {
appName: 'Your App Name',
description: 'A fantastic SaaS to make your life easier.',
baseUrl: 'https://yourdomain.com'
// ...
};
```
This configuration is used in:
- Navigation and headers
- Email templates
- SEO metadata
- Social sharing
## Metadata
The metadata in `app/layout.tsx` automatically uses values from `appConfig`:
```typescript filename="app/layout.tsx" lineNumbers
import { appConfig } from '@/config/app.config';
export const metadata: Metadata = {
metadataBase: new URL(appConfig.baseUrl),
title: {
absolute: appConfig.appName,
default: appConfig.appName,
template: `%s | ${appConfig.appName}`
},
description: appConfig.description,
openGraph: {
type: 'website',
locale: 'en_US',
siteName: appConfig.appName,
title: appConfig.appName,
description: appConfig.description
},
twitter: {
card: 'summary_large_image',
title: appConfig.appName,
description: appConfig.description
}
};
```
## Package.json
Update the name and description in `package.json`:
```json filename="package.json" lineNumbers
{
"name": "your-app-name",
"version": "1.0.0",
"description": "Your app description",
"author": "Your Name",
"license": "MIT"
// ...
}
```
## Environment Variables
The `baseUrl` in `appConfig` uses `getBaseUrl()` which reads from `NEXT_PUBLIC_SITE_URL` if set, otherwise falls back to the request URL. You can set it in your `.env`:
```env filename=".env" lineNumbers
NEXT_PUBLIC_SITE_URL=https://yourdomain.com
```
## Email Branding
Update email templates to reflect your branding. Email templates are located in `lib/email/templates/`:
```typescript filename="lib/email/templates/welcome-email.tsx" lineNumbers
export function WelcomeEmail({ name }: { name: string }) {
return (
Welcome to Your App Name!Welcome to Your App Name!Hi {name},Welcome to Your App Name! We're excited to have you.
);
}
```
---
## Overview
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/recipes/overview
**Description**: Common recipes and guides to help you extend your application.
Recipes are step-by-step guides for common tasks and integrations that help you extend your application with additional features and services.
---
## Supabase Setup
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/recipes/supabase-setup
**Description**: Learn how to set up your application with Supabase as the database and storage provider.
In this guide, we'll show you how to set up your application with Supabase as the database and storage provider.
Note
We will use Supabase as the database and storage provider. The authentication
feature of Supabase is not used, as we use Better Auth for authentication,
which stores user data directly in your database.
Before we start, make sure you have a Supabase account. If you don't have one yet, you can create one for free at [supabase.com](https://supabase.com/).
## 1. Create a new Supabase project
1. Go to [supabase.com](https://supabase.com/) and sign in
2. Click **New Project**
3. Fill in your project details:
- **Name**: Your project name
- **Database Password**: Choose a strong password (save this!)
- **Region**: Choose a region close to your deployment (see note below)
Region Selection
Make sure your application and database are physically close. If you use
Vercel and Supabase, make sure they are in the same AWS region. For example: -
Vercel (D.C.) and Supabase (Virginia): Both are in the us-east-1 region, which
results in a fast application - Vercel (D.C.) and Supabase (Ohio): You are
dealing with two different AWS regions, the app can be up to 3-12x slower!
## 2. Get connection strings
In the Supabase dashboard, click the **Connect** button in the top row.
Select the **ORM** tab and **Drizzle** as the tool.
You will need the `DATABASE_URL`. For Drizzle, you can use the connection pooling URL or the direct connection URL.
## 3. Set environment variables
Open your `.env` file and set the environment variables as follows:
### For Connection Pooling (Recommended)
```env filename=".env" lineNumbers
# Connection pooling URL (recommended for production)
DATABASE_URL="postgres://postgres.[your-supabase-project]:[password]@aws-0-[aws-region].pooler.supabase.com:6543/postgres?pgbouncer=true"
```
### For Direct Connection
```env filename=".env" lineNumbers
# Direct connection URL (for migrations and development)
DATABASE_URL="postgresql://postgres:[password]@db.[your-project-ref].supabase.co:5432/postgres"
```
Important
Make sure to replace the password and project ref placeholders with your own
values from the Supabase dashboard.
## 4. Run migrations
To push the database schema to Supabase, run the following command:
```bash filename="Terminal" lineNumbers
npm run db:push
```
Or if you prefer to use migrations:
```bash filename="Terminal" lineNumbers
npm run db:generate
npm run db:migrate
```
Database access and RLS
The shipped app uses Drizzle on the server. It does not configure the Supabase
Data API or map Better Auth sessions or JWT claims into RLS policies. Better
Auth does not replace RLS. If you expose the Data API or query Supabase from
client code, design and test RLS policies for every exposed table first.
## 5. Connect Supabase storage for file uploads
To enable the shipped user avatar and organization logo uploads, you can use Supabase Storage through its S3-compatible endpoint.
### Create a storage bucket
1. Go to the **Storage** tab in the Supabase dashboard
2. Click the **Create bucket** button
3. Name the bucket, for example `avatars`
4. Deactivate the **Public bucket** switch to prevent direct anonymous object access
5. Optionally, define a maximum file size and restrict file types for this bucket
Application access remains public by key
The private bucket setting does not add authorization to the starter kit. The
shipped /storage/[...path] route does not read a session or check
ownership. Anyone who knows an image key can ask the application for a signed
download redirect. Add a protected route and file ownership metadata before
storing private files.
### Get storage credentials
1. Navigate to **Project settings** from the sidebar
2. Select the **Storage** tab
3. Scroll down to the **S3 access keys** section
4. Click the **New access key** button
5. Enter a description for your access key
6. After clicking **Create access key**, copy the **Access key ID** and **Secret access key**
### Configure environment variables
Add the following environment variables to your `.env` file:
```env filename=".env" lineNumbers
S3_ACCESS_KEY_ID="your-access-key"
S3_SECRET_ACCESS_KEY="your-secret-key"
S3_ENDPOINT="https://[YOUR-PROJECT-REF].storage.supabase.co/storage/v1/s3"
S3_REGION="[YOUR-PROJECT-REGION]"
NEXT_PUBLIC_IMAGES_BUCKET_NAME="avatars"
```
Copy the endpoint and region shown with the S3 access keys in the Supabase
dashboard. The region participates in request signing, so do not substitute a
region from another project.
## 6. Confirm storage configuration
No storage code change is required for the bucket name. The shipped configuration reads `NEXT_PUBLIC_IMAGES_BUCKET_NAME`:
```typescript filename="config/storage.config.ts" lineNumbers
import { env } from '@/lib/env';
export const storageConfig = {
bucketNames: {
images: env.NEXT_PUBLIC_IMAGES_BUCKET_NAME ?? ''
}
} satisfies StorageConfig;
```
## 7. Run development server
Now you should be able to start the development server:
```bash filename="Terminal" lineNumbers
npm run dev
```
## Troubleshooting
### Connection issues
If you're experiencing connection issues:
1. Verify your connection string is correct
2. Check that your IP is allowed in Supabase (if IP restrictions are enabled)
3. Ensure you're using the correct region
4. Try using the direct connection URL instead of the pooled connection
### Migration issues
If migrations fail:
1. Make sure you're using the direct connection URL for migrations
2. Check that your database password is correct
3. Verify that your project has the necessary permissions
### Storage issues
If file uploads aren't working:
1. Verify your S3 credentials are correct
2. Check that the bucket exists and is accessible
3. Ensure the bucket name matches your configuration
4. Verify the endpoint URL is correct
That's all it takes to set up your application with Supabase! If you have questions or need help, refer to the [Supabase documentation](https://supabase.com/docs).
---
## Theming & Styling
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/recipes/theming
**Description**: Learn how to customize colors, themes, and styling with Tailwind CSS and shadcn/ui.
The starter kit uses [Tailwind CSS](https://tailwindcss.com) for styling and [shadcn/ui](https://ui.shadcn.com) 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 (
);
}
);
Button.displayName = 'Button';
export { Button };
```
## Dark Mode
Dark mode is automatically handled by the theme system. Users can toggle between light and dark themes:
```tsx filename="components/theme-toggle.tsx" lineNumbers
'use client';
import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
);
}
```
## Global Styles
Customize global styles in `app/globals.css`:
```css filename="app/globals.css" lineNumbers
@import 'tailwindcss';
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
```
## Custom Themes
You can create custom themes by modifying the CSS variables:
```css filename="app/globals.css" lineNumbers
[data-theme='custom'] {
--primary: 142 76% 36%;
--primary-foreground: 355 100% 97%;
/* ... other custom colors */
}
```
Then apply the theme:
```tsx filename="app/layout.tsx" lineNumbers
{/* ... */}
```
---
## Setup
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/setup
**Description**: Get your Pro Next.js Drizzle project up and running in less than 30 minutes.
This guide will walk you through the steps to set up your project locally and start developing.
## Prerequisites
Install these before cloning the repository:
- Node.js `22.21.1`, matching the version in `package.json`
- npm, included with Node.js
- Git
- Docker Desktop or another PostgreSQL 17 installation
- A Resend account for password signups. A verified domain is required before
sending to arbitrary recipients, but Resend's test sender can be used with
your own account email during initial local setup.
Start from a clean baseline
Complete this guide and verify the included application before renaming the
product, replacing providers or moving routes. This keeps setup failures
separate from customization failures.
## Step 1: Clone the Repository
Clone the project to your local machine:
```bash filename="Terminal" lineNumbers
git clone my-saas-app
cd my-saas-app
```
## Step 2: Install Dependencies
We use `npm` for dependency management:
```bash filename="Terminal" lineNumbers
npm install
```
## Step 3: Configure Environment Variables
Copy the example environment file:
```bash filename="Terminal" lineNumbers
cp .env.example .env
```
Generate a secret for Better Auth and add it to `.env`:
You can use the generated value above or create one from your terminal:
```bash filename="Terminal" lineNumbers
openssl rand -base64 32
```
```ini filename=".env" lineNumbers
BETTER_AUTH_SECRET="paste-the-generated-secret-here"
```
## Step 4: Database Setup
1. Make sure you have PostgreSQL running. We provide a `docker-compose.yml` for convenience:
```bash filename="Terminal" lineNumbers
npm run docker:up
```
2. The database is automatically created by Docker Compose. If you're using a local PostgreSQL installation, create the database:
```bash filename="Terminal" lineNumbers
createdb database
```
3. Update `DATABASE_URL` in `.env` to match your local setup:
```ini filename=".env" lineNumbers
# For Docker (default):
DATABASE_URL="postgresql://postgres:password@localhost:5432/database"
# For local PostgreSQL:
DATABASE_URL="postgresql://your_user:your_password@localhost:5432/database"
```
4. Apply the migrations committed with the starter kit:
```bash filename="Terminal" lineNumbers
npm run db:migrate
```
Start from the committed migration history
A fresh clone already contains the migrations required by the shipped
schema. Applying them keeps your local database aligned with staging and
production.
After changing the TypeScript schema, generate a new SQL migration and apply it:
```bash filename="Terminal" lineNumbers
npm run db:generate
npm run db:migrate
```
Commit the generated files in `lib/db/migrations/` with the schema change. On
staging and production, run only `npm run db:migrate` against the target
database. Do not generate new migrations during deployment.
Use `npm run db:push` only for disposable local prototyping when you deliberately
do not need a migration file. It must not replace the initial migration step or
be used against staging or production.
## Step 5: Configure Email
Email verification is required for password signups. Configure `RESEND_API_KEY`
and `EMAIL_FROM` before creating an account. For the quickest local check, use
`onboarding@resend.dev` as the sender and sign up with the email address attached
to your Resend account. Verify a domain before testing other recipients or
deploying the application. Follow the
[email configuration guide](/docs/starter-kits/pro-nextjs-drizzle/email/configuration)
to create a Resend API key and verify your sending domain.
## Step 6: Start Development Server
```bash filename="Terminal" lineNumbers
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) - your app is running!
## Step 7: Create Your First Account
1. Go to [http://localhost:3000/auth/sign-up](http://localhost:3000/auth/sign-up)
2. Enter your name, email and password
3. Open the verification email sent through Resend
4. Click the link to verify your email
5. You're in!
## Step 8: Verify the Baseline
Before customizing the product, confirm that the repository passes its included
quality checks:
```bash filename="Terminal" lineNumbers
npm run typecheck
npm run lint
npm run format
npm run test -- --run
```
The explicit `--run` makes Vitest execute once and exit instead of opening its
local watch workflow.
Then verify these flows in the browser:
- Create and verify an account
- Create an organization
- Invite a second member if you have another test email
- Open account and organization settings
- Confirm the dashboard loads without server errors
The repository also includes authenticated Playwright coverage for sign-in,
organizations, settings, two-factor authentication, AI credit enforcement and
the admin area. Point `DATABASE_URL` at an isolated disposable test database,
install the browser once and run the suite:
```bash filename="Terminal" lineNumbers
npm run test:e2e:setup
npm run test:e2e
```
The E2E seed resets deterministic users and authentication state. Never run it
against a development database containing data you need or against any
production database. See the [E2E testing
guide](/docs/starter-kits/pro-nextjs-drizzle/tests/e2e) for the fixture
accounts and browser workflow.
## Step 9: Make Yourself an Admin
The first user should be a platform admin to access the admin dashboard (`/dashboard/admin`).
**Option A: Using Drizzle Studio (Recommended)**
```bash filename="Terminal" lineNumbers
# Open Drizzle Studio
npm run db:studio
```
1. Open [https://local.drizzle.studio](https://local.drizzle.studio) in your browser
2. Click on the `user` table
3. Find your user and click to edit
4. Change `role` from `user` to `admin`
5. Save
**Option B: Using SQL directly**
```bash filename="Terminal" lineNumbers
# If using Docker (container name may vary based on directory name):
docker compose exec postgres psql -U postgres -d database
# If using local PostgreSQL:
psql -d database
# Then run:
UPDATE "user" SET role = 'admin' WHERE email = 'your@email.com';
\q
```
Now you can access the admin panel at [http://localhost:3000/dashboard/admin](http://localhost:3000/dashboard/admin).
## Next Steps
Keep the verified baseline working while you turn it into your product:
1. Bookmark the [common commands](/docs/starter-kits/pro-nextjs-drizzle/codebase/commands) used for development, tests and local services.
2. Update the product name, theme and assets using the [customization guide](/docs/starter-kits/pro-nextjs-drizzle/customization/overview).
3. Enable only the providers your product needs in [configuration](/docs/starter-kits/pro-nextjs-drizzle/configuration).
4. Add or change the product-specific data model through the [Drizzle database guide](/docs/starter-kits/pro-nextjs-drizzle/database).
5. Complete the [production deployment checklist](/docs/starter-kits/pro-nextjs-drizzle/deployment) before inviting real users.
Change one subsystem at a time
Keep authentication, email and the database working while you customize the
product. Run the typecheck, lint and test commands after each meaningful
change so failures remain easy to trace.
---
## Storage
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/storage
**Description**: Understand the image storage integration that ships with the Pro Next.js Drizzle starter kit.
The starter kit includes an S3-compatible image storage integration for user avatars and organization logos. It is configured for Cloudflare R2 and can be adapted to another provider that supports the S3 API.
Security boundary
The upload signer requires an authenticated user. The download route does not.
The shipped /storage/[...path] route will generate a one-hour
signed download URL for anyone who knows a valid image key. A signed URL
limits how long storage credentials can be used, but it does not prove file
ownership or organization membership.
## What ships
- `lib/storage/s3.ts` creates an S3 client and presigns `PutObject` and `GetObject` commands.
- `trpc/routers/storage/index.ts` exposes an authenticated `storage.signedUploadUrl` mutation for the configured images bucket.
- `app/storage/[...path]/route.ts` exposes a public image redirect route.
- `hooks/use-storage.tsx` converts a stored image key into the public route URL.
- Avatar and organization logo components crop an image, upload it directly and save its key through Better Auth.
The included flow is intended for display images such as avatars and logos. The generated UUID-based keys make accidental discovery less likely, but an unguessable key is not authorization.
## What does not ship
The repositories do not include:
- A file metadata table
- User or organization ownership checks for storage objects
- Private download, listing or deletion procedures
- File quota enforcement, malware scanning or audit logs
- Automatic object deletion when an avatar or logo is removed
- Server-enforced file size or MIME type validation
Add those controls before using the storage integration for invoices, exports, identity documents or other private files. See [Access Files](/docs/starter-kits/pro-nextjs-drizzle/storage/access) for the required design changes.
## Environment variables
```env filename=".env" lineNumbers
S3_ACCESS_KEY_ID="your-access-key"
S3_SECRET_ACCESS_KEY="your-secret-key"
S3_ENDPOINT="https://your-s3-endpoint"
S3_REGION="auto"
NEXT_PUBLIC_IMAGES_BUCKET_NAME="your-images-bucket"
```
`NEXT_PUBLIC_IMAGES_BUCKET_NAME` is public configuration. Keep the access key and secret key server-only.
---
## Access Files
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/storage/access
**Description**: Understand the shipped public image route and how to add private file authorization.
The kit stores image object keys rather than permanent provider URLs. `useStorage` converts those keys into a URL handled by the application.
## Shipped behavior
The current route is `app/storage/[...path]/route.ts`:
```typescript filename="app/storage/[...path]/route.ts" lineNumbers
import { NextResponse } from 'next/server';
import { storageConfig } from '@/config/storage.config';
import { getSignedUrl } from '@/lib/storage';
export const GET = async (
_req: Request,
{ params }: { params: Promise<{ path: string[] }> }
) => {
const { path } = await params;
const [bucket, filePath] = path;
if (!(bucket && filePath)) {
return new Response('Invalid path', { status: 400 });
}
if (bucket === storageConfig.bucketNames.images) {
const signedUrl = await getSignedUrl(filePath, bucket, 60 * 60);
return NextResponse.redirect(signedUrl, {
headers: { 'Cache-Control': 'max-age=3600' }
});
}
return new Response('Not found', { status: 404 });
};
```
This route:
- Is public and does not read a session
- Allows only the configured images bucket
- Generates a signed `GetObject` URL that expires after one hour
- Caches the redirect for up to one hour
- Does not query a file record or verify user or organization ownership
Anyone who knows a valid key can request a signed download URL through this route. Keeping the bucket itself private prevents direct anonymous bucket access, but it does not make this application route private.
## Using `useStorage`
```tsx filename="components/user/user-avatar.tsx" lineNumbers
import { useStorage } from '@/hooks/use-storage';
export function Image({ imageKey }: { imageKey: string }) {
const src = useStorage(imageKey);
return (
);
}
```
For a local key, the hook returns:
```text
/storage/{NEXT_PUBLIC_IMAGES_BUCKET_NAME}/{imageKey}
```
If the value starts with `http`, the hook returns it unchanged. If the value is empty, it returns the optional fallback.
## Flat keys only
Although the route uses a catch-all segment, the shipped handler reads only the first two segments:
```typescript
const [bucket, filePath] = path;
```
The included avatar and logo components therefore use flat keys such as `user-id-uuid.png`. A nested key such as `users/user-id/avatar.png` will not be reconstructed by the current route. To support nested keys, change the handler to read `[bucket, ...filePath]` and join the remaining segments after validation.
## Signed URLs are not authorization
A signed URL is a temporary bearer credential. Anyone who receives it can use it until it expires. Signing a URL proves that your server authorized the storage operation, but the shipped public route does not decide whether the requester owns the object.
The included route is suitable for avatars and logos that are expected to be visible. Do not use it for private documents or tenant-confidential exports.
## Adding private file access
The following work is a customization. It is not included in either Pro repository.
1. Add a file metadata table with the object key, bucket, owner or organization ID, content type, byte size and lifecycle status.
2. Create object keys on the server from the authenticated user or active organization. Do not accept an unrestricted owner prefix from the client.
3. Replace the public image route for private files with a protected tRPC procedure or route handler.
4. Load the file record and verify current organization membership and resource permission before signing a short-lived download URL.
5. Use private cache headers or `no-store` for protected redirects.
6. Add rate limits, access logs and deletion cleanup for your requirements.
Keep public display images and private documents in separate buckets or separate route policies. This makes it harder to expose a private object through the convenience image route.
## Listing and deleting
The storage module exports only `getSignedUploadUrl` and `getSignedUrl`. It does not export the S3 client and there are no shipped list or delete procedures. Removing an avatar or organization logo clears the database reference but does not delete the object from storage.
Implement listing, deletion and orphan cleanup only after adding file metadata and ownership checks. Examples that refer to a `File` model, `filesTable`, `verifyFileAccess` or `storageService.getS3Client()` are custom designs rather than repository APIs.
---
## Overview
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/storage/overview
**Description**: Learn how the shipped S3-compatible image storage flow works.
The Pro Next.js Drizzle kit ships a focused image storage flow for avatars and organization logos. Both Pro kits use the same storage implementation.
## Upload flow
1. A signed-in user selects and crops an image in the browser.
2. The client calls `storage.signedUploadUrl` with an object key and the configured images bucket.
3. The protected tRPC procedure checks only that the bucket equals `storageConfig.bucketNames.images`.
4. `getSignedUploadUrl` validates the key syntax and returns a `PutObject` URL that expires after 60 seconds.
5. The browser uploads directly to the storage provider.
6. Better Auth stores the object key in the user `image` field or organization `logo` field.
The server does not create an ownership record for the object. It also does not derive the key from the authenticated user, enforce a size limit or inspect the uploaded bytes.
## Read flow
1. `useStorage(image)` returns `/storage/{imagesBucket}/{image}` for a local image key.
2. The public route reads the bucket and image key from the URL.
3. If the bucket matches the configured images bucket, it returns a redirect to a signed `GetObject` URL.
4. The signed URL and redirect cache both use a one-hour lifetime.
The route does not read the current session. It does not query the database or verify ownership. This is suitable for product images that are intended to be displayed wherever their key is known, not for confidential files.
The upload signer accepts keys containing slashes, but the current read route
destructures only the bucket and the first segment after it. Avatar and logo
keys must therefore be flat, such as 550e8400.png. If you need
nested keys such as users/123/avatar.png, update the route to
join all remaining path segments before requesting the object.
## Included functions
```typescript filename="lib/storage/s3.ts" lineNumbers
getSignedUploadUrl(path, bucket); // PutObject URL, 60 seconds
getSignedUrl(path, bucket, expiresIn); // GetObject URL
```
Both functions validate that a path:
- Is not absolute
- Contains no `..`, null byte or hidden path segment
- Uses only letters, numbers, hyphens, underscores, slashes and dots
Path validation prevents malformed object keys. It is not a user or organization authorization check.
## Current scope
| Capability | Shipped behavior |
| ---------------------------- | ---------------- |
| Avatar and logo upload | Included |
| Direct browser upload | Included |
| Authenticated upload signing | Included |
| Public image redirect | Included |
| Private file authorization | Not included |
| File ownership metadata | Not included |
| Listing and deletion APIs | Not included |
| Storage quota enforcement | Not included |
| Nested-key download routing | Not included |
Use the included integration as a starting point for public display images. Build a separate authorized download flow for private files.
---
## Setup
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/storage/setup
**Description**: Configure the shipped S3-compatible image storage integration.
The storage implementation uses `S3Client` from the AWS SDK with a configurable endpoint and `forcePathStyle: true`. Cloudflare R2 is the documented default. Other S3-compatible providers can work, but you must verify their endpoint, path-style support and CORS behavior.
## Environment variables
The repositories read these exact names. `S3_REGION` is optional and defaults to `auto`; the other values are required for the included image flow.
```env filename=".env" lineNumbers
S3_ACCESS_KEY_ID="your-access-key"
S3_SECRET_ACCESS_KEY="your-secret-key"
S3_ENDPOINT="https://your-s3-endpoint"
S3_REGION="auto"
NEXT_PUBLIC_IMAGES_BUCKET_NAME="your-images-bucket"
```
There is no `S3_BUCKET` environment variable in the shipped configuration. `NEXT_PUBLIC_IMAGES_BUCKET_NAME` supplies the only configured bucket name.
## Cloudflare R2
### 1. Create a bucket
1. Open the Cloudflare dashboard and select **R2 Object Storage**.
2. Create a bucket for avatars and organization logos.
3. Keep direct public bucket access disabled.
The application still exposes a public image redirect route. A private bucket stops direct anonymous requests to R2, but it does not add user or organization checks to `/storage/[...path]`.
### 2. Create credentials
Create an R2 API token with object read and write permission scoped to this bucket. The current code signs `PutObject` and `GetObject` operations. It does not need account-wide administration permission.
Copy the access key ID and secret access key when the token is created.
### 3. Configure the endpoint
```env filename=".env" lineNumbers
S3_ACCESS_KEY_ID="your-r2-access-key-id"
S3_SECRET_ACCESS_KEY="your-r2-secret-access-key"
S3_ENDPOINT="https://.r2.cloudflarestorage.com"
S3_REGION="auto"
NEXT_PUBLIC_IMAGES_BUCKET_NAME="my-app-images"
```
### 4. Configure CORS
Direct browser uploads are part of the shipped avatar and logo flow, so the bucket must allow `PUT` from every application origin you use.
```json filename="R2 CORS policy" lineNumbers
[
{
"AllowedOrigins": ["http://localhost:3000", "https://yourdomain.com"],
"AllowedMethods": ["GET", "PUT"],
"AllowedHeaders": ["Content-Type"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
```
Replace the example production origin. Do not use `*` for production origins unless your application intentionally accepts uploads from every website.
## AWS S3
Create a private bucket and credentials that are limited to the required object operations. A minimal starting policy for the shipped flow is:
```json filename="IAM policy" lineNumbers
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::YOUR_BUCKET/*"
}
]
}
```
Configure the same CORS origins and methods on the bucket, then set:
```env filename=".env" lineNumbers
S3_ACCESS_KEY_ID="your-aws-access-key-id"
S3_SECRET_ACCESS_KEY="your-aws-secret-access-key"
S3_ENDPOINT="https://s3.us-east-1.amazonaws.com"
S3_REGION="us-east-1"
NEXT_PUBLIC_IMAGES_BUCKET_NAME="my-app-images"
```
Use your bucket region in both the endpoint and `S3_REGION`.
## Other providers
DigitalOcean Spaces, MinIO and providers with an S3 gateway require the same five variables. Provider compatibility is not abstracted behind separate adapters. The single client in `lib/storage/s3.ts` always sets `forcePathStyle: true`, so change that option if your provider requires virtual-hosted bucket URLs.
For Supabase's S3 gateway, follow the [Supabase setup guide](/docs/starter-kits/pro-nextjs-drizzle/recipes/supabase-setup) and keep the public application route limitation in mind.
## Storage configuration
The bucket is exposed through `config/storage.config.ts`:
```typescript filename="config/storage.config.ts" lineNumbers
import { env } from '@/lib/env';
export const storageConfig = {
bucketNames: {
images: env.NEXT_PUBLIC_IMAGES_BUCKET_NAME ?? ''
}
} satisfies StorageConfig;
```
The S3 client and signing functions are in `lib/storage/s3.ts`. There is no `lib/storage/service.ts` or `storageService` object.
## Verify the complete flow
Generating a presigned URL happens locally and does not prove that the credentials or bucket are valid. Test the full shipped flow:
1. Start the application with the storage variables set.
2. Sign in and upload a user avatar or organization logo.
3. Confirm that the direct `PUT` request returns a successful status.
4. Confirm that the object key is saved to the user or organization record.
5. Request `/storage/{bucket}/{key}` and confirm that it redirects and displays the image.
Keep `{key}` to one URL segment with the shipped route. Although the signer
validates nested S3 keys, `/storage/[...path]` currently reads only the first
segment after the bucket. Extend that route before introducing folder-style
keys.
If the upload returns a signature error, verify the endpoint, region, clock and request content type. The current signer uses `image/jpeg`, while the included crop components send `image/png`. Some providers require those values to match.
---
## Upload Files
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/storage/upload
**Description**: Use the shipped image upload flow and understand the validation you must add for other files.
Before uploading images, [configure the storage provider](/docs/starter-kits/pro-nextjs-drizzle/storage/setup).
## Shipped upload flow
The repository includes 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-drizzle/storage/access).
---
## Tech Stack
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/tech-stack
**Description**: An overview of the modern and powerful technologies used in the Pro Next.js Drizzle starter kit.
We've selected the best-in-class tools to provide a professional, scalable, and high-performance foundation for your SaaS.
## Core Framework
- **[Next.js](https://nextjs.org/)**: The React framework for the web, using the App Router for modern features like Server Components and Streaming.
- **[TypeScript](https://www.typescriptlang.org/)**: For end-to-end type safety and an excellent developer experience.
## Database & Authentication
- **[Drizzle ORM](https://orm.drizzle.team/)**: A lightweight, high-performance TypeScript ORM for SQL databases.
- **[PostgreSQL](https://www.postgresql.org/)**: The world's most advanced open-source relational database.
- **[Better Auth](https://better-auth.com/)**: A robust and flexible authentication library for Next.js.
## API & State Management
- **[tRPC](https://trpc.io/)**: For building end-to-end type-safe APIs without the boilerplate.
- **[TanStack Query](https://tanstack.com/query/latest)**: For powerful data fetching, caching, and state management on the client.
## Styling & UI
- **[Tailwind CSS](https://tailwindcss.com/)**: A utility-first CSS framework for rapid UI development.
- **[shadcn/ui](https://ui.shadcn.com/)**: Beautifully designed components built with 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-drizzle/tests/e2e
**Description**: Test complete user flows and interactions with Playwright.
End-to-end (E2E) tests verify that your application works correctly from a user's perspective. They test complete user flows by simulating real user interactions in a browser.
The Pro Next.js Drizzle starter kit uses [Playwright](https://playwright.dev) for E2E testing. Playwright is a modern, reliable testing framework that supports multiple browsers and provides excellent debugging tools.
Playwright provides automatic waiting, network interception and useful
debugging tools. Playwright supports Chromium, Firefox and WebKit. The shipped
configuration runs Desktop Chrome only so the default suite stays focused and
reasonably fast.
## Why write E2E tests?
E2E tests verify that your application works correctly as a whole. They catch issues that unit tests might miss, such as:
- **Integration problems**: Issues between different parts of your application
- **User flow bugs**: Problems with complete user journeys
- **UI regressions**: Visual or interaction issues
- **Browser behavior**: Issues that only appear in a real browser
E2E tests are slower than unit tests, so use them strategically for critical user flows rather than trying to test everything.
## Configuration
The Playwright configuration is in `playwright.config.ts`:
```typescript filename="playwright.config.ts" lineNumbers
import path from 'node:path';
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
dotenv.config({ path: path.resolve(__dirname, '.env') });
const isCI = !!process.env.CI;
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: false,
forbidOnly: isCI,
retries: isCI ? 1 : 0,
workers: 1,
reporter: [['html']],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
video: {
mode: 'retain-on-failure',
size: { width: 640, height: 480 }
}
},
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome']
}
}
],
webServer: {
command: 'npm run build && npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !isCI,
stdout: 'pipe',
timeout: 180 * 1000
}
});
```
Key features:
- **Test directory**: Tests are located in `./tests/e2e`
- **Automatic server**: Builds and starts the app automatically
- **Video recording**: Records videos of failed tests
- **Trace viewer**: Captures traces for debugging failed tests
- **Deterministic execution**: Uses one worker because authenticated tests reset
shared database fixtures such as users, organizations and two-factor state
- **Configured browser**: Runs the Chromium project with the Desktop Chrome
device profile. Add Firefox or WebKit projects explicitly if your support
policy requires them
## Running E2E tests
### Run all E2E tests
```bash filename="Terminal" lineNumbers
npm run test:e2e
```
This runs all E2E tests. The app is automatically built and started before tests run.
### Run with UI mode
```bash filename="Terminal" lineNumbers
npm run test:e2e:ui
```
This opens Playwright's UI mode, which provides a visual interface for running and debugging tests.
### Run in debug mode
```bash filename="Terminal" lineNumbers
npm run test:e2e:debug
```
This opens Playwright Inspector, allowing you to step through tests and see what's happening.
### Run in headed mode
```bash filename="Terminal" lineNumbers
npm run test:e2e:headed
```
This runs tests with a visible browser window, useful for debugging visual issues.
### Setup Playwright
Install Playwright browsers (first time only):
```bash filename="Terminal" lineNumbers
npm run test:e2e:setup
```
## Writing E2E tests
### Example: Testing authentication pages
Here's an example of testing authentication pages:
```typescript filename="tests/e2e/auth.spec.ts" lineNumbers
import { expect, test } from '@playwright/test';
test.describe('Authentication Pages', () => {
test('sign-in page loads correctly', async ({ page }) => {
await page.goto('/auth/sign-in');
// Check page title
await expect(page).toHaveTitle(/Sign in/);
await expect(
page.getByText('Sign in to your account', { exact: true })
).toBeVisible();
// Check form elements
await expect(page.getByLabel('Email')).toBeVisible();
await expect(page.getByLabel('Password', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
// Check links
await expect(
page.getByRole('link', { name: 'Forgot password?' })
).toBeVisible();
await expect(page.getByRole('link', { name: 'Sign up' })).toBeVisible();
});
test('sign-up page loads correctly', async ({ page }) => {
await page.goto('/auth/sign-up');
// Check page title
await expect(page).toHaveTitle(/Create an account/);
await expect(
page.getByText('Create your account', { exact: true })
).toBeVisible();
});
});
```
### Common patterns
#### Navigation
```typescript filename="tests/e2e/navigation.spec.ts" lineNumbers
import { test } from '@playwright/test';
test('navigates to dashboard', async ({ page }) => {
await page.goto('/');
await page.click('text=Dashboard');
await expect(page).toHaveURL('/dashboard');
});
```
#### Form interactions
```typescript filename="tests/e2e/forms.spec.ts" lineNumbers
import { expect, test } from '@playwright/test';
test('fills out and submits form', async ({ page }) => {
await page.goto('/contact');
await page.fill('input[name="name"]', 'John Doe');
await page.fill('input[name="email"]', 'john@example.com');
await page.fill('textarea[name="message"]', 'Test message');
await page.click('button[type="submit"]');
await expect(page.locator('text=Message sent')).toBeVisible();
});
```
#### Waiting for elements
```typescript filename="tests/e2e/waiting.spec.ts" lineNumbers
import { expect, test } from '@playwright/test';
test('waits for dynamic content', async ({ page }) => {
await page.goto('/dashboard');
// Wait for data to load
await page.waitForSelector('text=Loading...', { state: 'hidden' });
// Check that data is displayed
await expect(page.locator('text=Total Users')).toBeVisible();
});
```
#### Assertions
```typescript filename="tests/e2e/assertions.spec.ts" lineNumbers
import { expect, test } from '@playwright/test';
test('checks various assertions', async ({ page }) => {
await page.goto('/');
// Check visibility
await expect(page.locator('h1')).toBeVisible();
// Check text content
await expect(page.locator('h1')).toHaveText('Welcome');
// Check URL
await expect(page).toHaveURL('http://localhost:3000/');
// Check element count
await expect(page.locator('button')).toHaveCount(3);
});
```
## Best practices
### Test user flows, not implementation
Focus on what users do, not how the code works. Test complete user journeys rather than individual components.
```typescript
// ✅ Good - tests user flow
test('user can sign up and access dashboard', async ({ page }) => {
await page.goto('/auth/sign-up');
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
});
// ❌ Not so good - tests implementation
test('calls signup API', async ({ page }) => {
// Testing API calls directly
});
```
### Use page object model for complex flows
For complex pages or flows, use the page object model to keep tests maintainable.
```typescript
class SignInPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/auth/sign-in');
}
async signIn(email: string, password: string) {
await this.page.fill('input[name="email"]', email);
await this.page.fill('input[name="password"]', password);
await this.page.click('button[type="submit"]');
}
}
test('user can sign in', async ({ page }) => {
const signInPage = new SignInPage(page);
await signInPage.goto();
await signInPage.signIn('test@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});
```
### Use data-testid for stable selectors
Use `data-testid` attributes for elements that are likely to change, making tests more resilient.
```typescript
// In your component
// In your test
await page.click('[data-testid="submit-button"]');
```
### Make shared state explicit
Prefer independent tests when a flow can create and remove its own data. The
shipped authenticated application suite is deliberately serial because it
shares deterministic users and resets mutable authentication state between
security scenarios.
The authenticated suite uses `tests/e2e/seed.mjs` to create test-only owner and
administrator accounts in the configured test database. It also resets mutable
security state such as TOTP enrollment before the relevant flow. Never point
the E2E environment at a development, staging or production database containing
real users.
Set `DATABASE_URL` in the local `.env` file to a disposable E2E database. The
seed script inserts predictable fixtures and is intentionally safe to rerun,
but it must never operate on customer data.
The seed is executed from `tests/e2e/application.spec.ts` before the authenticated
suite. Run it through Node with the test environment loaded when you need to
restore those fixtures manually:
```sh filename="Terminal" lineNumbers
node --env-file=.env tests/e2e/seed.mjs
```
Do not add a setup project or saved browser authentication state unless you also
change the tests to consume it. The current tests sign in through the UI so they
exercise the real authentication flow.
## Debugging failed tests
When a test fails, Playwright provides several tools to help debug:
### View test report
```bash filename="Terminal" lineNumbers
npx playwright show-report
```
This opens the HTML test report showing all test results, screenshots, and videos.
### Use trace viewer
The configuration captures a trace on the first retry. CI retries failures once,
so its failed-test artifacts can include a trace. Local runs use no retries;
enable tracing explicitly or reproduce the failure with debug mode when needed.
View a captured trace with:
```bash filename="Terminal" lineNumbers
npx playwright show-trace trace.zip
```
The trace viewer shows a timeline of all actions, network requests, and console logs.
### Videos and screenshots
The shipped configuration retains video for failed tests in `test-results/`.
Screenshots are not enabled by default. Add `screenshot: 'only-on-failure'` to
the Playwright `use` configuration if your CI artifacts should include them.
## Next steps
With E2E tests set up, you can now:
- **Test complete user flows** to ensure everything works together
- **Catch integration issues** before they reach production
- **Verify UI behavior** across different browsers
- **Debug failures** with powerful debugging tools
For faster feedback during development, use [Unit Tests](/docs/starter-kits/pro-nextjs-drizzle/tests/unit) to test individual functions and components.
---
## Overview
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/tests/overview
**Description**: Learn about the testing setup and how to write tests for your application.
The Pro Next.js Drizzle starter kit includes a comprehensive testing setup with unit tests and end-to-end (E2E) tests to help you build reliable applications.
## Testing Stack
The starter kit uses modern testing tools:
- **[Vitest](https://vitest.dev)**: Fast unit testing framework built on Vite
- **[Playwright](https://playwright.dev)**: Reliable end-to-end testing framework
- **[Testcontainers](https://testcontainers.com)**: For database integration tests
## Test Structure
Tests are organized in the `tests/` directory:
```
tests/
e2e/ # End-to-end tests (Playwright)
lib/ # Unit tests for utility functions
trpc/ # tRPC router tests
support/ # Test setup and utilities
```
## Running Tests
### Unit Tests
Run all unit tests:
```bash filename="Terminal" lineNumbers
npm run test:unit
```
Run tests in watch mode:
```bash filename="Terminal" lineNumbers
npm run test:watch
```
Generate coverage report:
```bash filename="Terminal" lineNumbers
npm run test:coverage
```
### E2E Tests
Run E2E tests:
```bash filename="Terminal" lineNumbers
npm run test:e2e
```
Run E2E tests with UI:
```bash filename="Terminal" lineNumbers
npm run test:e2e:ui
```
Run E2E tests in debug mode:
```bash filename="Terminal" lineNumbers
npm run test:e2e:debug
```
### Database Tests
Run database integration tests (requires Docker):
```bash filename="Terminal" lineNumbers
npm run test:db
```
## Test Configuration
### Vitest Configuration
The Vitest configuration is in `vitest.config.mts`:
- Uses `vite-tsconfig-paths` for path resolution
- Includes tests from `tests/**/*.{test,spec}.?(c|m)[jt]s?(x)` and `lib/**/*.test.ts`
- Excludes database tests unless `RUN_DB_TESTS=true`
- Uses Testcontainers for database tests when enabled
### Playwright Configuration
The Playwright configuration is in `playwright.config.ts`:
- Tests located in `./tests/e2e`
- Automatically builds and starts the app for testing
- Uses Chromium by default
- Includes video recording and tracing for failed tests
## Best Practices
### Write focused unit tests
Unit tests should test individual functions or components in isolation. Keep them fast and focused on specific behavior.
### Use E2E tests for user flows
E2E tests should cover complete user journeys, not individual components.
They're slower but catch integration issues.
### Test critical paths
Focus on testing the most important user flows and business logic. Don't try
to achieve 100% coverage.
### Keep tests maintainable
Write clear, readable tests that serve as documentation. If a test is hard to understand, refactor it.
## Next Steps
Ready to start writing tests? Check out the guides for [Unit Tests](/docs/starter-kits/pro-nextjs-drizzle/tests/unit) and [E2E Tests](/docs/starter-kits/pro-nextjs-drizzle/tests/e2e) to learn more.
---
## Unit Tests
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/tests/unit
**Description**: Write and run fast unit tests for individual functions and components with instant feedback.
Unit tests are a type of automated test where individual units or components are tested. The "unit" in "unit test" refers to the smallest testable parts of an application. These tests are designed to verify that each unit of code performs as expected.
The Pro Next.js Drizzle starter kit uses [Vitest](https://vitest.dev) as the unit testing framework. It's a blazing-fast test runner built on top of [Vite](https://vite.dev), designed for modern JavaScript and TypeScript projects.
If you've used [Jest](https://jestjs.io) before, you already know Vitest - it shares the same API. But Vitest is built for speed: native TypeScript support without transpilation, parallel test execution, and a smart watch mode that only re-runs tests affected by your changes.
The repository configures coverage, environment stubs, database isolation and
TypeScript path aliases for you. You can extend those defaults in
`vitest.config.mts` as the application grows.
## Why write unit tests?
Unit tests give you **fast, focused feedback** on small pieces of your code - individual functions, hooks, or components. Instead of debugging an entire page or flow, you can verify just the logic you care about in isolation.
They also act as **living documentation**: a good test tells you how a function is supposed to behave, which edge cases are important, and what assumptions the code makes. This makes it much easier to safely refactor or extend features later.
In the starter kit, unit tests are designed to be **cheap and quick to run**, so you can keep Vitest running in watch mode while you code. Every change you make is immediately checked, helping you catch regressions before they ever reach integration or end‑to‑end tests.
## Configuration
The Vitest configuration is in `vitest.config.mts`:
```typescript filename="vitest.config.mts" lineNumbers
import { defineConfig } from 'vitest/config';
const runDbTests = process.env.RUN_DB_TESTS === 'true';
export default defineConfig({
resolve: { tsconfigPaths: true },
test: {
coverage: { provider: 'v8' },
passWithNoTests: true,
watch: false,
testTimeout: 10_000,
exclude: [
'**/node_modules/**',
'**/dist/**',
'**/.next/**',
'**/e2e/**',
// Exclude database tests unless RUN_DB_TESTS is true
...(runDbTests
? []
: [
'**/organizations.test.ts',
'**/tests/trpc/routers/**',
'**/*db*.test.ts'
])
],
include: ['tests/**/*.{test,spec}.?(c|m)[jt]s?(x)', 'lib/**/*.test.ts'],
environment: 'node',
pool: runDbTests ? 'forks' : 'threads',
fileParallelism: !runDbTests,
sequence: { concurrent: !runDbTests },
globalSetup: runDbTests ? './tests/support/setup-global.ts' : undefined,
setupFiles: runDbTests
? ['./tests/support/setup-shared-db.ts']
: ['./tests/support/setup-env.ts']
}
});
```
Key features:
- **Path resolution**: Uses Vitest's native `tsconfigPaths` support for aliases
- **Coverage**: Uses v8 provider for code coverage
- **Database tests**: Optional Testcontainers suite using a shared PostgreSQL
container and an isolated schema (requires Docker)
- **Test locations**: Includes tests from `tests/` directory and `lib/**/*.test.ts` files
## Running tests
There are several ways to run unit tests:
### Run all tests
```bash filename="Terminal" lineNumbers
npm run test:unit
```
This runs all unit tests once and exits. Perfect for CI/CD pipelines.
### Watch mode
```bash filename="Terminal" lineNumbers
npm run test:watch
```
This starts Vitest in watch mode. As you edit files, only the affected tests are re-run, giving you fast feedback while you work.
### Code coverage
Generate a code coverage report:
```bash filename="Terminal" lineNumbers
npm run test:coverage
```
This runs all tests and generates a coverage report showing which lines, branches, and functions are covered by tests.
### Database tests
Run database integration tests (requires Docker):
```bash filename="Terminal" lineNumbers
npm run test:db
```
This starts one temporary PostgreSQL container through Testcontainers. Database
tests receive an isolated schema and truncate its tables before each test. The
global teardown stops the container after the run.
Start Docker Desktop or your Docker daemon before running this command. The
suite creates and destroys its own PostgreSQL container; it does not use the
database from your normal `DATABASE_URL`.
## Writing unit tests
### Example: Testing utility functions
Here's an example of testing a utility function:
```typescript filename="tests/lib/utils.test.ts" lineNumbers
import { describe, expect, it } from 'vitest';
import { capitalize, getInitials } from '@/lib/utils';
describe('capitalize', () => {
it('capitalizes the first letter of a word', () => {
expect(capitalize('hello')).toBe('Hello');
});
it('returns empty string if input is empty', () => {
expect(capitalize('')).toBe('');
});
it('capitalizes a single character', () => {
expect(capitalize('a')).toBe('A');
});
});
describe('getInitials', () => {
it('returns initials for a two-word name', () => {
expect(getInitials('John Doe')).toBe('JD');
});
it('handles single name', () => {
expect(getInitials('John')).toBe('J');
});
it('handles empty string', () => {
expect(getInitials('')).toBe('');
});
});
```
### Test structure
- **`describe`**: Groups related tests together
- **`it` or `test`**: Defines an individual test case
- **`expect`**: Makes assertions about the code being tested
### Common assertions
```typescript filename="tests/example.test.ts" lineNumbers
import { describe, expect, it } from 'vitest';
describe('Common assertions', () => {
it('checks equality', () => {
expect(1 + 1).toBe(2);
});
it('checks object equality', () => {
expect({ name: 'John' }).toEqual({ name: 'John' });
});
it('checks truthiness', () => {
expect(true).toBeTruthy();
expect(false).toBeFalsy();
});
it('checks for null/undefined', () => {
expect(null).toBeNull();
expect(undefined).toBeUndefined();
});
it('checks strings', () => {
expect('hello').toContain('ell');
expect('hello').toMatch(/^h/);
});
it('checks arrays', () => {
expect([1, 2, 3]).toContain(2);
expect([1, 2, 3]).toHaveLength(3);
});
it('checks errors', () => {
expect(() => {
throw new Error('test');
}).toThrow('test');
});
});
```
## Best practices
Unit tests should work **for you**, not the other way around. Focus on writing tests that make it easier to change code with confidence, not on satisfying arbitrary rules or reaching a magic number in a dashboard.
### Test behavior, not implementation
Focus on what the function does, not how it does it. This makes tests more resilient to refactoring.
```typescript
// ✅ Good - tests behavior
expect(capitalize('hello')).toBe('Hello');
// ❌ Not so good - tests implementation details
expect(capitalize.toString()).toContain('charAt');
```
### Keep tests focused
Each test should verify one specific behavior. If a test is checking multiple things, split it into multiple tests.
```typescript
// ✅ Good - focused test
it('capitalizes the first letter', () => {
expect(capitalize('hello')).toBe('Hello');
});
// ❌ Not so good - testing multiple things
it('handles various inputs', () => {
expect(capitalize('hello')).toBe('Hello');
expect(capitalize('')).toBe('');
expect(capitalize('a')).toBe('A');
});
```
### Use descriptive test names
Test names should clearly describe what is being tested.
```typescript
// ✅ Good - descriptive
it('returns empty string if input is empty', () => {
expect(capitalize('')).toBe('');
});
// ❌ Not so good - unclear
it('handles edge case', () => {
expect(capitalize('')).toBe('');
});
```
### Test edge cases
Don't just test the happy path. Test edge cases like empty strings, null values, and boundary conditions.
```typescript
describe('capitalize', () => {
it('handles normal input', () => {
expect(capitalize('hello')).toBe('Hello');
});
it('handles empty string', () => {
expect(capitalize('')).toBe('');
});
it('handles single character', () => {
expect(capitalize('a')).toBe('A');
});
});
```
### Code coverage is a guide, not a goal
Code coverage helps you find untested code, but it shouldn't be the primary goal. Focus on testing critical paths and edge cases, not achieving 100% coverage.
## Next steps
With unit tests set up, you can now:
- **Test utility functions** to ensure they work correctly
- **Test business logic** in isolation
- **Catch regressions** before they reach production
- **Refactor with confidence** knowing tests will catch breaking changes
Ready to test complete user flows? Check out the [E2E Tests](/docs/starter-kits/pro-nextjs-drizzle/tests/e2e) guide.
---
## Authentication
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/troubleshooting/authentication
**Description**: Learn about authentication troubles and their solutions.
Start with the first server-side error produced by the failed request. Browser
messages such as `Failed to fetch` often hide a database, environment or OAuth
configuration error logged by the server.
## Sign-up or sign-in fails
Check the shared prerequisites before changing authentication code:
1. Confirm the root `.env` contains a valid `DATABASE_URL` and a non-empty
`BETTER_AUTH_SECRET`.
2. Apply the committed schema with `npm run db:migrate`.
3. Restart `npm run dev` after changing environment variables.
4. Reproduce the request while watching the terminal that runs Next.js.
Use a unique `BETTER_AUTH_SECRET` in every deployed environment. Do not copy the
development value from `.env.example` into production.
## Production redirects to localhost
Better Auth receives its `baseURL` in `lib/auth/index.ts`. The value comes from
`getBaseUrl()` in `lib/utils.ts` in this order:
1. A Vercel branch URL for a non-staging Preview deployment.
2. `NEXT_PUBLIC_SITE_URL`.
3. Vercel's generated deployment URL.
4. `http://localhost:3000` when none of the values above exist.
Set `NEXT_PUBLIC_SITE_URL` to the final HTTPS production origin, without a path
or trailing route, then redeploy. Confirm the variable exists in the Production
environment rather than only Development or Preview.
## Google OAuth returns a callback error
Verify the configuration as one complete set:
1. Set both `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in the environment
where the deployment runs.
2. Register the exact callback URL
`https://yourdomain.com/api/auth/callback/google` in Google Cloud.
3. Register the matching application origin.
4. Keep `enableSocialLogin` enabled in `config/auth.config.ts` only when the
credentials are ready.
5. Confirm `google` remains in `lib/auth/oauth-providers.tsx` and in the
`socialProviders` configuration in `lib/auth/index.ts`.
Google compares callback URLs exactly. Scheme, hostname, port and path must all
match. A Vercel branch preview can use a different hostname from production, so
register that exact preview callback or test OAuth on a stable staging domain.
Use the [OAuth setup guide](/docs/starter-kits/pro-nextjs-drizzle/authentication/oauth)
to generate the callback URL for each environment.
## Request rejected because of its origin
`config/auth.config.ts` builds Better Auth's trusted origins from `getBaseUrl()`,
the configured site URL and Vercel deployment URLs. If a custom frontend origin
calls the auth API, add that exact HTTPS origin deliberately. Do not use a broad
wildcard for production origins.
After changing trusted origins, restart or redeploy the application and test
sign-in, sign-out and an authenticated request from the intended frontend.
---
## Codebase
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/troubleshooting/codebase
**Description**: Learn about codebase troubles and their solutions.
## Build errors or TypeScript errors
Make sure you've installed all dependencies:
```sh filename="Terminal" lineNumbers
npm install
```
If errors persist, try clearing the Next.js cache:
```sh filename="Terminal" lineNumbers
rm -rf .next
npm run build
```
## Port already in use
If port 3000 is already in use, you can change it:
```sh filename="Terminal" lineNumbers
PORT=3001 npm run dev
```
Or update the port in your `package.json` scripts.
## Module not found errors
If you're seeing module not found errors:
1. Make sure all dependencies are installed: `npm install`
2. Check that the import path is correct
3. Restart your development server
4. Clear the Next.js cache: `rm -rf .next`
---
## Customization
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/troubleshooting/customization
**Description**: Learn about customization troubles and their solutions.
## Tailwind styles not applying
If Tailwind styles aren't being applied:
1. Make sure the file is included in `app/globals.css` using `@source` directives
2. Check that `@import 'tailwindcss'` is present in your global CSS
3. Restart your development server after changing Tailwind configuration
4. Clear the Next.js cache: `rm -rf .next`
## Component styles not working
If component styles aren't working:
1. Verify that the component is importing the correct CSS
2. Check that Tailwind is properly configured
3. Make sure you're using the correct class names
4. Check for any CSS conflicts or overrides
## Theme not applying
If theme changes aren't working:
1. Check that the theme provider is wrapping your app
2. Verify theme configuration in your config files
3. Clear browser cache and cookies
4. Check that theme variables are correctly defined
---
## Database
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/troubleshooting/database
**Description**: Learn about database troubles and their solutions.
Use the first database error from the server log as the starting point. Do not
paste a complete `DATABASE_URL` into an issue or support message because it
contains credentials.
## The application cannot connect
Check the connection in this order:
1. Confirm `DATABASE_URL` exists in the root `.env` file for local development
or in the deployed environment's settings.
2. Restart the development server or redeploy after changing the value.
3. For the included local PostgreSQL service, run `npm run docker:up` and inspect
startup failures with `npm run docker:logs`.
4. For a managed database, confirm the hostname, database name, user, password
and required SSL query parameters with the provider.
5. Confirm the provider allows connections from the application environment.
Run `npm run db:studio` only after the connection is available. If Studio also
fails, diagnose the database connection before changing application code.
## Production queries are slow
Place the application and PostgreSQL database in nearby regions. Cross-region
network latency is paid on every query and transaction, but the exact impact
depends on the query count and provider network.
Measure a slow request in server traces or logs before changing regions. Check
whether it issues repeated queries, waits for a connection or spends most of
its time on one database operation. Moving regions does not fix missing indexes
or an inefficient query.
## A committed migration fails
Treat a production migration as a release operation:
1. Back up data you need before applying a schema change.
2. Confirm the deployment uses the intended `DATABASE_URL` without printing its
value.
3. Review the committed Drizzle migration files included with the release.
4. Confirm the database user can change the required schema objects.
5. Run `npm run db:migrate` once from CI or a one-off release task.
Do not replace a failed production migration with `npm run db:push`. Do not
delete committed migration history to make the current environment appear
clean. Resolve the reported migration or schema difference and test the fix on
a disposable copy first.
Follow the [Drizzle database guide](/docs/starter-kits/pro-nextjs-drizzle/database)
for the development workflow used to author new migrations.
---
## Troubleshooting
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/troubleshooting/overview
**Description**: Common troubles and their solutions.
Find answers to questions other developers have asked - it might be just what you need!
## Common Issues
### Environment variables from .env are not being loaded
Make sure you are running the `npm run dev` command from the root directory of your project (where the `package.json` file is located).
Note
Next.js automatically loads environment variables from `.env` when running the
development server. Make sure the file exists in the root directory and
contains all required variables.
Also make sure that the environment variable you are trying to access in your application is prefixed with `NEXT_PUBLIC_` if you want to use it in client-side code.
### Application is very slow in production
The most common reason for a slow application in production is the physical distance between the server or serverless functions and the database.
Make sure to deploy your application to a region that is close to your database.
For example when you are using Vercel, you can select the region of the Vercel serverless functions in the project settings under the **Functions** tab.
### Database connection issues
If you're experiencing database connection issues:
1. Verify your `DATABASE_URL` in `.env` is correct
2. Make sure your database is running and accessible
3. Check if your database allows connections from your IP address (for managed databases)
4. Verify the database credentials are correct
### Port already in use
If port 3000 is already in use, you can change it by setting the `PORT` environment variable:
```sh filename="Terminal" lineNumbers
PORT=3001 npm run dev
```
---
## tRPC
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/trpc
**Description**: Build end-to-end type-safe APIs with tRPC.
The Pro Next.js Drizzle starter kit uses **tRPC** for its API layer, providing seamless type safety between your server-side logic and client-side components.
## Architecture
Our tRPC setup is designed for performance and maintainability, with built-in support for authentication and organization-scoped data.
### Root Router
The root router is located in `trpc/routers/app.ts` and aggregates all feature-specific routers using lazy loading:
```typescript filename="trpc/routers/app.ts" lineNumbers
import { createTRPCRouter } from '@/trpc/init';
import { lazy } from '@trpc/server';
export const appRouter = createTRPCRouter({
admin: lazy(() => import('./admin')),
organization: lazy(() => import('./organization')),
user: lazy(() => import('./user')),
upload: lazy(() => import('./upload')),
contact: lazy(() => import('./contact'))
});
export type AppRouter = typeof appRouter;
```
Lazy Loading
Routers are lazy-loaded to improve initial bundle size and enable code
splitting.
## Procedures
We provide several base procedures to simplify development:
- **`publicProcedure`**: No authentication required
- **`protectedProcedure`**: Requires a valid user session
- **`protectedOrganizationProcedure`**: Requires a valid session and an active organization
### Example: Public Endpoint
```typescript filename="trpc/routers/public.ts" lineNumbers
import { createTRPCRouter, publicProcedure } from '@/trpc/init';
import { z } from 'zod';
export const publicRouter = createTRPCRouter({
health: publicProcedure.query(() => {
return { status: 'ok', timestamp: new Date() };
})
});
```
### Example: Protected Endpoint
```typescript filename="trpc/routers/user.ts" lineNumbers
import { createTRPCRouter, protectedProcedure } from '@/trpc/init';
import { z } from 'zod';
export const userRouter = createTRPCRouter({
getProfile: protectedProcedure.query(async ({ ctx }) => {
// ctx.user is guaranteed to exist
return ctx.user;
}),
updateProfile: protectedProcedure
.input(
z.object({
name: z.string().min(1).optional(),
email: z.string().email().optional()
})
)
.mutation(async ({ input, ctx }) => {
// Update user profile
return await updateUser(ctx.user.id, input);
})
});
```
### Example: Organization-Scoped Endpoint
```typescript filename="trpc/routers/organization.ts" lineNumbers
import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '@/lib/db';
import { leadTable } from '@/lib/db/schema';
export const organizationRouter = createTRPCRouter({
getLeads: protectedOrganizationProcedure.query(async ({ ctx }) => {
// ctx.organization is guaranteed to exist
return await db.query.leadTable.findMany({
where: eq(leadTable.organizationId, ctx.organization.id)
});
}),
createLead: protectedOrganizationProcedure
.input(
z.object({
name: z.string().min(1),
email: z.string().email()
})
)
.mutation(async ({ input, ctx }) => {
const [lead] = await db
.insert(leadTable)
.values({
...input,
organizationId: ctx.organization.id
})
.returning();
return lead;
})
});
```
## Client Usage
### React Hooks
On the client, use the `trpc` object to access your API procedures via React Query hooks.
```tsx filename="components/user-profile.tsx" lineNumbers
'use client';
import { trpc } from '@/trpc/client';
export function UserProfile() {
const { data: user, isLoading } = trpc.user.getProfile.useQuery();
if (isLoading) return
Loading...
;
if (!user) return
Not found
;
return
Hello, {user.name}!
;
}
```
### Mutations
For actions that modify data, use mutations.
```tsx filename="components/update-profile-form.tsx" lineNumbers
'use client';
import { trpc } from '@/trpc/client';
import { useForm } from 'react-hook-form';
export function UpdateProfileForm() {
const utils = trpc.useUtils();
const updateProfile = trpc.user.updateProfile.useMutation({
onSuccess: () => {
// Invalidate and refetch
utils.user.getProfile.invalidate();
}
});
const onSubmit = async (data: { name: string }) => {
await updateProfile.mutateAsync(data);
};
return ;
}
```
### Optimistic Updates
For better UX, use optimistic updates:
```tsx filename="components/optimistic-update.tsx" lineNumbers
'use client';
import { trpc } from '@/trpc/client';
export function OptimisticComponent() {
const utils = trpc.useUtils();
const updateMutation = trpc.user.updateProfile.useMutation({
onMutate: async (newData) => {
// Cancel outgoing refetches
await utils.user.getProfile.cancel();
// Snapshot previous value
const previous = utils.user.getProfile.getData();
// Optimistically update
utils.user.getProfile.setData(undefined, (old) => ({
...old!,
...newData
}));
return { previous };
},
onError: (err, newData, context) => {
// Rollback on error
utils.user.getProfile.setData(undefined, context?.previous);
},
onSettled: () => {
// Refetch to ensure consistency
utils.user.getProfile.invalidate();
}
});
// ... use mutation
}
```
## Server-Side Usage
### Prefetching
For better performance, prefetch data on the server in your Next.js Server Components.
```tsx filename="app/(saas)/dashboard/profile/page.tsx" lineNumbers
import { HydrateClient, trpc } from '@/trpc/server';
export default async function ProfilePage() {
// Prefetch data on the server
await trpc.user.getProfile.prefetch();
return (
);
}
```
### Server-Side Calls
You can also call tRPC procedures directly on the server:
```typescript filename="app/api/example/route.ts" lineNumbers
import { trpc } from '@/trpc/server';
export async function GET() {
const user = await trpc.user.getProfile();
return Response.json(user);
}
```
## Organization Scoping
The starter kit includes organization scoping to automatically filter data by organization:
```typescript filename="trpc/organization-scope.ts" lineNumbers
// Organization is automatically determined from the request context
// and made available in ctx.organization
```
When using `protectedOrganizationProcedure`, the organization is automatically determined from:
- The active organization ID stored in the session (`session.activeOrganizationId`)
- The active organization in the session
- Custom organization resolution logic
## Type Inference
Extract types from your procedures for use in your components.
```typescript filename="types/lead.ts" lineNumbers
import type { AppRouter } from '@/trpc/routers/app';
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
type RouterOutputs = inferRouterOutputs;
type RouterInputs = inferRouterInputs;
// Extract output type
export type Lead = RouterOutputs['organization']['getLeads'][number];
// Extract input type
export type CreateLeadInput = RouterInputs['organization']['createLead'];
```
## Error Handling
tRPC automatically handles errors and provides type-safe error handling:
```typescript filename="trpc/routers/example.ts" lineNumbers
import { createTRPCRouter, protectedProcedure } from '@/trpc/init';
import { TRPCError } from '@trpc/server';
export const exampleRouter = createTRPCRouter({
getData: protectedProcedure.query(async ({ ctx }) => {
const data = await fetchData();
if (!data) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Data not found'
});
}
return data;
})
});
```
```tsx filename="components/error-handling.tsx" lineNumbers
'use client';
import { trpc } from '@/trpc/client';
export function DataComponent() {
const { data, error, isLoading } = trpc.example.getData.useQuery();
if (error) {
if (error.data?.code === 'NOT_FOUND') {
return
Data not found
;
}
return
Error: {error.message}
;
}
if (isLoading) return
Loading...
;
return
{data}
;
}
```
## Best Practices
1. **Use appropriate procedures** - Choose `publicProcedure`, `protectedProcedure` or `protectedOrganizationProcedure` based on your needs
2. **Validate inputs** - Always use Zod schemas for input validation
3. **Handle errors** - Use `TRPCError` for consistent error handling
4. **Optimize queries** - Use prefetching and optimistic updates for better UX
5. **Type safety** - Leverage TypeScript inference for type safety
---
## Define Endpoint
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/trpc/define-endpoint
**Description**: Learn how to create new tRPC endpoints.
This guide shows you how to create new tRPC endpoints in your application. We'll create a complete CRUD example for a `posts` feature.
## Creating a Router
Create a new router file in `trpc/routers/`:
```typescript filename="trpc/routers/posts.ts" lineNumbers
import { protectedProcedure, router } from '@/trpc/init';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '@/lib/db';
import { postsTable } from '@/lib/db/schema';
export const postsRouter = router({
// Endpoints will go here
});
```
## List Posts (Query)
Create a query to list posts:
```typescript filename="trpc/routers/posts.ts" lineNumbers
list: protectedProcedure
.input(
z.object({
limit: z.number().min(1).max(100).default(10),
offset: z.number().min(0).default(0),
})
)
.query(async ({ input, ctx }) => {
const posts = await db.query.postsTable.findMany({
limit: input.limit,
offset: input.offset,
orderBy: (posts, { desc }) => [desc(posts.createdAt)],
});
return posts;
}),
```
## Create Post (Mutation)
Create a mutation to create a new post:
```typescript filename="trpc/routers/posts.ts" lineNumbers
create: protectedProcedure
.input(
z.object({
title: z.string().min(1).max(255),
content: z.string().min(1),
})
)
.mutation(async ({ input, ctx }) => {
const [post] = await db
.insert(postsTable)
.values({
title: input.title,
content: input.content,
authorId: ctx.session.user.id,
})
.returning();
return post;
}),
```
## Get Post by ID (Query)
Create a query to get a single post:
```typescript filename="trpc/routers/posts.ts" lineNumbers
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const post = await db.query.postsTable.findFirst({
where: eq(postsTable.id, input.id),
});
if (!post) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Post not found",
});
}
return post;
}),
```
## Update Post (Mutation)
Create a mutation to update a post:
```typescript filename="trpc/routers/posts.ts" lineNumbers
update: protectedProcedure
.input(
z.object({
id: z.string(),
title: z.string().min(1).max(255).optional(),
content: z.string().min(1).optional(),
})
)
.mutation(async ({ input, ctx }) => {
// Verify post exists and user is author
const existingPost = await db.query.postsTable.findFirst({
where: eq(postsTable.id, input.id),
});
if (!existingPost) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Post not found",
});
}
if (existingPost.authorId !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not the author of this post",
});
}
const [updatedPost] = await db
.update(postsTable)
.set({
title: input.title,
content: input.content,
updatedAt: new Date(),
})
.where(eq(postsTable.id, input.id))
.returning();
return updatedPost;
}),
```
## Delete Post (Mutation)
Create a mutation to delete a post:
```typescript filename="trpc/routers/posts.ts" lineNumbers
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
// Verify post exists and user is author
const existingPost = await db.query.postsTable.findFirst({
where: eq(postsTable.id, input.id),
});
if (!existingPost) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Post not found",
});
}
if (existingPost.authorId !== ctx.session.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not the author of this post",
});
}
await db.delete(postsTable).where(eq(postsTable.id, input.id));
return { success: true };
}),
```
## Complete Router Example
Here's the complete router:
```typescript filename="trpc/routers/posts.ts" lineNumbers
import { protectedProcedure, router } from '@/trpc/init';
import { TRPCError } from '@trpc/server';
import { desc, eq } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '@/lib/db';
import { postsTable } from '@/lib/db/schema';
export const postsRouter = router({
list: protectedProcedure
.input(
z.object({
limit: z.number().min(1).max(100).default(10),
offset: z.number().min(0).default(0)
})
)
.query(async ({ input }) => {
return await db.query.postsTable.findMany({
limit: input.limit,
offset: input.offset,
orderBy: [desc(postsTable.createdAt)]
});
}),
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const post = await db.query.postsTable.findFirst({
where: eq(postsTable.id, input.id)
});
if (!post) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Post not found'
});
}
return post;
}),
create: protectedProcedure
.input(
z.object({
title: z.string().min(1).max(255),
content: z.string().min(1)
})
)
.mutation(async ({ input, ctx }) => {
const [post] = await db
.insert(postsTable)
.values({
title: input.title,
content: input.content,
authorId: ctx.session.user.id
})
.returning();
return post;
}),
update: protectedProcedure
.input(
z.object({
id: z.string(),
title: z.string().min(1).max(255).optional(),
content: z.string().min(1).optional()
})
)
.mutation(async ({ input, ctx }) => {
const existingPost = await db.query.postsTable.findFirst({
where: eq(postsTable.id, input.id)
});
if (!existingPost) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
if (existingPost.authorId !== ctx.session.user.id) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
const [updatedPost] = await db
.update(postsTable)
.set({
title: input.title,
content: input.content,
updatedAt: new Date()
})
.where(eq(postsTable.id, input.id))
.returning();
return updatedPost;
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
const existingPost = await db.query.postsTable.findFirst({
where: eq(postsTable.id, input.id)
});
if (!existingPost) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
if (existingPost.authorId !== ctx.session.user.id) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
await db.delete(postsTable).where(eq(postsTable.id, input.id));
return { success: true };
})
});
```
## Adding Router to App Router
Add your new router to the main app router:
```typescript filename="trpc/routers/app.ts" lineNumbers
import { createTRPCRouter } from '@/trpc/init';
import { lazy } from '@trpc/server';
export const appRouter = createTRPCRouter({
admin: lazy(() => import('./admin')),
organization: lazy(() => import('./organization')),
user: lazy(() => import('./user')),
posts: lazy(() => import('./posts')) // Add your new router
// ... other routers
});
export type AppRouter = typeof appRouter;
```
## Using the Endpoint
### Client-Side
```tsx filename="components/posts-list.tsx" lineNumbers
'use client';
import { trpc } from '@/trpc/client';
export function PostsList() {
const { data: posts, isLoading } = trpc.posts.list.useQuery({
limit: 10,
offset: 0
});
if (isLoading) return
Loading...
;
return (
{posts?.map((post) => (
{post.title}
{post.content}
))}
);
}
```
### Server-Side
```tsx filename="app/(saas)/dashboard/posts/page.tsx" lineNumbers
import { HydrateClient, trpc } from '@/trpc/server';
export default async function PostsPage() {
await trpc.posts.list.prefetch({ limit: 10, offset: 0 });
return (
);
}
```
## Best Practices
1. **Use appropriate procedures** - Choose `publicProcedure`, `protectedProcedure`, or `protectedOrganizationProcedure`
2. **Validate inputs** - Always use Zod schemas for input validation
3. **Handle errors** - Use `TRPCError` with appropriate error codes
4. **Check permissions** - Verify user has access before operations
5. **Use returning()** - For Drizzle, use `.returning()` to get the created/updated record
6. **Type safety** - Let TypeScript infer types from your procedures
---
## Protect Endpoint
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/trpc/protect-endpoint
**Description**: Learn how to protect tRPC endpoints with authentication and authorization.
The starter kit provides several base procedures for protecting endpoints. Choose the appropriate procedure based on your security requirements.
## Available Procedures
### Public Procedure
No authentication required. Use for public endpoints:
```typescript filename="trpc/routers/public.ts" lineNumbers
import { createTRPCRouter, publicProcedure } from '@/trpc/init';
export const publicRouter = createTRPCRouter({
health: publicProcedure.query(() => {
return { status: 'ok', timestamp: new Date() };
})
});
```
### Protected Procedure
Requires a valid user session. The session and user are available in `ctx`:
```typescript filename="trpc/routers/user.ts" lineNumbers
import { createTRPCRouter, protectedProcedure } from '@/trpc/init';
export const userRouter = createTRPCRouter({
getProfile: protectedProcedure.query(async ({ ctx }) => {
// ctx.user and ctx.session are guaranteed to exist
return ctx.user;
})
});
```
### Protected Admin Procedure
Requires authentication AND admin role:
```typescript filename="trpc/routers/admin.ts" lineNumbers
import { createTRPCRouter, protectedAdminProcedure } from '@/trpc/init';
export const adminRouter = createTRPCRouter({
getAllUsers: protectedAdminProcedure.query(async ({ ctx }) => {
// ctx.user.role is guaranteed to be "admin"
return await getAllUsers();
})
});
```
### Protected Organization Procedure
Requires authentication AND an active organization. The organization is available in `ctx`:
```typescript filename="trpc/routers/organization.ts" lineNumbers
import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init';
export const organizationRouter = createTRPCRouter({
getData: protectedOrganizationProcedure.query(async ({ ctx }) => {
// ctx.organization is guaranteed to exist
// ctx.membership contains the user's role in the organization
return await getOrganizationData(ctx.organization.id);
})
});
```
## Custom Authorization
### Role-Based Access
Check user roles within a procedure:
```typescript filename="trpc/routers/example.ts" lineNumbers
import { createTRPCRouter, protectedProcedure } from '@/trpc/init';
import { TRPCError } from '@trpc/server';
export const exampleRouter = createTRPCRouter({
adminOnly: protectedProcedure.query(async ({ ctx }) => {
if (ctx.user.role !== 'admin') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Admin access required'
});
}
return { data: 'admin data' };
})
});
```
### Resource Ownership
Verify the user owns the resource:
```typescript filename="trpc/routers/posts.ts" lineNumbers
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { protectedProcedure } from "@/trpc/init";
import { db } from "@/lib/db";
import { postTable } from "@/lib/db/schema";
update: protectedProcedure
.input(z.object({ id: z.string(), title: z.string() }))
.mutation(async ({ input, ctx }) => {
const [post] = await db
.select()
.from(postTable)
.where(eq(postTable.id, input.id))
.limit(1);
if (!post) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Post not found"
});
}
// Check ownership
if (post.authorId !== ctx.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You can only edit your own posts",
});
}
// Update post
const [updatedPost] = await db
.update(postTable)
.set({ title: input.title })
.where(eq(postTable.id, input.id))
.returning();
return updatedPost;
}),
```
### Organization Membership
The `protectedOrganizationProcedure` automatically verifies organization membership. For additional checks:
```typescript filename="trpc/routers/organization.ts" lineNumbers
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { protectedOrganizationProcedure } from "@/trpc/init";
import { assertUserIsOrgMember } from "@/lib/auth/server";
getData: protectedOrganizationProcedure
.input(z.object({ organizationId: z.string() }))
.query(async ({ input, ctx }) => {
// Verify user is member (if different from active org)
if (input.organizationId !== ctx.organization.id) {
await assertUserIsOrgMember(input.organizationId, ctx.user.id);
}
return await getData(input.organizationId);
}),
```
### Plan-Based Access
Check if organization has required plan:
```typescript filename="trpc/routers/premium.ts" lineNumbers
import { TRPCError } from "@trpc/server";
import { protectedOrganizationProcedure } from "@/trpc/init";
import { requirePaidPlan, hasSpecificPlan } from "@/lib/billing";
premiumFeature: protectedOrganizationProcedure.query(async ({ ctx }) => {
// Option 1: Throw error if no paid plan
await requirePaidPlan(ctx.organization.id);
// Option 2: Check for specific plan (doesn't throw)
const hasProPlan = await hasSpecificPlan(ctx.organization.id, "pro");
if (!hasProPlan) {
throw new TRPCError({
code: "FORBIDDEN",
message: "This feature requires a Pro plan",
});
}
return { data: "premium content" };
}),
```
## Creating Custom Procedures
You can create custom procedures for common authorization patterns:
```typescript filename="trpc/procedures.ts" lineNumbers
import {
protectedOrganizationProcedure,
protectedProcedure
} from '@/trpc/init';
import { TRPCError } from '@trpc/server';
/**
* Procedure that requires user to have completed onboarding
*/
export const onboardedProcedure = protectedProcedure.use(
async ({ ctx, next }) => {
if (!ctx.user.onboardingComplete) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Please complete onboarding first'
});
}
return next({ ctx });
}
);
/**
* Procedure that requires organization admin role
*/
export const organizationAdminProcedure = protectedOrganizationProcedure.use(
async ({ ctx, next }) => {
const isAdmin =
ctx.membership.role === 'admin' || ctx.membership.role === 'owner';
if (!isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Organization admin access required'
});
}
return next({ ctx });
}
);
```
Usage:
```typescript filename="trpc/routers/example.ts" lineNumbers
import { createTRPCRouter } from '@/trpc/init';
import {
onboardedProcedure,
organizationAdminProcedure
} from '@/trpc/procedures';
import { z } from 'zod';
export const exampleRouter = createTRPCRouter({
// Requires onboarding
getDashboard: onboardedProcedure.query(async ({ ctx }) => {
return await getDashboardData(ctx.user.id);
}),
// Requires org admin
updateSettings: organizationAdminProcedure
.input(z.object({ settings: z.object({}) }))
.mutation(async ({ input, ctx }) => {
return await updateOrgSettings(ctx.organization.id, input.settings);
})
});
```
## Error Codes
Use appropriate TRPC error codes:
- **`UNAUTHORIZED`** - User is not authenticated
- **`FORBIDDEN`** - User is authenticated but lacks permission
- **`NOT_FOUND`** - Resource doesn't exist
- **`BAD_REQUEST`** - Invalid input
- **`INTERNAL_SERVER_ERROR`** - Server error
```typescript filename="trpc/routers/example.ts" lineNumbers
import { createTRPCRouter, protectedProcedure } from '@/trpc/init';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
export const exampleRouter = createTRPCRouter({
getResource: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const resource = await getResource(input.id);
if (!resource) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Resource not found'
});
}
// Check access
if (!hasAccess(resource, ctx.user)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: "You don't have access to this resource"
});
}
return resource;
})
});
```
## Best Practices
1. **Fail fast** - Check authentication and authorization early
2. **Use appropriate procedures** - Don't use `protectedProcedure` when `publicProcedure` is sufficient
3. **Verify ownership** - Always verify resource ownership before mutations
4. **Clear error messages** - Provide helpful error messages (but don't leak sensitive info)
5. **Log access attempts** - Log failed authorization attempts for security monitoring
---
## Usage in Frontend
**URL**: https://www.achromatic.dev/docs/starter-kits/pro-nextjs-drizzle/trpc/usage-frontend
**Description**: Learn how to use tRPC endpoints in your React components.
tRPC provides type-safe hooks for using your API in React components. All procedures are automatically typed based on your router definitions.
## Queries
Use `useQuery` for data fetching:
```tsx filename="components/user-profile.tsx" lineNumbers
'use client';
import { trpc } from '@/trpc/client';
export function UserProfile() {
const { data: user, isLoading, error } = trpc.user.getProfile.useQuery();
if (isLoading) return
Loading...
;
if (error) return
Error: {error.message}
;
if (!user) return
Not found
;
return
Hello, {user.name}!
;
}
```
### Query with Input
Pass input parameters to queries:
```tsx filename="components/post-detail.tsx" lineNumbers
'use client';
import { trpc } from '@/trpc/client';
export function PostDetail({ postId }: { postId: string }) {
const { data: post, isLoading } = trpc.posts.getById.useQuery({ id: postId });
if (isLoading) return
Loading...
;
if (!post) return
Post not found
;
return (
{post.title}
{post.content}
);
}
```
### Conditional Queries
Enable/disable queries conditionally:
```tsx filename="components/conditional-query.tsx" lineNumbers
'use client';
import { trpc } from '@/trpc/client';
export function ConditionalQuery({ enabled }: { enabled: boolean }) {
const { data } = trpc.posts.list.useQuery(
{ limit: 10 },
{ enabled } // Only fetch when enabled is true
);
return
{/* render data */}
;
}
```
## Mutations
Use `useMutation` for data modifications:
```tsx filename="components/create-post-form.tsx" lineNumbers
'use client';
import { trpc } from '@/trpc/client';
import { useForm } from 'react-hook-form';
export function CreatePostForm() {
const utils = trpc.useUtils();
const { handleSubmit } = useForm<{ title: string; content: string }>();
const createPost = trpc.posts.create.useMutation({
onSuccess: () => {
// Invalidate and refetch posts list
utils.posts.list.invalidate();
}
});
const onSubmit = async (data: { title: string; content: string }) => {
try {
await createPost.mutateAsync(data);
// Handle success
} catch (error) {
// Handle error
}
};
return ;
}
```
### 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