Back to recipes

Base App Setup

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

Create a Next.js App

Start with a fresh Next.js application using the latest version.

Step 1: Initialize the project

bunx create-next-app@latest my-app
cd my-app
bash

During setup, select:

  • TypeScript: Yes
  • ESLint: No
  • Tailwind CSS: Yes
  • App Router: Yes

Note: This guide uses Bun as the package manager. Replace bun with npm, yarn, or pnpm if you prefer.

Step 2: Add Prettier and scripts

We use Prettier for code formatting and TypeScript for typechecking (no linter).

bun add -D prettier
bash

Add these scripts to your package.json:

{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "fmt": "prettier --write ."
  }
}
json

References


Set Up Shadcn UI

Add beautiful, accessible components to your Next.js app with Shadcn UI.

Step 1: Initialize Shadcn

bunx --bun shadcn@latest init
bash

Follow the prompts to configure your project. The CLI will:

  • Create a components.json config file
  • Set up your CSS variables in globals.css
  • Configure path aliases

Step 2: Add components

Install all components at once:

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

Or add individual components as needed:

bunx --bun shadcn@latest add button card input
bash

Step 3: Add dark mode (optional)

Install the theme provider:

bun add next-themes
bash

Create a theme provider component:

// src/components/themes/provider.tsx
"use client";

import { ThemeProvider as NextThemesProvider } from "next-themes";

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  return <NextThemesProvider attribute="class">{children}</NextThemesProvider>;
}
tsx

Wrap your app with the provider in your root layout:

// src/app/layout.tsx
import { ThemeProvider } from "@/components/themes/provider";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}
tsx

References


Environment Variable Management

Type-safe environment variable validation using Zod with a modular config pattern.

Why This Pattern?

  • Type safety: Catch missing or invalid env vars at startup, not runtime
  • Modular: Each feature/lib owns its own config
  • Clear error messages: Know exactly which variable is missing
  • No global config: Import config directly from the lib that owns it

The goal is twofold: get full TypeScript autocompletion when accessing config values, and surface actionable error messages when configuration is missing or invalid.

When environment variables are missing, you get an error like this:

Error [InvalidConfigurationError]: Configuration validation error! Did you correctly set all required environment variables in .env file?
 - BETTER_AUTH_SECRET must be defined. (at path: secret)
 - BETTER_AUTH_URL must be defined. (at path: url)
    at validateConfig (src/lib/common/validate-config.ts:72:11)
    at module evaluation (src/lib/auth/config.ts:16:41)

This tells you exactly which variables are missing and where the validation failed, making it easy to fix configuration issues during development or deployment.

Step 1: Create config utilities

Create src/lib/common/validate-config.ts with shared helpers:

import { z } from "zod";

/**
 * Makes all properties potentially undefined, with special handling for string enums.
 * Used to type raw config objects before Zod validation since `process.env.*` returns
 * `string | undefined`.
 *
 * @example
 * ```ts
 * type Config = { url: string; port: number; nested: { key: string } };
 * type Raw = PreValidate<Config>;
 * // Result: { url: string | undefined; port: number | undefined; nested: { key: string | undefined } | undefined }
 * ```
 */
export type PreValidate<ConfigData> = {
  [K in keyof ConfigData]: ConfigData[K] extends object
    ? PreValidate<ConfigData[K]> | undefined
    : ConfigData[K] extends string
      ? string | undefined
      : ConfigData[K] | undefined;
};

/**
 * Error thrown when configuration validation fails.
 * Provides detailed error messages listing all missing or invalid environment variables.
 *
 * @example
 * ```
 * Error [InvalidConfigurationError]: Configuration validation error! Did you correctly set all required environment variables in .env file?
 *  - DATABASE_URL must be defined. (at path: url)
 *  - API_KEY must be defined. (at path: apiKey)
 * ```
 */
export class InvalidConfigurationError extends Error {
  constructor(issues: z.ZodError["issues"]) {
    let errorMessage =
      "Configuration validation error! Did you correctly set all required environment variables in .env file?";
    for (const issue of issues) {
      errorMessage = `${errorMessage}\n - ${issue.message} (at path: ${issue.path.join(".")})`;
    }
    super(errorMessage);
    this.name = "InvalidConfigurationError";
  }
}

