Hosted Databases
Serverless database platforms for modern applications — Neon (Postgres), PlanetScale (MySQL), Turso (SQLite/libSQL), and MongoDB Atlas (document DB) — with connection setup, ORM configuration, branching workflows, and edge deployment patterns for Next.js.
Hosted Databases
Serverless database platforms for modern applications — Neon (Postgres), PlanetScale (MySQL), Turso (SQLite/libSQL), and MongoDB Atlas (document DB) — with connection setup, ORM configuration, branching workflows, and edge deployment patterns for Next.js.
When to Use What
| Feature | Neon | PlanetScale | Turso | MongoDB Atlas |
|---|---|---|---|---|
| Database type | PostgreSQL | MySQL | SQLite / libSQL | MongoDB (document) |
| Free tier | 0.5 GiB storage, 1 project, 100 hrs compute | Hobby: 5 GiB, 1B row reads/mo | 500 databases, 9 GiB total, 500M rows read/mo | 512 MB storage, shared cluster |
| Serverless | Yes (scales to zero) | Yes (scales to zero) | Yes (scales to zero) | Yes (serverless instances) |
| Branching | Yes (copy-on-write, instant) | Yes (schema-only branches) | Yes (via database forking) | No |
| Connection pooling | Built-in (PgBouncer) | Built-in | N/A (HTTP/WebSocket) | Built-in (driver-level) |
| Edge support | Yes (@neondatabase/serverless over WebSocket) | Yes (@planetscale/database HTTP driver) | Yes (embedded replicas, HTTP) | Limited (TCP only, no edge driver) |
| ORM support | Prisma, Drizzle, Kysely, raw pg | Prisma, Drizzle, Kysely, raw mysql2 | Drizzle, raw @libsql/client | Prisma, Mongoose, raw driver |
| Migrations | ORM-managed (Prisma Migrate, Drizzle Kit) | Built-in safe migrations (non-blocking DDL) | ORM-managed (Drizzle Kit) | Schema-less (Mongoose schemas, Prisma) |
| Best for | General-purpose Postgres, preview branches, serverless | MySQL workloads, safe schema changes at scale | Edge-first, local-first, read-heavy global apps | Flexible schemas, content systems, full-text search |
Decision Guide
Pick Neon when you want PostgreSQL (which should be your default). Neon gives you instant branching for preview deployments, true scale-to-zero serverless, and an edge-compatible WebSocket driver. If you are starting a new project and have no strong reason to pick something else, start with Neon.
Pick PlanetScale when you need MySQL specifically (legacy compatibility, WordPress migrations, existing MySQL expertise) or you work on a team that needs non-blocking schema migrations with a deploy-request review workflow. PlanetScale's migration safety net is unmatched.
Pick Turso when you are building an edge-first application that needs reads at every global edge location, a local-first app that benefits from embedded replicas, or your workload is overwhelmingly read-heavy. Turso shines when you need data close to users.
Pick MongoDB Atlas when your data is inherently document-shaped (CMS content, product catalogs, event logs), your schema changes frequently, or you need Atlas Search for full-text search without managing a separate Elasticsearch cluster.
Principles
1. Serverless Databases Change the Economics
Traditional databases run 24/7, billing by the hour. Serverless databases bill by usage — compute time, rows read, storage consumed. This changes architecture decisions:
- Development databases cost nothing. Branch per PR, spin up test databases freely, tear them down automatically.
- Low-traffic apps are nearly free. A side project with 100 daily users costs pennies per month.
- Cold starts are real. Neon's scale-to-zero means ~300-500ms on the first query after inactivity. Design your app to tolerate this (warm in middleware, use pooling).
- Cost spikes from bad queries are real. A missing index on a serverless database multiplies your bill. Rows scanned and compute time both increase.
2. Connection Pooling Is Not Optional
Serverless functions create a new connection per invocation. Without pooling, you exhaust your database's connection limit within minutes under moderate traffic.
- Neon: Built-in PgBouncer. Use the pooled connection string (
-poolerin hostname) for app queries. Use direct connection for migrations only. - PlanetScale:
@planetscale/databaseHTTP driver avoids TCP entirely, sidestepping the problem. - Turso: Uses HTTP/WebSocket natively — no TCP pool needed. Embedded replicas read from a local SQLite file.
- MongoDB Atlas: Node.js driver manages a pool internally. Set
maxPoolSizeto 10-20 for serverless (default 100 is too high).
# .env — Neon connection strings
# Pooled (app queries):
DATABASE_URL="postgresql://user:pass@ep-cool-name-123456-pooler.us-east-2.aws.neon.tech/neondb?sslmode=require"
# Direct (migrations only):
DIRECT_URL="postgresql://user:pass@ep-cool-name-123456.us-east-2.aws.neon.tech/neondb?sslmode=require"3. Branching Replaces Staging Databases
Database branching is the most impactful feature of modern hosted databases. Instead of maintaining a shared staging database that drifts from production, create isolated branches:
- Neon branching: Copy-on-write. Instant full copy (schema + data) that shares storage with parent until writes diverge. Branch per PR, connect preview deployments, delete when merged.
- PlanetScale branching: Schema-only. Create a "deploy request" (like a PR for your schema) that gets reviewed and merged with zero downtime.
- Turso forking: Independent copy. Not copy-on-write, so large databases take longer. Best for testing schema changes.
4. Edge Databases Need Different Drivers
Edge runtimes (Cloudflare Workers, Vercel Edge Functions) do not support raw TCP. You need HTTP or WebSocket drivers.
| Database | Edge Driver | Protocol |
|---|---|---|
| Neon | @neondatabase/serverless | WebSocket / HTTP |
| PlanetScale | @planetscale/database | HTTP (Fetch API) |
| Turso | @libsql/client | HTTP / WebSocket |
| MongoDB Atlas | None | TCP only |
If you need edge deployment and a document database, use Turso or Neon with JSONB columns instead of MongoDB Atlas.
5. ORM Configuration Differs Per Provider
Prisma and Drizzle are the two TypeScript ORMs that matter. Key differences per provider:
- Prisma + PlanetScale requires
relationMode = "prisma"— PlanetScale/Vitess does not support foreign key constraints. - Prisma + Neon needs separate
url(pooled) anddirectUrl(direct) in the datasource. - Drizzle + Turso uses the
@libsql/clientadapter, not a traditional SQL driver. - Prisma + MongoDB uses
@db.ObjectIdfor IDs andtype(notmodel) for embedded documents.
6. Migrations Must Run in CI/CD, Not at Function Startup
In serverless, there is no single server to run migrations from. Multiple function instances may start simultaneously.
- Run migrations in your CI/CD pipeline as a build step before deployment.
- Use the direct (non-pooled) connection for migrations — pooled connections interfere with DDL and advisory locks.
- PlanetScale handles this natively via deploy requests.
- Never run
prisma migrate devin production. Useprisma migrate deploy.
# .github/workflows/deploy.yml
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20" }
- run: npm ci
- run: npx prisma migrate deploy
env:
DATABASE_URL: ${{ secrets.DIRECT_DATABASE_URL }}
- run: vercel deploy --prod --token=${{ secrets.VERCEL_TOKEN }}7. Monitor What Matters
Serverless databases abstract infrastructure but not performance. Monitor:
- Query latency (p50, p95, p99). Alert on p95 > 200ms.
- Connection count. Even with pooling, you can saturate the pool.
- Rows read / compute time. These are your billing metrics. A bad query can spike your bill overnight.
- Cold start frequency. Consider keeping minimum compute active in Neon if cold starts hurt UX.
- Storage growth. Clean up old branches — diverged data accumulates.
LLM Instructions
Neon (Serverless Postgres)
- Always use the pooled connection string (
-poolerin hostname) for app code. Direct string only for migrations. - Always configure
directUrlin Prisma schema alongsideurl. - Use
@neondatabase/serverlessfor edge runtimes. Standardpgor Prisma for Node.js. - Enable
pgvectorextension if the project involves embeddings or vector search. - Recommend Neon as the default for any new Postgres project.
- For Drizzle, use
drizzle-orm/neon-httpadapter in edge,drizzle-orm/node-postgresin Node.js.
PlanetScale (Serverless MySQL)
- Always set
relationMode = "prisma"in Prisma schema. - Never expect database-level cascade deletes. With
relationMode = "prisma", cascades are handled by Prisma Client only. - Use
@planetscale/databasefor edge runtimes. - Guide users through deploy request workflow for schema changes.
- Add
@@indexdirectives for every relation field. Without FK constraints, indexes are not auto-created.
Turso (Edge SQLite / libSQL)
- Always use
@libsql/client— notbetter-sqlite3orsql.js. - Configure embedded replicas for fast edge reads (local SQLite file syncing from remote).
- Use HTTP mode for serverless, WebSocket for long-lived servers, file mode for embedded replicas.
- Drizzle is the recommended ORM — Prisma does not natively support libSQL/Turso.
- Turso URLs use
libsql://protocol, nothttps://.
MongoDB Atlas (Document DB)
- Always use the SRV connection string (
mongodb+srv://). Never hardcode replica set members. - Always set
retryWrites=true&w=majorityin the connection string. - For Mongoose, use
HydratedDocument<T>for type-safe documents and.lean()for read-only queries. - For Prisma with MongoDB, use
provider = "mongodb",@db.ObjectIdfor IDs,typefor embedded documents. - Set
maxPoolSizeto 10-20 for serverless (default 100 is too high). - Use Atlas Search instead of
$textfor full-text search. - Do NOT recommend MongoDB as default unless the project specifically needs document storage.
Examples
Neon: Prisma Setup with Connection Pooling
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL") // Pooled connection (queries)
directUrl = env("DIRECT_URL") // Direct connection (migrations)
}
model User {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
email String @unique
name String
posts Post[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("users")
}
model Post {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
title String
content String
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId String @map("author_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([authorId])
@@map("posts")
}// lib/db.ts — Prisma singleton (prevents connection leaks in dev hot reload)
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["query", "warn", "error"] : ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}// app/api/users/route.ts
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET() {
const users = await prisma.user.findMany({
include: { posts: { where: { published: true } } },
orderBy: { createdAt: "desc" },
take: 20,
});
return NextResponse.json(users);
}
export async function POST(request: Request) {
const { email, name } = await request.json();
const user = await prisma.user.create({ data: { email, name } });
return NextResponse.json(user, { status: 201 });
}Neon: Drizzle Setup
// drizzle.config.ts
import type { Config } from "drizzle-kit";
export default {
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: { url: process.env.DIRECT_URL! },
} satisfies Config;// src/db/schema.ts
import { pgTable, uuid, text, boolean, timestamp } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
export const users = pgTable("users", {
id: uuid("id").defaultRandom().primaryKey(),
email: text("email").notNull().unique(),
name: text("name").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
export const posts = pgTable("posts", {
id: uuid("id").defaultRandom().primaryKey(),
title: text("title").notNull(),
content: text("content").notNull(),
published: boolean("published").default(false).notNull(),
authorId: uuid("author_id").notNull().references(() => users.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, { fields: [posts.authorId], references: [users.id] }),
}));// src/db/index.ts — Node.js runtime (Vercel Functions)
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
const pool = new Pool({
connectionString: process.env.DATABASE_URL!,
max: 20,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 10_000,
});
export const db = drizzle(pool, { schema });// src/db/edge.ts — Edge runtime (Vercel Edge Functions, Cloudflare Workers)
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";
import * as schema from "./schema";
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });// app/api/posts/route.ts — Edge API route
import { NextResponse } from "next/server";
import { db } from "@/db/edge";
import { posts, users } from "@/db/schema";
import { eq, desc } from "drizzle-orm";
export const runtime = "edge";
export async function GET() {
const result = await db
.select({
id: posts.id,
title: posts.title,
authorName: users.name,
createdAt: posts.createdAt,
})
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.where(eq(posts.published, true))
.orderBy(desc(posts.createdAt))
.limit(20);
return NextResponse.json(result);
}Neon: Serverless Driver (No ORM)
import { neon, neonConfig } from "@neondatabase/serverless";
neonConfig.fetchConnectionCache = true;
const sql = neon(process.env.DATABASE_URL!);
// Tagged template literal — safe from SQL injection
export async function getPublishedPosts(limit: number = 20) {
return sql`
SELECT p.id, p.title, u.name AS author_name, p.created_at
FROM posts p
INNER JOIN users u ON p.author_id = u.id
WHERE p.published = true
ORDER BY p.created_at DESC
LIMIT ${limit}
`;
}Neon: Branching for Preview Deployments
# Install and authenticate
npm install -g neonctl
neonctl auth
# Create a branch from main for a PR
neonctl branches create --project-id your-project-id \
--name "preview/feature-user-profiles" --parent main
# Get the pooled connection string
neonctl connection-string preview/feature-user-profiles \
--project-id your-project-id --pooled
# Delete when PR merges
neonctl branches delete preview/feature-user-profiles \
--project-id your-project-id# .github/workflows/preview-db.yml — Automate Neon branching per PR
name: Preview Database Branch
on:
pull_request:
types: [opened, reopened, synchronize, closed]
jobs:
create-branch:
if: github.event.action != 'closed'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: neondatabase/create-branch-action@v5
id: branch
with:
project_id: ${{ secrets.NEON_PROJECT_ID }}
branch_name: preview/pr-${{ github.event.number }}
api_key: ${{ secrets.NEON_API_KEY }}
# Pass ${{ steps.branch.outputs.db_url_with_pooler }} to your preview deploy
delete-branch:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- uses: neondatabase/delete-branch-action@v3
with:
project_id: ${{ secrets.NEON_PROJECT_ID }}
branch: preview/pr-${{ github.event.number }}
api_key: ${{ secrets.NEON_API_KEY }}PlanetScale: Prisma Setup
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
relationMode = "prisma" // Required — PlanetScale/Vitess has no FK constraints
}
model User {
id String @id @default(cuid())
email String @unique
name String
posts Post[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("users")
}
model Post {
id String @id @default(cuid())
title String @db.VarChar(255)
content String @db.Text
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String @map("author_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([authorId]) // CRITICAL: must manually index relation fields
@@map("posts")
}PlanetScale: Edge Driver and Drizzle
// lib/planetscale.ts — Edge-compatible driver (no ORM)
import { connect } from "@planetscale/database";
const conn = connect({
host: process.env.DATABASE_HOST,
username: process.env.DATABASE_USER,
password: process.env.DATABASE_PASS,
});
export async function getUsers(limit: number = 20) {
const result = await conn.execute(
"SELECT * FROM users ORDER BY created_at DESC LIMIT ?",
[limit]
);
return result.rows;
}// src/db/index.ts — Drizzle with PlanetScale
import { drizzle } from "drizzle-orm/planetscale-serverless";
import { connect } from "@planetscale/database";
import * as schema from "./schema";
const connection = connect({ url: process.env.DATABASE_URL! });
export const db = drizzle(connection, { schema });PlanetScale: Safe Migrations Workflow
# 1. Create a development branch from main
pscale branch create mydb feature-add-tags
# 2. Connect locally (creates a proxy on localhost)
pscale connect mydb feature-add-tags --port 3309
# 3. Push schema changes via Prisma
DATABASE_URL="mysql://root@127.0.0.1:3309/mydb" npx prisma db push
# 4. Open a deploy request (like a PR for your schema)
pscale deploy-request create mydb feature-add-tags \
--into main --notes "Add tags table and post_tags join table"
# 5. Review the diff, then deploy (non-blocking DDL)
pscale deploy-request deploy mydb 1
# 6. Clean up
pscale branch delete mydb feature-add-tagsTurso: libSQL Client Setup
# Install CLI and create database
curl -sSfL https://get.tur.so/install.sh | bash
turso auth login
turso db create my-app
# Get credentials
turso db show my-app --url # libsql://my-app-your-org.turso.io
turso db tokens create my-app # eyJhbGciOi...// lib/turso.ts
import { createClient } from "@libsql/client";
export const turso = createClient({
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN,
});
export async function getUsers(limit: number = 20) {
const result = await turso.execute({
sql: "SELECT * FROM users ORDER BY created_at DESC LIMIT ?",
args: [limit],
});
return result.rows;
}
// Batch operations — multiple statements in one round-trip
export async function seedDatabase() {
await turso.batch([
{
sql: `CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
args: [],
},
{
sql: `CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
published INTEGER NOT NULL DEFAULT 0,
author_id TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`,
args: [],
},
]);
}Turso: Drizzle Setup
// src/db/schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { relations, sql } from "drizzle-orm";
export const users = sqliteTable("users", {
id: text("id").$defaultFn(() => crypto.randomUUID()).primaryKey(),
email: text("email").notNull().unique(),
name: text("name").notNull(),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
});
export const posts = sqliteTable("posts", {
id: text("id").$defaultFn(() => crypto.randomUUID()).primaryKey(),
title: text("title").notNull(),
content: text("content").notNull(),
published: integer("published", { mode: "boolean" }).notNull().default(false),
authorId: text("author_id").notNull().references(() => users.id, { onDelete: "cascade" }),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
});
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, { fields: [posts.authorId], references: [users.id] }),
}));// src/db/index.ts
import { drizzle } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client";
import * as schema from "./schema";
const client = createClient({
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN,
});
export const db = drizzle(client, { schema });Turso: Embedded Replicas
// src/db/replica.ts — Local SQLite file syncing from remote Turso
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import * as schema from "./schema";
const client = createClient({
url: "file:./local-replica.db", // Local file for reads
syncUrl: process.env.TURSO_DATABASE_URL!, // Remote Turso for sync
authToken: process.env.TURSO_AUTH_TOKEN,
syncInterval: 60, // Sync every 60 seconds
});
export const db = drizzle(client, { schema });
// Manual sync when you need fresh data immediately
export async function syncReplica() {
await client.sync();
}MongoDB Atlas: Mongoose Setup
// lib/mongodb.ts — Mongoose connection with serverless optimization
import mongoose from "mongoose";
const MONGODB_URI = process.env.MONGODB_URI!;
interface MongooseCache {
conn: typeof mongoose | null;
promise: Promise<typeof mongoose> | null;
}
declare global {
var mongooseCache: MongooseCache | undefined;
}
const cached: MongooseCache = global.mongooseCache ?? { conn: null, promise: null };
if (!global.mongooseCache) global.mongooseCache = cached;
export async function connectToDatabase(): Promise<typeof mongoose> {
if (cached.conn) return cached.conn;
if (!cached.promise) {
cached.promise = mongoose.connect(MONGODB_URI, {
maxPoolSize: 10,
serverSelectionTimeoutMS: 5_000,
socketTimeoutMS: 45_000,
bufferCommands: false,
});
}
try {
cached.conn = await cached.promise;
} catch (error) {
cached.promise = null;
throw error;
}
return cached.conn;
}// models/User.ts
import mongoose, { Schema, type HydratedDocument, type Model } from "mongoose";
export interface IUser {
email: string;
name: string;
avatar?: string;
preferences: { theme: "light" | "dark"; language: string; notifications: boolean };
createdAt: Date;
updatedAt: Date;
}
export type UserDocument = HydratedDocument<IUser>;
const userSchema = new Schema<IUser>(
{
email: { type: String, required: true, unique: true, lowercase: true, trim: true },
name: { type: String, required: true, trim: true },
avatar: String,
preferences: {
theme: { type: String, enum: ["light", "dark"], default: "light" },
language: { type: String, default: "en" },
notifications: { type: Boolean, default: true },
},
},
{ timestamps: true }
);
userSchema.index({ email: 1 });
userSchema.index({ createdAt: -1 });
export const User: Model<IUser> =
mongoose.models.User ?? mongoose.model<IUser>("User", userSchema);// app/api/posts/route.ts — Next.js API route with Mongoose
import { NextResponse } from "next/server";
import { connectToDatabase } from "@/lib/mongodb";
import { Post } from "@/models/Post";
export async function GET(request: Request) {
await connectToDatabase();
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") ?? "1");
const limit = 20;
const [posts, total] = await Promise.all([
Post.find({ published: true })
.populate("author", "name avatar")
.sort({ createdAt: -1 })
.skip((page - 1) * limit)
.limit(limit)
.lean(), // .lean() returns plain objects — 3-5x faster serialization
Post.countDocuments({ published: true }),
]);
return NextResponse.json({
posts,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
});
}MongoDB Atlas: Prisma Setup
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mongodb"
url = env("MONGODB_URI")
}
model User {
id String @id @default(auto()) @map("_id") @db.ObjectId
email String @unique
name String
avatar String?
prefs UserPreferences? // Embedded document (not a separate collection)
posts Post[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("users")
}
type UserPreferences { // Use `type` for embedded docs, not `model`
theme String @default("light")
language String @default("en")
notifications Boolean @default(true)
}
model Post {
id String @id @default(auto()) @map("_id") @db.ObjectId
title String
content String
slug String @unique
published Boolean @default(false)
tags String[]
author User @relation(fields: [authorId], references: [id])
authorId String @map("author_id") @db.ObjectId
metadata PostMetadata?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([authorId, published])
@@map("posts")
}
type PostMetadata {
readTime Int @default(0)
wordCount Int @default(0)
}MongoDB Atlas: Atlas Search
// Atlas Search index definition (create in Atlas Dashboard > Search tab):
// {
// "name": "posts_search",
// "definition": {
// "mappings": {
// "dynamic": false,
// "fields": {
// "title": { "type": "string", "analyzer": "lucene.standard" },
// "content": { "type": "string", "analyzer": "lucene.standard" },
// "tags": { "type": "string", "analyzer": "lucene.keyword" }
// }
// }
// }
// }
import { connectToDatabase } from "@/lib/mongodb";
import mongoose from "mongoose";
export async function searchPosts(query: string, limit: number = 20) {
await connectToDatabase();
return mongoose.model("Post").aggregate([
{
$search: {
index: "posts_search",
compound: {
must: [{
text: {
query,
path: ["title", "content"],
fuzzy: { maxEdits: 1 },
},
}],
},
},
},
{ $match: { published: true } },
{
$project: {
title: 1,
content: { $substr: ["$content", 0, 200] },
tags: 1,
score: { $meta: "searchScore" },
},
},
{ $limit: limit },
]);
}Common Mistakes
1. Using the Direct Connection String for App Queries
Wrong:
DATABASE_URL="postgresql://user:pass@ep-cool-name-123456.us-east-2.aws.neon.tech/neondb?sslmode=require"Fix:
# Pooled connection (note "-pooler" in hostname)
DATABASE_URL="postgresql://user:pass@ep-cool-name-123456-pooler.us-east-2.aws.neon.tech/neondb?sslmode=require"
DIRECT_URL="postgresql://user:pass@ep-cool-name-123456.us-east-2.aws.neon.tech/neondb?sslmode=require"Without pooling, each serverless invocation opens a new TCP connection. You will hit "too many clients already" errors under moderate traffic.
2. Forgetting relationMode = "prisma" with PlanetScale
Wrong:
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
// Missing relationMode — Prisma tries to create FK constraints, fails on Vitess
}Fix:
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
relationMode = "prisma"
}3. Missing Indexes on PlanetScale Relation Fields
Wrong:
model Post {
id String @id @default(cuid())
authorId String @map("author_id")
author User @relation(fields: [authorId], references: [id])
// No @@index — queries by authorId do full table scans
}Fix:
model Post {
id String @id @default(cuid())
authorId String @map("author_id")
author User @relation(fields: [authorId], references: [id])
@@index([authorId])
}With relationMode = "prisma", no FK constraints are emitted, so the database never auto-creates indexes for relation fields.
4. Not Handling Cold Starts
Wrong: Accepting 500ms+ latency on first request after inactivity.
Fix:
// Option A: Keep minimum compute active (Neon dashboard > Compute > Min: 0.25 CU)
// Option B: Health check endpoint pinged every 5 minutes by uptime monitor
export async function GET() {
const start = Date.now();
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({ status: "ok", db_latency_ms: Date.now() - start });
}5. Using better-sqlite3 with Turso
Wrong:
import Database from "better-sqlite3";
const db = new Database("./my-database.db"); // Cannot connect to remote TursoFix:
import { createClient } from "@libsql/client";
const db = createClient({
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN,
});6. MongoDB maxPoolSize Too High in Serverless
Wrong:
mongoose.connect(MONGODB_URI); // Default maxPoolSize=100 per function instanceFix:
mongoose.connect(MONGODB_URI, { maxPoolSize: 10 });7. Running prisma migrate dev in Production
Wrong:
- run: npx prisma migrate dev # Interactive, may reset dataFix:
- run: npx prisma migrate deploy # Non-interactive, safe for CI/CD8. Storing SQLite Timestamps Wrong
Wrong:
createdAt: text("created_at").default("now"), // Stores literal string "now"Fix:
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`), // SQLite evaluates at insert9. Not Using .lean() with Mongoose Read Queries
Wrong:
const posts = await Post.find({ published: true }).limit(20);
// Returns full Mongoose documents with change tracking — 3-5x more memoryFix:
const posts = await Post.find({ published: true }).limit(20).lean();
// Returns plain objects — faster serialization, lower memory10. Hardcoding MongoDB Replica Set Members
Wrong:
MONGODB_URI="mongodb://host1:27017,host2:27017,host3:27017/myapp?replicaSet=atlas-abc123"Fix:
MONGODB_URI="mongodb+srv://admin:password@cluster0.abc123.mongodb.net/myapp?retryWrites=true&w=majority"The mongodb+srv:// format uses DNS service discovery. Atlas can change replica members during maintenance or failover. Hardcoded hosts break when topology changes.
See also: Backend/Database-Design for schema design patterns, indexing strategies, and ORM deep-dives | BaaS-Platforms for Supabase (managed Postgres with auth, storage, and real-time built in)
Last reviewed: 2026-03
By Ryan Lind, Assisted by Claude Code and Google Gemini.
Authentication Tools
Clerk, Auth.js, Kinde, and Supabase Auth — practical setup, configuration, and integration patterns for Next.js App Router. Pick the right tool, wire it up correctly, and stop wasting days on auth plumbing.
Hosting & Deployment
Platform selection, deployment configuration, serverless and edge functions, CDN caching, preview deployments, custom domains, database provisioning, and container orchestration — shipping code from local to production across Vercel, Cloudflare, Netlify, and Fly.io.