Step 1: Install the Neon MCP Server globally
bunx neonctl@latest initbunx neonctl@latest initNote: This installs the MCP server globally (not project-scoped) using your user API key. By default, the MCP server has write access to your Neon account.
For production apps in your organization, configure the MCP server to be read-only:
{
"mcpServers": {
"Neon": {
"url": "https://mcp.neon.tech/mcp",
"headers": {
"Authorization": "Bearer <$NEON_API_KEY>",
"x-read-only": "true"
}
}
}
}{
"mcpServers": {
"Neon": {
"url": "https://mcp.neon.tech/mcp",
"headers": {
"Authorization": "Bearer <$NEON_API_KEY>",
"x-read-only": "true"
}
}
}
}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 { loadConfig } from "@/lib/common/load-config";
export const databaseConfig = loadConfig({
server: {
url: process.env.DATABASE_URL,
},
});import { loadConfig } from "@/lib/common/load-config";
export const databaseConfig = loadConfig({
server: {
url: process.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.