Tutorial14 min read · Updated September 10, 2026

How to Add Neon Postgres to Next.js (2026)

Neon is Postgres with the storage separated from the compute, which buys two things a serverless app cares about: the compute can scale to zero between requests, and a copy-on-write branch of the whole database is cheap enough to create per pull request. Its driver is unusual too — `@neondatabase/serverless` runs single queries over HTTPS, so an Edge function with no TCP sockets can still talk to Postgres. This guide covers both halves.

What Neon is, and why the driver is different

Neon is real Postgres — the same server, the same SQL, the same extensions — with the storage layer rewritten to live on object storage instead of a local disk. Two consequences follow and they are the whole pitch. Compute detaches from storage, so an idle database can suspend and cost nothing until the next query wakes it. And because storage is versioned, a branch is a copy-on-write pointer rather than a dump and restore, so branching a production-sized database is a cheap operation instead of an overnight job.

The driver is the part that surprises people. A conventional Postgres client opens a TCP connection and holds it, which is a poor fit for a function that may be cold, may be one of hundreds, and is torn down after the response. `@neondatabase/serverless` offers two shapes instead: a `neon()` function that sends one query over HTTPS with no session at all, and a `Pool`/`Client` pair that tunnels the real Postgres protocol over a WebSocket when you need a session. The package is a drop-in for `pg` in the second case — same API, same result objects.

The HTTP path is the one to default to, because it has no connection to exhaust and works on runtimes where raw sockets are unavailable. Its limitation is the thing it gives up: no session, so no interactive transaction where a later statement depends on an earlier result, no `LISTEN`, no prepared statement reused across calls. Reach for the WebSocket path when you need one of those. Before you write any of it, if the tables are not settled, sketch the app's entities and screens first — those are the tables.

Step 1 — Create the project and get two connection strings

Create a project in the Neon console and pick the region your functions run in; the round trip is a latency floor you cannot optimise away later. The dashboard then hands you two connection strings — a pooled one and a direct one — and they are not interchangeable.

The pooled string routes through a connection pooler in transaction mode and is what your application runtime should use, because it lets many short-lived function invocations share a small number of real Postgres connections. The direct string connects to the database itself. You can tell them apart by the host: the pooled one carries a pooler suffix in the hostname.

Migrations need the direct string. A transaction-mode pooler hands your connection back to the pool between statements, which breaks anything relying on session state — session-level advisory locks, `SET` statements that must persist, some `CREATE INDEX CONCURRENTLY` paths. Migration tools use all of those. The usual arrangement is `DATABASE_URL` holding the pooled string for the app and `DIRECT_URL` holding the unpooled one for the migration command, and getting this backwards produces migration failures that look like bugs in the migration tool.

`npm i @neondatabase/serverless`, then put both strings in `.env.local` and in your host's environment settings. There is no other setup — no adapter to register, no client to generate.

Step 2 — Query over HTTP

`const sql = neon(process.env.DATABASE_URL!)` gives you a tagged-template function, and in an App Router Server Component a query is one awaited call: `const rows = await sql`SELECT id, title FROM notes WHERE owner_id = ${userId} ORDER BY created_at DESC``. Interpolated values become bound parameters, not string concatenation, so the ordinary usage is also the safe one.

Version 1.0 made that safety structural. The query function can now *only* be called as a template — `sql("SELECT 1")` is both a TypeScript error and a runtime error, where earlier versions accepted it and silently permitted string-built SQL. Two replacements cover the cases that legitimately needed it: `sql.query("SELECT * FROM notes WHERE id = $1", [id])` for manually numbered placeholders, and `sql.unsafe(str)` for interpolating a string you have separately established is safe, such as a column name chosen from a fixed list.

Templates compose, which is what makes conditional queries readable without building SQL by hand. `const where = sql`WHERE owner_id = ${userId}`` and then `` sql`SELECT * FROM notes ${where} LIMIT ${n}` `` works, because compilation to raw SQL happens lazily at query time and the parameter placeholders are renumbered then. Before version 1.0 this only worked for fragments without parameters.

Two options change the return shape. By default you get an array of row objects. `neon(url, { fullResults: true })` returns a node-postgres-shaped object with `rows`, `fields`, `command`, `rowCount` and `rowAsArray`, which is what you want when you need the column metadata. `arrayMode: true` returns rows as arrays instead of objects — meaningfully faster to transfer for wide result sets, and unreadable for everything else. Errors arrive as a `NeonDbError`, which carries the Postgres fields you actually diagnose with: `code`, `detail`, `hint`, `constraint`, `table` and `column`.

Step 3 — When you need a session: Pool and Client

`Pool` and `Client` are the node-postgres API, tunnelled over a WebSocket. `const pool = new Pool({ connectionString: process.env.DATABASE_URL })`, then `pool.query("SELECT * FROM notes WHERE id = $1", [id])` — and because the shapes match `pg` exactly, code written against `pg` moves over by changing the import line.

