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-appbunx create-next-app@latest my-app
cd my-appDuring setup, select:
- TypeScript: Yes
- ESLint: No
- Tailwind CSS: Yes
- App Router: Yes
Note: This guide uses Bun as the package manager. Replace
bunwithnpm,yarn, orpnpmif you prefer.
Step 2: Add Prettier and scripts
We use Prettier for code formatting and TypeScript for typechecking (no linter).
bun add -D prettierbun add -D prettierAdd these scripts to your package.json:
{
"scripts": {
"typecheck": "tsc --noEmit",
"fmt": "prettier --write ."
}
}{
"scripts": {
"typecheck": "tsc --noEmit",
"fmt": "prettier --write ."
}
}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 initbunx --bun shadcn@latest initFollow the prompts to configure your project. The CLI will:
- Create a
components.jsonconfig 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 --allbunx --bun shadcn@latest add --allOr add individual components as needed:
bunx --bun shadcn@latest add button card inputbunx --bun shadcn@latest add button card inputStep 3: Add dark mode (optional)
Install the theme provider:
bun add next-themesbun add next-themesCreate 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>;
}// 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>;
}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>
);
}// 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>
);
}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)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;
}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;
}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);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);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);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);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,
});// Before
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
// After
import { databaseConfig } from "./config";
const pool = new Pool({
connectionString: databaseConfig.url,
});Adding New Environment Variables
When adding a new feature that needs env vars:
- Create
src/lib/<feature>/config.tswith the Zod schema - Import and use it within that feature's lib
- 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);// 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);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);// src/lib/stripe/client.ts
import Stripe from "stripe";
import { stripeConfig } from "./config";
export const stripe = new Stripe(stripeConfig.secretKey);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),
});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),
});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.localcp .env.example .env.localStep 2: Get your Neon database URL
Option A: Using Vercel CLI (if your project is connected to Vercel)
vercel env pullvercel env pullThis pulls all environment variables from your Vercel project, including the DATABASE_URL set by the Neon integration.
Option B: From Neon Console
- Go to the Neon Dashboard
- Select your project
- Copy the connection string from the Connection Details widget
- Add it to your
.env.local:
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"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);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);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/pgnpm i drizzle-orm pg @vercel/functions
npm i -D drizzle-kit @types/pgStep 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"DATABASE_URL="postgresql://user:password@ep-xxx.region.aws.neon.tech/neondb?sslmode=require"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 };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 };Note: This uses the type-safe
serverConfigpattern instead of accessingprocess.envdirectly. 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";export * from "@/lib/chat/schema";
export * from "@/lib/stripe/schema";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,
},
});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,
},
});Step 6: Generate and run migrations
npx drizzle-kit generate
npx drizzle-kit migratenpx drizzle-kit generate
npx drizzle-kit 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
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:
| 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
- Drizzle PostgreSQL docs
- Drizzle Neon integration
- Vercel Connection Pooling Guide
- Neon + Vercel Connection Methods
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@betabun add ai@beta @ai-sdk/react@betaThe @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/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
Create an API key at Vercel AI Gateway and add it to your .env.local:
AI_GATEWAY_API_KEY="your-api-key-here"AI_GATEWAY_API_KEY="your-api-key-here"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.local:
OPENAI_API_KEY="sk-..."
# or
ANTHROPIC_API_KEY="sk-ant-..."OPENAI_API_KEY="sk-..."
# or
ANTHROPIC_API_KEY="sk-ant-..."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);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);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();
}// 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();
}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>
);
}// 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>
);
}Step 3: Test your chat
Start the development server:
bun run devbun run devOpen http://localhost:3000 and start chatting.
Info: Chat Status States
The status value from useChat indicates the current state:
| Status | Description |
|---|---|
ready | Idle, ready to send messages |
streaming | Currently receiving a response |
error | An error occurred |
Use these states to show loading indicators and disable inputs appropriately.