Register your interest: Tag @Cody, get an agent
BlogResources

Connecting Supabase to Vercel: connections, not configuration

Serverless functions and Postgres connections do not get along by default. Pooling, preview environments, and the failures that only appear under load.

Aymeric ZhuoAymeric Zhuo11 min read

Summarize with AI

Connecting Supabase to Vercel: connections, not configuration
On this page

The setup is a few environment variables and it works immediately, which is why the real problem arrives later: serverless functions scale out horizontally and Postgres has a fixed connection limit, and those two facts collide the first time you get meaningful traffic.

Everything else here — preview environments, secrets, migrations — is ordinary configuration. The connection model is the part that produces an outage on a good day, and it is worth understanding before you need to.

What we'll cover

Why serverless and Postgres collide

Each function instance opens its own connection, and instances scale with traffic.

Postgres has a hard connection limit, set by the instance size, that is far smaller than the number of concurrent function instances a busy site produces.

Connections are expensive to establish, so per-request connecting adds latency as well as pressure.

Traditional pooling assumes a long-lived process, which a serverless function is not.

The failure is sudden. Everything works, then a traffic spike exhausts connections and every request fails at once.

It appears in production first, since development and preview traffic never reach the concurrency where this matters.

Pooling, and which mode to use

Use the pooled connection string for anything serverless, which is the single most important configuration choice here.

Transaction mode is the right default for serverless functions: a connection is held only for the duration of a transaction.

Session mode holds a connection for the session, which suits long-lived processes and defeats the purpose in a function.

Transaction mode does not support everything. Prepared statements, session-level settings and listen/notify behave differently or not at all, which is what usually breaks an ORM.

The direct connection is still needed for migrations and for anything requiring session features.

Know which string is which and configure them separately, since using the direct one from a function is the most common cause of the sudden failure above.

Which key belongs where

The anonymous key is public and is designed to be in the browser, protected by row level security.

The service role key bypasses all policies and must never reach the client, including in anything bundled into a client-side build.

Public environment variables are public. A prefix that exposes a variable to the browser exposes it to everyone, permanently, in the bundle.

Rotate anything that has been exposed, treating it as compromised, since it is in every copy of the deployed bundle.

Keep server-only secrets server-only, used in route handlers and server functions rather than anywhere that renders on the client.

Audit the built output rather than the configuration, because what is in the bundle is the fact that matters.

Building it with CodeWords

CodeWords connects to more than 3,000 integrations, so the deployment platform, the database and your alerting can meet in one automation.

  1. Open CodeWords and start a new automation.
  2. Describe what should happen in plain language to Cody, the automation builder: what to check on each deployment, what to monitor, and who is told.
  3. Authorize the connections — the hosting platform, the database provider, and Slack.
  4. Describe the exceptions: a preview deployment, a migration pending, a connection count approaching the limit.
  5. Run it against a preview deployment first.

You describe the outcome; Cody builds it, connects it, and deploys it. The free plan covers light use, with Pro at $39 per month and Business at $100 per month as usage grows; details are on the pricing page.

Preview deployments and their database

Every pull request gets an environment, and by default they all point at the same database.

Which means previews can write to production data, which is the configuration people discover by accident.

Branch databases are the answer where available — an isolated copy per preview, discarded when the branch closes.

Seed data matters. An empty preview database tests nothing, and a copy of production data in a preview environment is a data protection problem.

Anonymise if you copy, which is more work than it sounds and is the right answer for anything containing personal data.

Clean up. Abandoned branch databases accumulate cost and are forgotten, so removal should be automatic on branch deletion.

Migrations across environments

Migrations belong in version control, applied through a repeatable process rather than run by hand in a console.

Use the direct connection, since pooled transaction mode cannot do everything a migration needs.

Decide when they run. On deploy is convenient and couples schema changes to releases; separately is safer and requires coordination.

Backwards-compatible first. A schema change deployed before the code that needs it, and cleanup afterwards, avoids the window where old and new code run simultaneously against one schema.

Both versions run at once during a deployment, which is the fact that makes destructive migrations dangerous.

Test the rollback, or accept that you do not have one.

Row level security, which is the real access control

If the anonymous key is public, policies are the security boundary, not the application code.

Enabled but with no policy means no access, which is the safe default and is a common source of "it works locally" confusion.