One line of setup applies outside the browser. In Node v21 and earlier there is no global `WebSocket`, so the driver needs one handed to it: `import ws from "ws"; neonConfig.webSocketConstructor = ws`. On Node 22 and later, and on Edge runtimes and Cloudflare Workers, `WebSocket` is global and the line is unnecessary — which is why it appears in older tutorials and not newer ones, and why copying it into a modern project pulls in a dependency you do not need.

Use this path for a session: an interactive transaction where the second statement depends on the first's result, `LISTEN`/`NOTIFY`, or a long-running script that issues many queries and benefits from one connection rather than one request each. `pool.connect()` checks out a client you must `release()` in a `finally`, because a leaked client holds a connection until the pool gives up on it.

For a serverless function, though, think twice before instantiating a `Pool`. A pool inside a function that handles one request is a pool of one, with a WebSocket handshake on top of the query you wanted. The HTTP path exists precisely for that shape, and mixing them in the same app is fine — `neon()` for the request-scoped reads and writes, a `Pool` in the background worker or migration script that genuinely needs a session.

Step 4 — Transactions

The HTTP driver supports non-interactive transactions, which means you hand it every statement up front and it runs them as one unit. `await sql.transaction([sql`INSERT INTO notes ...`, sql`UPDATE counters ...`])` sends both in a single request wrapped in `BEGIN`/`COMMIT`. There is also a callback form, `sql.transaction((txn) => [txn`...`, txn`...`])`, which is the same thing with the queries built against the transaction's own function.

What it cannot do is branch on a result. A transaction that inserts a row and then uses the returned id to decide what to do next is interactive, and interactive needs a session — so that is a `Pool` or `Client` with explicit `BEGIN`, `COMMIT` and a `ROLLBACK` in the `catch`. Quite often the interactive version is avoidable: `INSERT ... RETURNING id` inside a CTE, or a single statement doing what you were about to do in two, keeps you on the HTTP path.

The transaction options are the Postgres ones, passed as a second argument: `isolationLevel` accepting `"ReadUncommitted"`, `"ReadCommitted"`, `"RepeatableRead"` or `"Serializable"`, plus `readOnly` and `deferrable`. The driver's own documentation notes that `ReadUncommitted` gets you `ReadCommitted`, because Postgres has never implemented the weaker level. Default to leaving these alone; reach for `Serializable` when you have an actual read-modify-write race and are prepared to retry on a serialization failure.

Writes belong in Server Actions rather than route handlers in most App Router apps — a `"use server"` function that validates its input, runs the statement, and calls `revalidatePath` so the Server Component read re-runs. Treat everything arriving from the client as hostile: a Server Action is a public HTTP endpoint with a nicer calling convention. If you would rather have this scaffolding generated than typed out, describe the app and InBuild generates the Next.js code — a paid plan is required to generate.

Step 5 — Branches, and a database per preview deploy

A Neon branch is a copy-on-write fork of the database at a point in time, with its own connection string. It shares storage with its parent and diverges only as you write to it, which is what makes branching a large database practical rather than theoretical. A branch is also a snapshot you can query — the standard recovery move is to branch from a timestamp before the bad migration and read the rows out of it, rather than restoring over the live database.

The payoff for a Next.js project is a database per preview deployment. Neon's GitHub integration creates a branch when a pull request opens, exposes its connection string to the preview environment, and deletes it on merge, so every preview runs its migrations against its own copy of production-shaped data. The alternative — every preview branch pointing at one shared staging database — means the first pull request with a destructive migration breaks every other open preview.

Two guardrails are worth setting up on day one. Give the preview branch a role with reduced privileges, because a branch of production is production data wearing a different hostname. And check that branches are actually deleted when the pull request closes, or you accumulate one per pull request forever.

Branches also make schema changes rehearsable. Branch, run the migration against the branch, point a local app at it, and see what breaks before anything touches the real database. That is the moment to settle details you would otherwise decide under pressure — which columns are nullable, what the `on delete` behaviour is, and how a human-readable URL column is normalised. Run real titles through a slug generator before you encode the rule, because the awkward cases are the ones with punctuation and accents.

Using Neon through an ORM

You do not have to choose between Neon's driver and an ORM; the ORMs use it. Prisma connects through `@prisma/adapter-neon`, which is what this site runs — `new PrismaClient({ adapter: new PrismaNeon({ connectionString }) })` in a module that caches the client on `globalThis` outside production, so hot reload does not open a new pool per reload. Drizzle has `drizzle-orm/neon-http` for the HTTP driver and `drizzle-orm/neon-serverless` for the WebSocket one, and the choice between them is the same session-or-not question as above.

The reason to reach for raw SQL anyway is that Neon's HTTP path is at its best with one statement per request. An ORM that issues several queries to assemble a nested object turns one round trip into several, and on a serverless function in another region that is the latency you will spend the rest of the project trying to win back. A single hand-written query with a join, or a CTE returning exactly the shape the page needs, is often the better tool even in a codebase that uses an ORM everywhere else.

Whichever you pick, keep migrations pointed at the direct connection string and the application pointed at the pooled one. That split is the single most common Neon configuration mistake, and it fails in the least helpful way — the app works, and the deploy that runs migrations fails intermittently with an error about the connection, which reads like a network problem rather than a configuration one.

