Next.js on Vercel
Create a Next.js app running on Bun, configure the development environment, and deploy to Vercel with automatic deployments on push.
Create the Next.js App
Initialize a new Next.js application:
bunx create-next-app@latest my-app --ts --tailwind --react-compiler --no-linter --src-dir --app --use-bun
cd my-appbunx create-next-app@latest my-app --ts --tailwind --react-compiler --no-linter --src-dir --app --use-bun
cd my-appThis command uses the following recommended options: TypeScript and Tailwind CSS for type safety and utility-first styling, enables the React Compiler for automatic optimizations, skips linter configuration (can be added later if needed), organizes code inside a src/ directory for cleaner project structure, uses the App Router, and bootstraps with Bun as the package manager.
Add Next.js & Vercel MCP Servers
Add MCP (Model Context Protocol) servers so your coding agent can debug Next.js and manage Vercel. bunx add-mcp updates all detected agents automatically; if none are detected, add -a cursor -a codex.
bunx add-mcp https://mcp.vercel.com
bunx add-mcp next-devtools-mcp@latestbunx add-mcp https://mcp.vercel.com
bunx add-mcp next-devtools-mcp@latest| Server | Description |
|---|---|
vercel | Manage Vercel projects, deployments, and search Vercel docs |
next-devtools | Next.js development tools for debugging, routing, and build info |
Vercel MCP: On first connection, Cursor will show a "Needs login" prompt. Click it to authorize access to your Vercel account. For project-specific context, use
https://mcp.vercel.com/<teamSlug>/<projectSlug>instead.
Install React & Design Agent Skills
Install skills that teach your coding agent React/Next.js patterns and web design best practices. bunx skills add updates all detected agents; if none are detected, add -a cursor -a codex.
bunx skills add vercel-labs/agent-skills -s web-design-guidelines -a cursor -a codex -y
bunx skills add vercel-labs/agent-skills -s vercel-react-best-practices -a cursor -a codex -ybunx skills add vercel-labs/agent-skills -s web-design-guidelines -a cursor -a codex -y
bunx skills add vercel-labs/agent-skills -s vercel-react-best-practices -a cursor -a codex -y| Skill | Description |
|---|---|
web-design-guidelines | Web design best practices for UI/UX |
vercel-react-best-practices | React patterns and conventions for Vercel apps |
Setup Vercel Configuration
Install the Vercel config package to programatically configure the Vercel project:
bun add -D @vercel/configbun add -D @vercel/configCreate the vercel.ts file:
import type { VercelConfig } from "@vercel/config/v1";
export const config: VercelConfig = {};import type { VercelConfig } from "@vercel/config/v1";
export const config: VercelConfig = {};Configure Bun as the Runtime on Vercel (Optional)
Using Bun both as the package manager and runtime provides a consistent development experience. To configure Bun as the runtime on Vercel, add the following to the vercel.ts file:
import type { VercelConfig } from "@vercel/config/v1";
export const config: VercelConfig = {
bunVersion: "1.x",
};import type { VercelConfig } from "@vercel/config/v1";
export const config: VercelConfig = {
bunVersion: "1.x",
};Add Bun types for better TypeScript support:
bun add -D @types/bunbun add -D @types/bunInstall GitHub CLI
Install the GitHub CLI to manage your GitHub repositories:
brew install ghbrew install ghLogin to your GitHub account:
gh auth logingh auth loginCreate GitHub Repository
Initialize git and create a new GitHub repository inside the project root:
# Create GitHub repository and push
gh repo create my-app --public --source=. --push# Create GitHub repository and push
gh repo create my-app --public --source=. --pushThe gh repo create command:
- Creates a new repository on GitHub
- Sets the remote origin
- Pushes your local code
Use --private instead of --public for a private repository.
Install Vercel CLI
Install the Vercel CLI globally to manage your Vercel projects:
bun add -g vercelbun add -g vercelAuthenticate with Vercel:
vercel loginvercel loginDeploy to Vercel
Link your project to Vercel and deploy:
# Deploy to Vercel (creates project on first run)
vercel# Deploy to Vercel (creates project on first run)
vercelOn first run, you'll be prompted to:
- Set up and deploy the project
- Link to an existing project or create a new one
- Configure project settings
Connect Git for Automatic Deployments
Connect your GitHub repository to enable automatic deployments on push:
vercel git connectvercel git connectThis links your local Git repository to your Vercel project, enabling:
- Automatic deployments on push to main branch
- Preview deployments for pull requests
- Deployment status checks on GitHub
Deployment Workflow
After initial setup, your workflow is:
- Develop locally:
bun run dev - Commit and push:
git push origin main - Automatic deployment: Vercel deploys on push
Code Health, Linting & Formatting
Configure Prettier for formatting, TypeScript for typechecking, and Fallow for code health (dead code, duplication, complexity, architecture drift). Skips ESLint/Biome to avoid config complexity.
Step 1: Install Prettier
bun add -D prettierbun add -D prettierStep 2: Install Fallow
Fallow is codebase intelligence for TypeScript and JavaScript. Use it to catch dead code (unused files, exports, types, dependencies), duplication, circular dependencies, complexity hotspots, and architecture drift. It is Rust-native, sub-second, and zero-config thanks to its framework plugins.
bun add -D fallowbun add -D fallowStep 3: Add scripts
Add these scripts to your package.json:
{
"scripts": {
"typecheck": "tsc --noEmit",
"fmt": "prettier --write .",
"fallow": "fallow"
}
}{
"scripts": {
"typecheck": "tsc --noEmit",
"fmt": "prettier --write .",
"fallow": "fallow"
}
}Run bun run fallow to analyze the whole project (dead code + duplication + health in one pass). Most projects need no configuration.
Step 4: Install the Fallow Skill
Install the fallow agent skill so your coding agent knows how to run and interpret Fallow's codebase intelligence (dead code, duplication, complexity, audit):
bunx skills add fallow-rs/fallow-skills -s fallow -a cursor -a codex -ybunx skills add fallow-rs/fallow-skills -s fallow -a cursor -a codex -yStep 5: Install VSCode Extension (Optional)
Install the Prettier VSCode extension for automatic formatting:
- Install in Cursor
- Or via VS Code command line:
ext install esbenp.prettier-vscode
Note: The extension may be marked as deprecated (replaced by prettier.prettier-vscode), however I've found that at least in Cursor esbenp.prettier-vscode works without issues while prettier.prettier-vscode has issues formatting .tsx files.
Step 6: Add .vscode Configuration (Optional)
Create a .vscode folder in your project root with the following files:
.vscode/extensions.json
Recommend the Prettier extension to all contributors:
{
// See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
// List of extensions which should be recommended for users of this workspace.
"recommendations": ["esbenp.prettier-vscode"],
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
"unwantedRecommendations": []
}{
// See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
// List of extensions which should be recommended for users of this workspace.
"recommendations": ["esbenp.prettier-vscode"],
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
"unwantedRecommendations": []
}.vscode/settings.json
Enable format on save with Prettier as the default formatter:
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}Step 7: Add .editorconfig (Optional)
Create a .editorconfig file in your project root. This is optional since Prettier already enforces these rules by default, but it ensures consistency when contributors use editors without setting up Prettier:
# Editor config - see http://EditorConfig.org
root = true
[*]
charset = utf-8
insert_final_newline = true
end_of_line = lf
indent_style = space
indent_size = 2
max_line_length = 80# Editor config - see http://EditorConfig.org
root = true
[*]
charset = utf-8
insert_final_newline = true
end_of_line = lf
indent_style = space
indent_size = 2
max_line_length = 80References
AI Coding Agent Configuration
Configure AI coding agents like Cursor, GitHub Copilot, or Claude Code with project-specific patterns, coding guidelines, and MCP servers for consistent AI-assisted development.
Step 1: Create an AGENTS.md file
Create an AGENTS.md file in your project root. This file provides coding guidelines and patterns for AI assistants to follow.
# Coding Guidelines
- Strictly follow the Functional Core, Imperative Shell pattern: separate application logic into two parts: a functional core with pure, side-effect-free functions for business rules and data transformation, and an imperative shell that handles impure actions like database I/O, network requests, or user input, making the core logic easily testable and modular
- Everything is a library: Organize features and domains as self-contained folders in `src/lib/` (e.g., `chat`, `ai`, `db`). Co-locate schema, queries, types, and utilities together. Components go in `components/<feature>/`.
- Use the web platform: Prefer native APIs and standards. Avoid abstractions that hide what the code actually does.
## Runtime and Package Manager
- Use Bun instead of Node.js, npm, pnpm, or vite.
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`.
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`.
## TypeScript
- Avoid `export default` in favor of `export` whenever possible.
- Only create an abstraction if it's actually needed
- Prefer clear function/variable names over inline comments
- Avoid helper functions when a simple inline expression would suffice
- Don't use emojis
- No barrel index files - just export from the source files instead
- No type.ts files, just inline types or co-locate them with their related code
- Don't unnecessarily add `try`/`catch`
- Don't cast to `any`
- Avoid `enum` and other TypeScript features that fail type stripping (e.g. namespaces, parameter properties); prefer `as const` objects/unions instead
## React
- Avoid massive JSX blocks and compose smaller components
- Colocate code that changes together
- Avoid `useEffect` unless absolutely needed
## Tailwind
- Mostly use built-in values, occasionally allow dynamic values, rarely globals
- Always use v4 + global CSS file format + shadcn/ui
## Next
- Prefer fetching data in RSC (page can still be static)
- Use next/font + next/script when applicable
- next/image above the fold should have `sync` / `eager` / use `priority` sparingly
- Be mindful of serialized prop size for RSC → child components
# Dev Workflow
Follow these steps for every feature. Do not skip verification steps. Repeat the full verification flow for every additional code change.
1. Start on the latest `main` unless instructed otherwise (`git checkout main && git pull`).
2. Make the changes.
3. Run `bun run typecheck` and `bun run fmt`. Fix all type errors before moving on.
4. Verify code health with `bun run fallow`. Fix every issue (dead code, duplication, complexity, etc.) or add a `fallow-ignore` suppression if you judge the warning a false positive. Verify the codebase is clean before moving on.
5. Verify the changes with `agent-browser`. Address all UI/UX concerns before moving on. Confirm everything looks great and works.
6. Commit and push to `main`. Pushing to `main` triggers the production deploy.
7. Check deployment logs. Fix any issues and redeploy via follow-up commits if anything breaks.
8. Check production with `agent-browser` to verify the changes shipped and look good in production too. Debug any issues via app logs and fix with follow-up commits.
9. For every additional code change, go through all verification steps again.
10. Once production looks good, report all changes.
## Delegating to Subagents
The orchestrating agent owns the intent-heavy, stateful, and destructive steps; delegate the noisy, verifiable, context-light steps to subagents with fresh context windows. This keeps the main context focused and avoids filling it with throwaway tokens (reports, screenshots, logs).
- **Keep in the main agent:** making the changes (step 2), committing and pushing (step 6), driving the per-change verification loop (step 9), and the final report (step 10). These hold the design intent or are deliberate/destructive actions that must be owned, not fired and forgotten.
- **Delegate to a "code health" subagent:** typecheck, format, and Fallow (steps 3–4). Hand it the diff; it fixes or suppresses issues and reports clean/dirty. This work is self-contained and burns tokens reading reports.
- **Delegate to a "verification" subagent:** `agent-browser` UI/UX checks against acceptance criteria (steps 5 and 8). It returns pass/fail plus screenshots instead of dumping navigation noise into the main window.
- **Delegate to a "deploy investigator" subagent:** reading deployment logs and diagnosing failures (step 7). It returns a root-cause summary rather than raw logs.
Give each subagent a self-contained brief with clear acceptance criteria, since it does not share the main conversation's history. Launch independent subagents in parallel when their tasks don't depend on each other.
## Dev Tooling
- Use `agent-browser` for interacting with browsers
- Use `fallow` to identify dead code and general code health. Don't blindly fix everything - if something seems fine you can always update the `.fallowrc.json` file or just ignore that specific report item.# Coding Guidelines
- Strictly follow the Functional Core, Imperative Shell pattern: separate application logic into two parts: a functional core with pure, side-effect-free functions for business rules and data transformation, and an imperative shell that handles impure actions like database I/O, network requests, or user input, making the core logic easily testable and modular
- Everything is a library: Organize features and domains as self-contained folders in `src/lib/` (e.g., `chat`, `ai`, `db`). Co-locate schema, queries, types, and utilities together. Components go in `components/<feature>/`.
- Use the web platform: Prefer native APIs and standards. Avoid abstractions that hide what the code actually does.
## Runtime and Package Manager
- Use Bun instead of Node.js, npm, pnpm, or vite.
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`.
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`.
## TypeScript
- Avoid `export default` in favor of `export` whenever possible.
- Only create an abstraction if it's actually needed
- Prefer clear function/variable names over inline comments
- Avoid helper functions when a simple inline expression would suffice
- Don't use emojis
- No barrel index files - just export from the source files instead
- No type.ts files, just inline types or co-locate them with their related code
- Don't unnecessarily add `try`/`catch`
- Don't cast to `any`
- Avoid `enum` and other TypeScript features that fail type stripping (e.g. namespaces, parameter properties); prefer `as const` objects/unions instead
## React
- Avoid massive JSX blocks and compose smaller components
- Colocate code that changes together
- Avoid `useEffect` unless absolutely needed
## Tailwind
- Mostly use built-in values, occasionally allow dynamic values, rarely globals
- Always use v4 + global CSS file format + shadcn/ui
## Next
- Prefer fetching data in RSC (page can still be static)
- Use next/font + next/script when applicable
- next/image above the fold should have `sync` / `eager` / use `priority` sparingly
- Be mindful of serialized prop size for RSC → child components
# Dev Workflow
Follow these steps for every feature. Do not skip verification steps. Repeat the full verification flow for every additional code change.
1. Start on the latest `main` unless instructed otherwise (`git checkout main && git pull`).
2. Make the changes.
3. Run `bun run typecheck` and `bun run fmt`. Fix all type errors before moving on.
4. Verify code health with `bun run fallow`. Fix every issue (dead code, duplication, complexity, etc.) or add a `fallow-ignore` suppression if you judge the warning a false positive. Verify the codebase is clean before moving on.
5. Verify the changes with `agent-browser`. Address all UI/UX concerns before moving on. Confirm everything looks great and works.
6. Commit and push to `main`. Pushing to `main` triggers the production deploy.
7. Check deployment logs. Fix any issues and redeploy via follow-up commits if anything breaks.
8. Check production with `agent-browser` to verify the changes shipped and look good in production too. Debug any issues via app logs and fix with follow-up commits.
9. For every additional code change, go through all verification steps again.
10. Once production looks good, report all changes.
## Delegating to Subagents
The orchestrating agent owns the intent-heavy, stateful, and destructive steps; delegate the noisy, verifiable, context-light steps to subagents with fresh context windows. This keeps the main context focused and avoids filling it with throwaway tokens (reports, screenshots, logs).
- **Keep in the main agent:** making the changes (step 2), committing and pushing (step 6), driving the per-change verification loop (step 9), and the final report (step 10). These hold the design intent or are deliberate/destructive actions that must be owned, not fired and forgotten.
- **Delegate to a "code health" subagent:** typecheck, format, and Fallow (steps 3–4). Hand it the diff; it fixes or suppresses issues and reports clean/dirty. This work is self-contained and burns tokens reading reports.
- **Delegate to a "verification" subagent:** `agent-browser` UI/UX checks against acceptance criteria (steps 5 and 8). It returns pass/fail plus screenshots instead of dumping navigation noise into the main window.
- **Delegate to a "deploy investigator" subagent:** reading deployment logs and diagnosing failures (step 7). It returns a root-cause summary rather than raw logs.
Give each subagent a self-contained brief with clear acceptance criteria, since it does not share the main conversation's history. Launch independent subagents in parallel when their tasks don't depend on each other.
## Dev Tooling
- Use `agent-browser` for interacting with browsers
- Use `fallow` to identify dead code and general code health. Don't blindly fix everything - if something seems fine you can always update the `.fallowrc.json` file or just ignore that specific report item.This
AGENTS.mdfile is based on Lee Robinson's original shared here.
Step 2: Symlink CLAUDE.md to AGENTS.md
Claude Code reads its instructions from CLAUDE.md. Create a symlink so it picks up the same guidelines from AGENTS.md, keeping a single source of truth:
ln -s AGENTS.md CLAUDE.mdln -s AGENTS.md CLAUDE.mdStep 3: Add MCP Servers
Use MCP (Model Context Protocol) servers to enhance your coding agent's capabilities. Add servers with bunx add-mcp <mcp url> -y — it updates all detected agents automatically, so no per-agent config files are required.
If no agents are detected, add -a cursor -a codex to the command or prompt the user to specify what agents they want to use for this project.
Add the context7 MCP server for up-to-date documentation lookup for any library:
bunx add-mcp https://mcp.context7.com/mcpbunx add-mcp https://mcp.context7.com/mcpStep 4: Install Browser Automation
Install the agent-browser package globally for web automation:
bun install -g agent-browser
agent-browser install # Download Chrome (first time only)bun install -g agent-browser
agent-browser install # Download Chrome (first time only)Step 5: Install Agent Skills
Install skills for AI agents:
bunx skills add anthropics/skills -s skill-creator -a cursor -a codex -y
bunx skills add vercel-labs/agent-browser -s agent-browser -a cursor -a codex -ybunx skills add anthropics/skills -s skill-creator -a cursor -a codex -y
bunx skills add vercel-labs/agent-browser -s agent-browser -a cursor -a codex -y| Skill | Description |
|---|---|
skill-creator | Anthropic skill creation guidelines |
agent-browser | Browser automation for web testing and interaction |
Shadcn UI & Theming
Add Shadcn UI components with dark mode support using next-themes. Includes theme provider and CSS variables configuration.
Step 1: Initialize Shadcn
bunx --bun shadcn@latest init --base radix --preset novabunx --bun shadcn@latest init --base radix --preset novaThe --base and --preset flags make this non-interactive. The CLI creates a components.json config file and sets up your CSS variables in globals.css. Available presets: nova, vega, maia, lyra, mira, luma, sera, rhea.
Step 2: Add components
Install all components:
bunx --bun shadcn@latest add --all --yesbunx --bun shadcn@latest add --all --yesNote: Shadcn is highly configurable. Omit --yes and follow the setup wizard to configure Shadcn to your liking.
Step 3: Add dark mode
Install the theme provider:
bun add next-themesbun add next-themesCreate the theme provider component:
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}Wrap your app with the provider in your layout:
import { ThemeProvider } from "@/components/themes/provider";
import { Toaster } from "@/components/ui/sonner";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
<Toaster richColors position="top-center" />
</ThemeProvider>
</body>
</html>
);
}import { ThemeProvider } from "@/components/themes/provider";
import { Toaster } from "@/components/ui/sonner";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
<Toaster richColors position="top-center" />
</ThemeProvider>
</body>
</html>
);
}Create the theme selector component to toggle between light, dark, and system themes:
"use client";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function ThemeSelector() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}"use client";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function ThemeSelector() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}References
Assertion Helper
TypeScript assertion function for runtime type narrowing with descriptive error messages. Based on tiny-invariant.
Install via shadcn registry
bunx --bun shadcn@latest add https://fullstackrecipes.com/r/assert.jsonWhy This Pattern?
- Type narrowing: TypeScript understands the assertion and narrows types after the check
- Descriptive errors: Know exactly what assertion failed and why
- Lazy messages: Defer expensive message construction until failure occurs
Usage
The assert function throws if the condition is falsy, and narrows the type when it passes:
import { assert } from "@/lib/common/assert";
function processUser(user: User | null) {
assert(user, "User must exist");
// TypeScript now knows `user` is `User`, not `User | null`
console.log(user.name);
}import { assert } from "@/lib/common/assert";
function processUser(user: User | null) {
assert(user, "User must exist");
// TypeScript now knows `user` is `User`, not `User | null`
console.log(user.name);
}Lazy Message Evaluation
For expensive message construction, pass a function that only runs on failure:
assert(
TOOL_TYPES.includes(part.type as ToolType),
() => `Invalid tool type: ${part.type}`,
);assert(
TOOL_TYPES.includes(part.type as ToolType),
() => `Invalid tool type: ${part.type}`,
);Attribution
This implementation is based on tiny-invariant.
References
Type-Safe Environment Configuration with better-env
Use better-env config modules for type-safe server/public env access, feature flags, and either-or credential constraints.
Type-Safe Environment Configuration with better-env
Use better-env/config-schema for typed environment configuration instead of maintaining a custom local env schema utility.
Install better-env
Install the package and the better-env agent skill, which teaches your coding agent better-env setup, sync, and validation workflows. The skill install updates all detected agents; if none are detected, add -a cursor -a codex.
bun add better-env
bunx skills add neon-solutions/better-env -a cursor -a codex -ybun add better-env
bunx skills add neon-solutions/better-env -a cursor -a codex -yDefine feature-level config modules
Create config modules in src/lib/*/config.ts.
import { configSchema, server } from "better-env/config-schema";
export const databaseConfig = configSchema("Database", {
url: server({ env: "DATABASE_URL" }),
});import { configSchema, server } from "better-env/config-schema";
export const databaseConfig = configSchema("Database", {
url: server({ env: "DATABASE_URL" }),
});Then consume values from your module instead of reading process.env directly:
import { Pool } from "pg";
import { databaseConfig } from "./config";
export const pool = new Pool({
connectionString: databaseConfig.server.url,
});import { Pool } from "pg";
import { databaseConfig } from "./config";
export const pool = new Pool({
connectionString: databaseConfig.server.url,
});Public values and feature flags
Use pub() for client-safe values and flag when a feature is optional.
import { configSchema, pub, server } from "better-env/config-schema";
export const sentryConfig = configSchema(
"Sentry",
{
token: server({ env: "SENTRY_AUTH_TOKEN" }),
dsn: pub({
env: "NEXT_PUBLIC_SENTRY_DSN",
value: process.env.NEXT_PUBLIC_SENTRY_DSN,
}),
project: pub({
env: "NEXT_PUBLIC_SENTRY_PROJECT",
value: process.env.NEXT_PUBLIC_SENTRY_PROJECT,
}),
},
{
flag: {
env: "NEXT_PUBLIC_ENABLE_SENTRY",
value: process.env.NEXT_PUBLIC_ENABLE_SENTRY,
},
},
);import { configSchema, pub, server } from "better-env/config-schema";
export const sentryConfig = configSchema(
"Sentry",
{
token: server({ env: "SENTRY_AUTH_TOKEN" }),
dsn: pub({
env: "NEXT_PUBLIC_SENTRY_DSN",
value: process.env.NEXT_PUBLIC_SENTRY_DSN,
}),
project: pub({
env: "NEXT_PUBLIC_SENTRY_PROJECT",
value: process.env.NEXT_PUBLIC_SENTRY_PROJECT,
}),
},
{
flag: {
env: "NEXT_PUBLIC_ENABLE_SENTRY",
value: process.env.NEXT_PUBLIC_ENABLE_SENTRY,
},
},
);Either-or credentials
Use oneOf when at least one credential must be configured.
import { configSchema, oneOf, server } from "better-env/config-schema";
export const aiConfig = configSchema(
"AI",
{
oidcToken: server({ env: "VERCEL_OIDC_TOKEN" }),
gatewayApiKey: server({ env: "AI_GATEWAY_API_KEY" }),
},
{
constraints: (s) => [oneOf([s.oidcToken, s.gatewayApiKey])],
},
);import { configSchema, oneOf, server } from "better-env/config-schema";
export const aiConfig = configSchema(
"AI",
{
oidcToken: server({ env: "VERCEL_OIDC_TOKEN" }),
gatewayApiKey: server({ env: "AI_GATEWAY_API_KEY" }),
},
{
constraints: (s) => [oneOf([s.oidcToken, s.gatewayApiKey])],
},
);Optional fields and schema validation
You can keep optional env vars and custom validation with Zod.
import { z } from "zod";
import { configSchema, server } from "better-env/config-schema";
export const resendConfig = configSchema("Resend", {
apiKey: server({ env: "RESEND_API_KEY" }),
fromEmail: server({
env: "RESEND_FROM_EMAIL",
schema: z
.string()
.regex(
/^.+\s<.+@.+\..+>$/,
'Must match "Name <email@domain.com>" format.',
),
}),
});import { z } from "zod";
import { configSchema, server } from "better-env/config-schema";
export const resendConfig = configSchema("Resend", {
apiKey: server({ env: "RESEND_API_KEY" }),
fromEmail: server({
env: "RESEND_FROM_EMAIL",
schema: z
.string()
.regex(
/^.+\s<.+@.+\..+>$/,
'Must match "Name <email@domain.com>" format.',
),
}),
});Build-Time Environment Validation with better-env
Validate all env-backed config modules with better-env before build and in CI.
Build-Time Environment Validation with better-env
Use better-env validate to fail fast when required environment variables are missing or invalid.
Install and initialize
bun add better-env
bun run env:initbun add better-env
bun run env:initenv:init runs bunx --bun better-env init -y, validates adapter prerequisites, and ensures your project is linked to the provider.
Add validation scripts
{
"scripts": {
"prebuild": "bun run env:validate:prod",
"env:validate": "bunx --bun better-env validate --environment=development",
"env:validate:prod": "bunx --bun better-env validate --environment=production"
}
}{
"scripts": {
"prebuild": "bun run env:validate:prod",
"env:validate": "bunx --bun better-env validate --environment=development",
"env:validate:prod": "bunx --bun better-env validate --environment=production"
}
}Validate locally
bun run env:validate
bun run env:validate:prodbun run env:validate
bun run env:validate:prodbetter-env validate:
- Loads
.env*files using Next.js semantics - Imports
src/lib/*/config.tsmodules - Runs
configSchemavalidation in each module - Reports missing or invalid values
- Warns about unused variables
Typical fix flow
When validation fails:
- Pull the latest variables:
bun run env:pull - Re-run validation:
bun run env:validate - Add/update missing values remotely via
bunx --bun better-env upsert ... - Pull + validate again
This replaces custom scripts/validate-env.ts scripts and keeps validation logic in the better-env CLI.
Neon + Drizzle Setup
Connect a Next.js app to Neon Postgres using Drizzle ORM with optimized connection pooling for Vercel serverless functions.
Step 1: Add the Neon MCP Server
bunx add-mcp https://mcp.neon.tech/mcp -ybunx add-mcp https://mcp.neon.tech/mcp -yThis updates all detected agents automatically. If no agents are detected, add -a opencode -a cursor to the command or prompt the user to specify what agents they want to use for this project.
Install the Neon Postgres agent skills so your coding agent knows Neon's operational patterns:
bunx skills add neondatabase/agent-skills -s neon-postgres -s neon-postgres-branches -s neon-postgres-egress-optimizer -a cursor -a codex -ybunx skills add neondatabase/agent-skills -s neon-postgres -s neon-postgres-branches -s neon-postgres-egress-optimizer -a cursor -a codex -y| Skill | Description |
|---|---|
neon-postgres | Neon Postgres setup and operational guidance |
neon-postgres-branches | Create and manage Neon Postgres branches |
neon-postgres-egress-optimizer | Optimize Neon Postgres egress and data transfer |
Step 2: Create a new Neon project
Use an existing Neon project or create a new one, either through the Neon Dashboard or by instructing your coding agent to create a new project or retrieve the connection string of an existing project.
Step 3: Get your Neon database URL
- Go to the Neon Dashboard
- Select your project
- Copy the connection string from the Connection Details widget
- Add it to your
.env.development:
DATABASE_URL="postgresql://user:password@ep-xxx.region.aws.neon.tech/neondb?sslmode=require"DATABASE_URL="postgresql://user:password@ep-xxx.region.aws.neon.tech/neondb?sslmode=require"Then sync to Vercel with bun run env:push. See Environment Variable Management for the full setup.
Tip: Use the pooled connection string for production workloads to improve performance and handle more concurrent connections.
Step 4: Create the database config
Instead of accessing process.env.DATABASE_URL directly, use the type-safe config pattern:
import { configSchema, server } from "better-env/config-schema";
export const databaseConfig = configSchema("Database", {
url: server({ env: "DATABASE_URL" }),
});import { configSchema, server } from "better-env/config-schema";
export const databaseConfig = configSchema("Database", {
url: server({ env: "DATABASE_URL" }),
});Then access via databaseConfig.server.url instead of process.env.DATABASE_URL. See the Environment Variable Management recipe for the full pattern.
Step 5: Validate config on server start
Import the config in instrumentation.ts to validate the environment variable when the server starts:
// Validate required configs on server start
import "./lib/db/config";// Validate required configs on server start
import "./lib/db/config";This ensures the server fails immediately on startup if DATABASE_URL is missing, rather than failing later when a database query runs.
Step 6: Install packages
bun add drizzle-orm pg @vercel/functions
bun add -D drizzle-kit @types/pg @next/envbun add drizzle-orm pg @vercel/functions
bun add -D drizzle-kit @types/pg @next/envThe @next/env package loads environment variables in the same order as Next.js, ensuring your .env.development and .env.local variables are available when running Drizzle Kit commands outside of the Next.js runtime.
Step 7: Create the database client
Create the Drizzle database client:
import { attachDatabasePool } from "@vercel/functions";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { databaseConfig } from "./config";
// Replace with your app's schemas
import * as authSchema from "@/lib/auth/schema";
import * as chatSchema from "@/lib/chat/schema";
const schema = {
...authSchema,
...chatSchema,
};
const pool = new Pool({
connectionString: databaseConfig.server.url,
});
attachDatabasePool(pool);
const db = drizzle({ client: pool, schema });
export { db };import { attachDatabasePool } from "@vercel/functions";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { databaseConfig } from "./config";
// Replace with your app's schemas
import * as authSchema from "@/lib/auth/schema";
import * as chatSchema from "@/lib/chat/schema";
const schema = {
...authSchema,
...chatSchema,
};
const pool = new Pool({
connectionString: databaseConfig.server.url,
});
attachDatabasePool(pool);
const db = drizzle({ client: pool, schema });
export { db };The databaseConfig import provides type-safe access to the DATABASE_URL environment variable. See the Environment Variable Management recipe for the config setup pattern.
Each feature library owns its own schema file (e.g., @/lib/auth/schema, @/lib/chat/schema). Instead of a central db/schema.ts aggregation file, schemas are imported directly in client.ts and merged into a single object for type-safe queries.
Step 8: Configure Drizzle Kit
Create the Drizzle Kit configuration in your project root:
// drizzle.config.ts
import { loadEnvConfig } from "@next/env";
loadEnvConfig(process.cwd());
import { defineConfig } from "drizzle-kit";
import { databaseConfig } from "./src/lib/db/config";
export default defineConfig({
schema: "./src/lib/*/schema.ts",
out: "./src/lib/db/migrations",
dialect: "postgresql",
dbCredentials: {
url: databaseConfig.server.url,
},
});// drizzle.config.ts
import { loadEnvConfig } from "@next/env";
loadEnvConfig(process.cwd());
import { defineConfig } from "drizzle-kit";
import { databaseConfig } from "./src/lib/db/config";
export default defineConfig({
schema: "./src/lib/*/schema.ts",
out: "./src/lib/db/migrations",
dialect: "postgresql",
dbCredentials: {
url: databaseConfig.server.url,
},
});The loadEnvConfig call at the top loads environment variables from .env.development, .env.local, and other .env files in the same order as Next.js. This ensures your DATABASE_URL is available when running Drizzle Kit commands like drizzle-kit generate or drizzle-kit migrate.
The schema glob pattern picks up schema.ts files from all feature libraries in src/lib/, following the "everything is a library" pattern where each feature owns its own schema. See Philosophy for more details.
Step 9: Add package.json scripts
Add these scripts to your package.json:
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
}
}{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
}
}Step 11: Generate and run migrations
bun run db:generate
bun run db:migratebun run db:generate
bun run db:migrateUnderstanding Connection Pooling
The attachDatabasePool helper from @vercel/functions is the key to efficient database connections on Vercel.
Why it matters:
- Without pooling: Each request opens a new TCP connection (~8 roundtrips), adding latency
- With pooling: The first request establishes a connection; subsequent requests reuse it instantly
- The helper:
attachDatabasePoolensures idle connections close gracefully before function suspension, preventing connection leaks
Info: Alternative Drivers
This recipe uses node-postgres (the pg package) because it provides the best performance on Vercel with Fluid compute. However, Drizzle supports other Postgres drivers:
| Driver | When to consider |
|---|---|
| postgres.js | If you prefer its API or need specific features like tagged template queries |
| Neon Serverless | For platforms without connection pooling (Netlify, Deno Deploy, Cloudflare Workers) |
Note: If you're deploying to a serverless platform that doesn't support connection pooling, the Neon Serverless driver connects over HTTP (~3 roundtrips) instead of TCP (~8 roundtrips), which is faster for single queries in classic serverless environments.
References
- Neon MCP Server
- Drizzle Postgres docs
- Drizzle Neon integration
- Vercel Connection Pooling Guide
- Neon + Vercel Connection Methods
AI SDK & Simple Chat
Install the Vercel AI SDK with AI Elements components. Build a streaming chat interface with the useChat hook.
Step 1: Install AI SDK v6
bun add ai @ai-sdk/reactbun add ai @ai-sdk/reactInstall the AI SDK agent skills so your coding agent knows the latest patterns. bunx skills add updates all detected agents; if none are detected, add -a cursor -a codex.
bunx skills add vercel/ai -s ai-sdk -a cursor -a codex -y
bunx skills add vercel/ai-elements --skill ai-elements -a cursor -a codex -ybunx skills add vercel/ai -s ai-sdk -a cursor -a codex -y
bunx skills add vercel/ai-elements --skill ai-elements -a cursor -a codex -y| Skill | Description |
|---|---|
ai-sdk | Vercel AI SDK patterns and best practices |
ai-elements | Vercel AI Elements UI components and patterns |
Step 2: Install AI Elements (optional)
AI Elements are pre-built UI components for AI interfaces:
bunx shadcn@latest add @ai-elements/allbunx shadcn@latest add @ai-elements/allThis adds components like:
- Chat bubbles and message lists
- Streaming text displays
- Loading indicators
- Code blocks with syntax highlighting
Step 3: Configure your AI provider
Option A: Using Vercel AI Gateway
The AI Gateway supports two authentication methods. Add one of these to your .env.development:
AI_GATEWAY_API_KEY="your-api-key-here"
VERCEL_OIDC_TOKEN="your-oidc-token"AI_GATEWAY_API_KEY="your-api-key-here"
VERCEL_OIDC_TOKEN="your-oidc-token"You can create an API key at Vercel AI Gateway and add it to your .env.development and sync to Vercel with bun run env:push.
Alternatively, you can get a Vercel OIDC token by logging in via the Vercel CLI:
vercel loginvercel loginThis will prompt you to authorize the Vercel CLI to access your Vercel account. Once authorized, you can run bun run env:pull to sync your environment variables, which will include the Vercel OIDC token.
At least one must be set when using the AI Gateway.
Option B: Using a specific provider
Install the provider SDK directly:
# OpenAI
bun add @ai-sdk/openai
# Anthropic
bun add @ai-sdk/anthropic
# Google
bun add @ai-sdk/google# OpenAI
bun add @ai-sdk/openai
# Anthropic
bun add @ai-sdk/anthropic
# Google
bun add @ai-sdk/googleAdd your API key to .env.development:
OPENAI_API_KEY="sk-..."
# or
ANTHROPIC_API_KEY="sk-ant-..."OPENAI_API_KEY="sk-..."
# or
ANTHROPIC_API_KEY="sk-ant-..."Step 4: Create the AI config
Create a type-safe config with either-or validation using the config schema pattern:
import { configSchema, server, oneOf } from "better-env/config-schema";
import { createOpenAI } from "@ai-sdk/openai";
import type { LanguageModelV1 } from "ai";
const config = configSchema(
"AI",
{
oidcToken: server({ env: "VERCEL_OIDC_TOKEN" }),
gatewayApiKey: server({ env: "AI_GATEWAY_API_KEY" }),
},
{
constraints: (s) => [oneOf([s.oidcToken, s.gatewayApiKey])],
},
);
function createProvider() {
const { oidcToken, gatewayApiKey } = config.server;
if (gatewayApiKey) {
return createOpenAI({
apiKey: gatewayApiKey,
baseURL: "https://api.openai.com/v1",
});
}
return createOpenAI({
apiKey: oidcToken,
baseURL: "https://api.vercel.ai/v1",
});
}
function getModel(model: string): LanguageModelV1 {
const provider = createProvider();
return provider(model);
}
export const aiConfig = {
...config,
getModel,
};import { configSchema, server, oneOf } from "better-env/config-schema";
import { createOpenAI } from "@ai-sdk/openai";
import type { LanguageModelV1 } from "ai";
const config = configSchema(
"AI",
{
oidcToken: server({ env: "VERCEL_OIDC_TOKEN" }),
gatewayApiKey: server({ env: "AI_GATEWAY_API_KEY" }),
},
{
constraints: (s) => [oneOf([s.oidcToken, s.gatewayApiKey])],
},
);
function createProvider() {
const { oidcToken, gatewayApiKey } = config.server;
if (gatewayApiKey) {
return createOpenAI({
apiKey: gatewayApiKey,
baseURL: "https://api.openai.com/v1",
});
}
return createOpenAI({
apiKey: oidcToken,
baseURL: "https://api.vercel.ai/v1",
});
}
function getModel(model: string): LanguageModelV1 {
const provider = createProvider();
return provider(model);
}
export const aiConfig = {
...config,
getModel,
};The oneOf constraint creates an either-or relationship: at least one of oidcToken or gatewayApiKey must be defined. See the Environment Variable Management recipe for the full config schema pattern.
Step 5: Validate config on server start
Import the config in instrumentation.ts to validate environment variables when the server starts:
// Validate required configs on server start
import "./lib/ai/config";// Validate required configs on server start
import "./lib/ai/config";This ensures the server fails immediately on startup if neither VERCEL_OIDC_TOKEN nor AI_GATEWAY_API_KEY is set, rather than failing later when AI features are used.
References
Build a Simple Chat
Create a basic chat interface with streaming responses.
Step 1: Create the API route
Create the chat API route:
import { convertToModelMessages, streamText, UIMessage } from "ai";
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: "anthropic/claude-sonnet-4.5",
system: "You are a helpful assistant.",
messages: await convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}import { convertToModelMessages, streamText, UIMessage } from "ai";
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: "anthropic/claude-sonnet-4.5",
system: "You are a helpful assistant.",
messages: await convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}Note: Replace the model string with your preferred model. See the AI SDK providers docs for available options.
Step 2: Create the chat page
Create the chat interface:
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useState } from "react";
export default function Page() {
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({
api: "/api/chat",
}),
});
const [input, setInput] = useState("");
return (
<div className="flex flex-col min-h-screen p-4 max-w-2xl mx-auto">
<div className="flex-1 space-y-4 pb-4">
{messages.map((message) => (
<div
key={message.id}
className={message.role === "user" ? "text-right" : "text-left"}
>
<span className="font-medium">
{message.role === "user" ? "You" : "AI"}:
</span>{" "}
{message.parts.map((part, index) =>
part.type === "text" ? (
<span key={index}>{part.text}</span>
) : null,
)}
</div>
))}
</div>
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
if (input.trim()) {
sendMessage({ text: input });
setInput("");
}
}}
>
<input
className="flex-1 px-3 py-2 border rounded-md"
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={status !== "ready"}
placeholder="Say something..."
/>
<button
className="px-4 py-2 bg-primary text-primary-foreground rounded-md disabled:opacity-50"
type="submit"
disabled={status !== "ready"}
>
Send
</button>
</form>
</div>
);
}"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useState } from "react";
export default function Page() {
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({
api: "/api/chat",
}),
});
const [input, setInput] = useState("");
return (
<div className="flex flex-col min-h-screen p-4 max-w-2xl mx-auto">
<div className="flex-1 space-y-4 pb-4">
{messages.map((message) => (
<div
key={message.id}
className={message.role === "user" ? "text-right" : "text-left"}
>
<span className="font-medium">
{message.role === "user" ? "You" : "AI"}:
</span>{" "}
{message.parts.map((part, index) =>
part.type === "text" ? (
<span key={index}>{part.text}</span>
) : null,
)}
</div>
))}
</div>
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
if (input.trim()) {
sendMessage({ text: input });
setInput("");
}
}}
>
<input
className="flex-1 px-3 py-2 border rounded-md"
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={status !== "ready"}
placeholder="Say something..."
/>
<button
className="px-4 py-2 bg-primary text-primary-foreground rounded-md disabled:opacity-50"
type="submit"
disabled={status !== "ready"}
>
Send
</button>
</form>
</div>
);
}Step 3: Test your chat
Start the development server:
bun run devbun run devOpen http://localhost:3000 and start chatting.
References
Drizzle Queries
Write type-safe database queries with Drizzle ORM. Covers select, insert, update, delete, relational queries, and adding new tables.
Install this skill so your agent retains these patterns for day-to-day work:
bunx skills add andrelandgraf/fullstackrecipes/skills -s drizzle-queriesbunx skills add andrelandgraf/fullstackrecipes/skills -s drizzle-queries