/**
 * Validates a config object against a Zod schema.
 * Returns the validated and typed config, or throws `InvalidConfigurationError` if validation fails.
 *
 * @param schema - Zod schema defining the expected config shape and validation rules
 * @param config - Raw config object with values from `process.env`
 * @returns Validated config object with full type safety
 * @throws {InvalidConfigurationError} When any required env vars are missing or invalid
 *
 * @example
 * ```ts
 * // Define a schema for your feature's config
 * const DatabaseConfigSchema = z.object({
 *   url: z.string("DATABASE_URL must be defined."),
 *   poolSize: z.coerce.number().default(10),
 * });
 *
 * type DatabaseConfig = z.infer<typeof DatabaseConfigSchema>;
 *
 * // Create raw config from env vars (PreValidate allows undefined values)
 * const config: PreValidate<DatabaseConfig> = {
 *   url: process.env.DATABASE_URL,
 *   poolSize: process.env.DATABASE_POOL_SIZE,
 * };
 *
 * // Validate and export - throws at startup if DATABASE_URL is missing
 * export const databaseConfig = validateConfig(DatabaseConfigSchema, config);
 *
 * // Now use with full type safety
 * databaseConfig.url;      // string (guaranteed to exist)
 * databaseConfig.poolSize; // number (defaults to 10 if not set)
 * ```
 */
export function validateConfig<T extends z.ZodTypeAny>(
  schema: T,
  config: PreValidate<z.infer<T>>,
): z.infer<T> {
  const result = schema.safeParse(config);
  if (!result.success) {
    throw new InvalidConfigurationError(result.error.issues);
  }
  return result.data;
}
typescript

Step 2: Create module-level configs

Each feature lib defines its own config file. For example, src/lib/db/config.ts:

import { z } from "zod";
import { validateConfig, type PreValidate } from "../common/validate-config";

const DatabaseConfigSchema = z.object({
  url: z.string("DATABASE_URL must be defined."),
});

export type DatabaseConfig = z.infer<typeof DatabaseConfigSchema>;

const config: PreValidate<DatabaseConfig> = {
  url: process.env.DATABASE_URL,
};

export const databaseConfig = validateConfig(DatabaseConfigSchema, config);
typescript

Similarly for AI config in src/lib/ai/config.ts:

import { z } from "zod";
import { validateConfig, type PreValidate } from "../common/validate-config";

const AIConfigSchema = z.object({
  gatewayApiKey: z.string("AI_GATEWAY_API_KEY must be defined."),
});

export type AIConfig = z.infer<typeof AIConfigSchema>;

const config: PreValidate<AIConfig> = {
  gatewayApiKey: process.env.AI_GATEWAY_API_KEY,
};

export const aiConfig = validateConfig(AIConfigSchema, config);
typescript

Step 3: Use the config

Import config directly from the lib that owns it:

// Before
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

// After
import { databaseConfig } from "./config";

const pool = new Pool({
  connectionString: databaseConfig.url,
});
typescript

Adding New Environment Variables

When adding a new feature that needs env vars:

  1. Create src/lib/<feature>/config.ts with the Zod schema
  2. Import and use it within that feature's lib
  3. Access via <feature>Config.<variable>

Example for adding Stripe:

// src/lib/stripe/config.ts
import { z } from "zod";
import { validateConfig, type PreValidate } from "../common/validate-config";

const StripeConfigSchema = z.object({
  secretKey: z.string("STRIPE_SECRET_KEY must be defined."),
  webhookSecret: z.string("STRIPE_WEBHOOK_SECRET must be defined."),
});

export type StripeConfig = z.infer<typeof StripeConfigSchema>;

const config: PreValidate<StripeConfig> = {
  secretKey: process.env.STRIPE_SECRET_KEY,
  webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
};

export const stripeConfig = validateConfig(StripeConfigSchema, config);
typescript

Then use it in your Stripe client:

// src/lib/stripe/client.ts
import Stripe from "stripe";
import { stripeConfig } from "./config";

export const stripe = new Stripe(stripeConfig.secretKey);
typescript

Advanced Validation

Zod supports complex validations:

const ConfigSchema = z.object({
  // URL validation
  apiUrl: z.url("API_URL must be a valid URL."),

  // String length validation
  encryptionKey: z.string().length(64, "ENCRYPTION_KEY must be 64 characters."),

  // Optional with default
  nodeEnv: z.enum(["development", "production", "test"]).default("development"),

  // Transform string to number
  port: z.coerce.number().default(3000),
});
typescript

References


Set Up Neon Environment

Configure your Neon PostgreSQL connection for local development.

Step 1: Create a .env.local file

Create an environment file in your project root:

cp .env.example .env.local
bash

Step 2: Get your Neon database URL

Option A: Using Vercel CLI (if your project is connected to Vercel)

vercel env pull
bash

This pulls all environment variables from your Vercel project, including the DATABASE_URL set by the Neon integration.

Option B: From Neon Console

  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.local:
DATABASE_URL="postgresql://user:password@ep-xxx.region.aws.neon.tech/neondb?sslmode=require"
env

Tip: Use the pooled connection string for production workloads to improve performance and handle more concurrent connections.

Step 3: Add to your config (recommended)

Instead of accessing process.env.DATABASE_URL directly, use the type-safe config pattern. Create src/lib/db/config.ts:

import { z } from "zod";
import { validateConfig, type PreValidate } from "../config/utils";