Common mistakes

Running migrations through the pooled connection string. Transaction-mode pooling breaks session-level state, so migrations fail in ways that look like flaky infrastructure. Use the direct string for the migration command and the pooled one for the app.

Copying `neonConfig.webSocketConstructor = ws` into a modern project. It is only needed on Node v21 and earlier. On Node 22 and above, and on Edge runtimes, `WebSocket` is global and the line adds a dependency for nothing.

Calling the query function as a plain function. `sql("SELECT 1")` was allowed before version 1.0 and is now an error on purpose, because it was the path to string-built SQL. Use `sql.query(text, params)` when you need explicit placeholders.

Reaching for `sql.unsafe` to interpolate user input. It exists for values you have already established are safe — a column name from a fixed list. Anything that came from a request goes in a template placeholder.

Instantiating a `Pool` in a request handler. You get a WebSocket handshake for one query. Use `neon()` for request-scoped work and keep pools for scripts and workers that issue many queries.

Forgetting `release()` after `pool.connect()`. Put it in a `finally`, or the client is held until the pool gives up on it and the leak only shows under load.

Treating a preview branch as disposable data. It is a copy of production with a different hostname. Reduced-privilege roles, and deletion when the pull request closes.

How to do it

  1. 1

    Create the project

    Create a Neon project in the region your functions run in. Copy both connection strings from the dashboard — the pooled one and the direct one. They are not interchangeable.

  2. 2

    Set the environment variables

    DATABASE_URL holds the pooled string for the application; DIRECT_URL holds the unpooled one for migrations. Add both to .env.local and to your host's environment settings.

  3. 3

    Install the driver

    npm i @neondatabase/serverless. No adapter to register and no client to generate — import { neon } and you have a query function.

  4. 4

    Query over HTTP

    const sql = neon(process.env.DATABASE_URL!), then await sql`SELECT ... WHERE id = ${id}` in a Server Component. Interpolated values become bound parameters.

  5. 5

    Use sql.query or sql.unsafe where a template will not do

    sql.query(text, params) for manually numbered $1 placeholders; sql.unsafe(str) only for strings you have established are safe, such as a column name from a fixed list.

  6. 6

    Add Pool only where a session is required

    Interactive transactions, LISTEN/NOTIFY, or a long-running script. On Node v21 and earlier set neonConfig.webSocketConstructor; on Node 22 and above it is unnecessary. Release checked-out clients in a finally.

  7. 7

    Branch per pull request

    Connect the GitHub integration so each pull request gets its own copy-on-write branch and connection string, migrations run against it, and the branch is deleted on merge.

Frequently asked questions

What is the difference between the pooled and direct connection strings?

The pooled string routes through a connection pooler in transaction mode, so many short-lived serverless invocations share a small number of real Postgres connections — that is what your application should use. The direct string connects to the database itself and keeps session state, which migrations require. You can tell them apart by the pooler suffix in the hostname. Point the app at the pooled one and the migration command at the direct one.

When should I use neon() versus Pool?

Default to neon(). It sends one query over HTTPS with no connection to open or exhaust, and it works on runtimes with no TCP sockets at all. Switch to Pool or Client when you need a session: an interactive transaction whose later statements depend on earlier results, LISTEN/NOTIFY, or a script issuing many queries where one connection beats one request each. Using both in the same app is normal.

Why does sql("SELECT 1") throw?

Version 1.0 made the HTTP query function template-only, deliberately. Calling it as an ordinary function meant the SQL had been built by string interpolation before the driver ever saw it, which is the classic injection path. It is now both a TypeScript error and a runtime error. Use sql.query(text, params) for manually numbered placeholders, or sql.unsafe(str) for a string you have separately established is safe.

Does scale-to-zero mean the first request is slow?

A suspended compute has to resume before it can answer, so the first query after an idle period pays for that. It matters for a low-traffic production app and barely at all for one with steady traffic, since the compute stays awake. The controls are on the Neon side — how long before it suspends, or whether it suspends at all — so treat it as a deliberate setting per environment rather than something to work around in application code.

Can I use Neon on the Edge runtime?

Yes, and that is much of the point of the HTTP driver — no TCP socket is needed, so neon() works in an Edge function and in a Cloudflare Worker. The WebSocket path works there too, since those runtimes provide a global WebSocket. What does not work on Edge is a conventional TCP-based Postgres client, which is the constraint the whole driver design is answering.

Do I still need an ORM?

No, and Neon's driver is pleasant enough to use directly that plenty of projects skip one. What you give up is generated migrations and types derived from the schema, which you would then write by hand. If you want an ORM, Prisma connects through @prisma/adapter-neon and Drizzle through drizzle-orm/neon-http or neon-serverless. Even then, consider dropping to raw SQL for the queries that would otherwise become several round trips.

Free tools used in this guide

No signup — each runs in the browser or as a single server-side fetch.

Ready to build?

See a real InBuild template rendered live, with the exact exported code, before you pick a plan — no account needed.

Or skip the tutorial: describe it and InBuild generates the Next.js code for you. A paid plan is required to generate.

Build this with InBuild

More Next.js guides