Test policies as an unauthenticated user, explicitly, since testing with the service role key tests nothing.

Policies apply per operation — select, insert, update, delete — and a table with a read policy and no write policy is not protected in the way people assume.

Write them alongside the schema, in migrations, so they are reviewed like code and cannot be forgotten in an environment.

Verify on every deployment that policies are enabled where they should be, since a table added without them is the failure this model has.

Environment variables, and the ways they go wrong

Most of the time lost on this setup is not the database. It is a variable that is set in one environment and not another, and nothing says so until something fails.

Set them per environment — production, preview and development — rather than assuming one value works everywhere.

Preview environments are frequently incomplete, since a variable added for production is not automatically present for previews and the failure looks like a code problem.

A change requires a redeploy to take effect, which catches people who update a value and expect the running deployment to pick it up.

Local and deployed drift, because a local file is not the same list as the platform's, and nothing reconciles them.

Never commit the file that holds them, and check the history rather than only the working tree if you suspect one was.

Verify at startup. A deployment that fails loudly on a missing variable is far better than one that starts and fails on the first request that needs it.

Making it survive

Monitor the connection count against the limit, with an alert well before exhaustion.

Set statement timeouts, so one slow query cannot hold connections and cascade.

Handle connection failures with a bounded retry rather than an unbounded one, which makes exhaustion worse.

Keep migrations out of the request path, always.

Report the outcome: deployments, migrations applied, policy checks, and the connection headroom.

Limits worth knowing about

Connection limits scale with instance size, so the fix is sometimes a plan change rather than a code change.

Pooler modes have real feature differences, and ORMs frequently assume features transaction mode does not provide.

Cold starts add latency on the first request to an idle function, including connection setup.

Regions matter. A function and a database in different regions add a round trip to every query.

Free tiers pause or limit, which produces confusing behaviour in previews that is not present in production.

What to build first

A check on every deployment that the pooled connection string is in use for serverless functions, the service role key is absent from the client bundle, and row level security is enabled on every public table. All three are silent failures — everything works until traffic arrives, or until somebody reads the bundle — and all three are cheap to verify automatically.

Two habits make the difference. Use the pooled connection string everywhere except migrations, since the direct string in a function works perfectly until the day it does not and then fails everything at once. And test row level security as an unauthenticated user, because testing with the service role key bypasses every policy and tells you nothing about what is actually exposed.

Frequently asked questions

Why do serverless functions exhaust Postgres connections?

Because each function instance opens its own connection and instances scale with traffic, while Postgres has a fixed connection limit far below the concurrency a busy site produces. The failure is sudden and appears first in production.

Which connection string should I use?

The pooled one, in transaction mode, for anything serverless. Keep the direct connection for migrations and anything needing session-level features. Using the direct string in a function is the most common cause of sudden connection exhaustion.

What does transaction mode not support?

Prepared statements, session-level settings and listen/notify behave differently or not at all, which is usually what breaks an ORM configured with default assumptions.

Where can the service role key be used?

Server-side only, never in anything bundled for the browser. It bypasses every row level security policy, and a key exposed in a client bundle is in every copy of the deployed site and must be rotated.

Do preview deployments share the production database?

By default, yes — which means preview code can write to production data. Branch databases give each preview an isolated copy, and abandoned ones should be removed automatically when the branch closes.

Can I copy production data into preview environments?

Only anonymised, if it contains personal data. An empty preview database tests nothing and a production copy is a data protection problem, so anonymisation is the middle path and it is more work than it sounds.

When should migrations run?

Through a repeatable process from version control, on the direct connection, and written to be backwards compatible — because both the old and new versions of your code run simultaneously during a deployment.

How do I test row level security properly?

As an unauthenticated user, and per operation. Testing with the service role key bypasses every policy, and a table with a read policy but no write policy is not protected the way people assume.

Why does my preview deployment fail when production works?

Usually a missing environment variable. Values added for production are not automatically present in previews, and the resulting failure looks like a code problem rather than a configuration one.

Why did changing an environment variable not take effect?

Because it requires a redeploy. The running deployment holds the values it was built and started with, and updating them in the dashboard changes nothing until the next deploy.

Get started today

Your first workflow is free to build.

Describe what you need. Cody handles the build, the connections, and the deployment.