const DatabaseConfigSchema = z.object({
  url: z.string("DATABASE_URL must be defined."),
});

export type DatabaseConfig = z.infer<typeof DatabaseConfigSchema>;

const config: PreValidate<DatabaseConfig> = {
  url: process.env.DATABASE_URL,
};

export const databaseConfig = validateConfig(DatabaseConfigSchema, config);
typescript

Then access via serverConfig.database.url instead of process.env.DATABASE_URL. See the Environment Variable Management recipe for the full pattern.


References


Neon + Drizzle Setup

Connect your Next.js app to a Neon PostgreSQL database using Drizzle ORM with optimized connection pooling for Vercel.

Step 1: Install packages

npm i drizzle-orm pg @vercel/functions
npm i -D drizzle-kit @types/pg
bash

Step 2: Add your connection string

Create a .env.local file with your Neon database URL:

DATABASE_URL="postgresql://user:password@ep-xxx.region.aws.neon.tech/neondb?sslmode=require"
env

Note: Get your connection string from the Neon console. Make sure to use the pooled connection string for production workloads.

Step 3: Create the database client

Create src/lib/db/client.ts:

import { attachDatabasePool } from "@vercel/functions";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { serverConfig } from "../config/server";
import * as schema from "./schema";

const pool = new Pool({
  connectionString: serverConfig.database.url,
});

attachDatabasePool(pool);

const db = drizzle({ client: pool, schema });

export { db };
typescript

Note: This uses the type-safe serverConfig pattern instead of accessing process.env directly. See the Environment Variable Management recipe for setup details.

Step 4: Create your schema file

Create src/lib/db/schema.ts to re-export all table definitions from your feature libs:

export * from "@/lib/chat/schema";
export * from "@/lib/stripe/schema";
typescript

Each feature lib owns its own schema. The central db/schema.ts just aggregates them for Drizzle.

Step 5: Configure Drizzle Kit

Create drizzle.config.ts in your project root:

import { defineConfig } from "drizzle-kit";
import { databaseConfig } from "./src/lib/db/config";

export default defineConfig({
  schema: "./src/lib/db/schema.ts",
  out: "./src/lib/db/migrations",
  dialect: "postgresql",
  dbCredentials: {
    url: databaseConfig.url,
  },
});
typescript

Step 6: Generate and run migrations

npx drizzle-kit generate
npx drizzle-kit migrate
bash

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

Best practices:

  • Define the pool at module scope (globally) so all requests share it
  • Keep minimum pool size at 1 for good concurrency
  • Use Vercel's Rolling releases to avoid connection surges during deployments

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 PostgreSQL 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


Set Up AI SDK

Install the Vercel AI SDK and AI Elements for building AI-powered features.

Step 1: Install AI SDK packages

bun add ai@beta @ai-sdk/react@beta
bash

The @beta tag installs AI SDK v6, which includes the latest features and improvements.

Step 2: Install AI Elements (optional)

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

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

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

Create an API key at Vercel AI Gateway and add it to your .env.local:

AI_GATEWAY_API_KEY="your-api-key-here"
env

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
bash

Add your API key to .env.local:

OPENAI_API_KEY="sk-..."
# or
ANTHROPIC_API_KEY="sk-ant-..."
env

Step 4: Add to your config (recommended)

Instead of accessing process.env.AI_GATEWAY_API_KEY directly, use the type-safe config pattern. Create src/lib/ai/config.ts:

import { z } from "zod";
import { validateConfig, type PreValidate } from "../config/utils";

const AIConfigSchema = z.object({
  gatewayApiKey: z.string("AI_GATEWAY_API_KEY must be defined."),
});

export type AIConfig = z.infer<typeof AIConfigSchema>;

const config: PreValidate<AIConfig> = {
  gatewayApiKey: process.env.AI_GATEWAY_API_KEY,
};

export const aiConfig = validateConfig(AIConfigSchema, config);
typescript

Then access via serverConfig.ai.gatewayApiKey instead of process.env.AI_GATEWAY_API_KEY. See the Environment Variable Management recipe for the full pattern.


References


Build a Simple Chat

Create a basic chat interface with streaming responses.

Step 1: Create the API route

Create a route handler that streams AI responses:

// src/app/api/chat/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: convertToModelMessages(messages),
  });

  return result.toUIMessageStreamResponse();
}
typescript

Note: Replace the model string with your preferred model. See the AI SDK providers docs for available options.

Step 2: Create the chat page

Build a client component that manages the chat state:

// src/app/page.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>
  );
}
tsx

Step 3: Test your chat

Start the development server:

bun run dev
bash

Open http://localhost:3000 and start chatting.


Info: Chat Status States

The status value from useChat indicates the current state:

StatusDescription
readyIdle, ready to send messages
streamingCurrently receiving a response
errorAn error occurred

Use these states to show loading indicators and disable inputs appropriately.


References