Back to recipes

Base App Setup

10 recipes

Complete setup guide for a Next.js app with Shadcn UI, Neon Postgres, Drizzle ORM, and AI SDK.

CookbooksStarter template

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:

bash
bunx create-next-app@latest my-app --ts --tailwind --react-compiler --no-linter --src-dir --app --use-bun
cd my-app

This 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.

bash
bunx add-mcp https://mcp.vercel.com
bunx add-mcp next-devtools-mcp@latest
ServerDescription
vercelManage Vercel projects, deployments, and search Vercel docs
next-devtoolsNext.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.

bash
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 -y
SkillDescription
web-design-guidelinesWeb design best practices for UI/UX
vercel-react-best-practicesReact patterns and conventions for Vercel apps

Setup Vercel Configuration

Install the Vercel config package to programatically configure the Vercel project:

bash
bun add -D @vercel/config

Create the vercel.ts file:

ts
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:

ts
import type { VercelConfig } from "@vercel/config/v1";

export const config: VercelConfig = {
  bunVersion: "1.x",
};

Add Bun types for better TypeScript support:

bash
bun add -D @types/bun

Install GitHub CLI

Install the GitHub CLI to manage your GitHub repositories:

bash
brew install gh

Login to your GitHub account:

bash
gh auth login

Create GitHub Repository

Initialize git and create a new GitHub repository inside the project root:

bash
# Create GitHub repository and push
gh repo create my-app --public --source=. --push

The 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:

bash
bun add -g vercel

Authenticate with Vercel:

bash
vercel login

Deploy to Vercel

Link your project to Vercel and deploy:

bash
# Deploy to Vercel (creates project on first run)
vercel

On 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:

bash
vercel git connect

This 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:

  1. Develop locally: bun run dev
  2. Commit and push: git push origin main
  3. 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

bash
bun add -D prettier

Step 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.

bash
bun add -D fallow

Step 3: Add scripts

Add these scripts to your package.json:

json
{
  "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):

bash
bunx skills add fallow-rs/fallow-skills -s fallow -a cursor -a codex -y

Step 5: Install VSCode Extension (Optional)

Install the Prettier VSCode extension for automatic formatting:

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:

json
{
  // 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:

json
{
  "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:

editorconfig
# 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

References


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.

md
# 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.md file 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:

bash
ln -s AGENTS.md CLAUDE.md

Step 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:

bash
bunx add-mcp https://mcp.context7.com/mcp

Step 4: Install Browser Automation

Install the agent-browser package globally for web automation:

bash
bun install -g agent-browser
agent-browser install  # Download Chrome (first time only)

Step 5: Install Agent Skills

Install skills for AI agents:

bash
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 -y
SkillDescription
skill-creatorAnthropic skill creation guidelines
agent-browserBrowser 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

bash
bunx --bun shadcn@latest init --base radix --preset nova

The --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:

bash
bunx --bun shadcn@latest add --all --yes

Note: 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:

bash
bun add next-themes

Create the theme provider component:

tsx
"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:

tsx
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:

tsx
"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

optional
bunx --bun shadcn@latest add https://fullstackrecipes.com/r/assert.json

Why 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:

typescript
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:

typescript
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.

bash
bun add better-env
bunx skills add neon-solutions/better-env -a cursor -a codex -y

Define feature-level config modules

Create config modules in src/lib/*/config.ts.

ts
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:

ts
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.

ts
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.

ts
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.

typescript
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

bash
bun add better-env
bun run env:init

env:init runs bunx --bun better-env init -y, validates adapter prerequisites, and ensures your project is linked to the provider.

Add validation scripts

json
{
  "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

bash
bun run env:validate
bun run env:validate:prod

better-env validate:

  1. Loads .env* files using Next.js semantics
  2. Imports src/lib/*/config.ts modules
  3. Runs configSchema validation in each module
  4. Reports missing or invalid values
  5. Warns about unused variables

Typical fix flow

When validation fails:

  1. Pull the latest variables: bun run env:pull
  2. Re-run validation: bun run env:validate
  3. Add/update missing values remotely via bunx --bun better-env upsert ...
  4. 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

bash
bunx add-mcp https://mcp.neon.tech/mcp -y

This 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:

bash
bunx skills add neondatabase/agent-skills -s neon-postgres -s neon-postgres-branches -s neon-postgres-egress-optimizer -a cursor -a codex -y
SkillDescription
neon-postgresNeon Postgres setup and operational guidance
neon-postgres-branchesCreate and manage Neon Postgres branches
neon-postgres-egress-optimizerOptimize 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

  1. Go to the Neon Dashboard
  2. Select your project
  3. Copy the connection string from the Connection Details widget
  4. Add it to your .env.development:
env
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:

ts
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:

ts
// 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

bash
bun add drizzle-orm pg @vercel/functions
bun add -D drizzle-kit @types/pg @next/env

The @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:

ts
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:

typescript
// 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:

json
{
  "scripts": {
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:studio": "drizzle-kit studio"
  }
}

Step 11: Generate and run migrations

bash
bun run db:generate
bun run db:migrate

Understanding Connection Pooling

The attachDatabasePool helper from @vercel/functions is the key to efficient database connections on Vercel.

Why it matters:

  1. Without pooling: Each request opens a new TCP connection (~8 roundtrips), adding latency
  2. With pooling: The first request establishes a connection; subsequent requests reuse it instantly
  3. The helper: attachDatabasePool ensures 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:

DriverWhen to consider
postgres.jsIf you prefer its API or need specific features like tagged template queries
Neon ServerlessFor 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


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

bash
bun add ai @ai-sdk/react

Install 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.

bash
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 -y
SkillDescription
ai-sdkVercel AI SDK patterns and best practices
ai-elementsVercel AI Elements UI components and patterns

Step 2: Install AI Elements (optional)

AI Elements are pre-built UI components for AI interfaces:

bash
bunx shadcn@latest add @ai-elements/all

This 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:

env
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:

bash
vercel login

This 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:

bash
# OpenAI
bun add @ai-sdk/openai

# Anthropic
bun add @ai-sdk/anthropic

# Google
bun add @ai-sdk/google

Add your API key to .env.development:

env
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:

ts
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:

ts
// 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:

ts
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:

tsx
"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:

bash
bun run dev

Open 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:

bash
bunx skills add andrelandgraf/fullstackrecipes/skills -s drizzle-queries