Logo

Escaping the Supabase Free-Tier Freeze: Migrating to Neon, Auth.js, and Vercel Blob

11 min read
SupabaseNeonNext.js

Table of Contents

The Freeze

I built a small recipe site for a friend. One or two users, a couple dozen recipes, maintained by exactly one person (me). It ran on Supabase - Postgres for the data, Supabase Auth for login, Supabase Storage for the photos. For a free side project, it was a great starting point.

The problem is Supabase's free tier pauses a project after a week of inactivity, and a recipe site doesn't get touched every week. I'd been ignoring the pause emails, figuring I'd click "restore" whenever I next needed it.

Then I opened the dashboard and saw this:

1This project has been paused for over 90 days and cannot be
2restored through the dashboard.

That's a different category of problem. A pause you can undo. A 90-day deletion you cannot. The only thing I had was a pg_dump backup file sitting in my Downloads folder from months earlier.

So this turned into two jobs: get the data back, and move to a stack that won't do this to me again.

Step 1: What's Actually in the Backup?

Before picking anything new, I needed to know what survived. The backup was a plain-text SQL cluster dump (pg_dump output), so I could just read it.

1file db_cluster.backup
2# ASCII text
3
4grep "CREATE TABLE public" db_cluster.backup

The public schema had what mattered: recipes, bookmarks, profiles. Counting the COPY ... FROM stdin blocks told me the data was intact - 19 recipes, 4 bookmarks, 1 user. The recipe rows still had their full Japanese titles, ingredients, and step-by-step directions.

One thing that did not survive: the images. A pg_dump only contains the database. The actual image blobs live in a separate object store, and the URLs in my recipes.image_url column pointed at my-project.supabase.co - a host that no longer resolves now that the project is deleted.

1curl -I "https://my-project.supabase.co/storage/v1/object/public/recipe-images/foo.webp"
2# curl: (6) Could not resolve host

The lesson there is blunt: a database backup is not a storage backup. Luckily only 4 of 19 recipes had photos, so re-shooting them was a non-issue. Everything else was recoverable.

I extracted the recipes into a clean, portable JSON file so the data was safe regardless of what I did next. That file later became my seed source.

Step 2: Picking a Stack That Doesn't Freeze

The important realization: I wasn't just using Supabase as a database. I was using three Supabase products glued together - Database, Auth, and Storage. Most "free Postgres" alternatives only replace the first one. I'd have to replace all three.

Here's what I landed on, and why:

A nice property of moving off Supabase Auth: my recipe data was all Postgres, so the database migration is nearly free. The work is in Auth and Storage.

Keeping the Validation Layer Intact

My old data access used the Supabase client (PostgREST) and converted snakecase columns to camelCase with ts-case-convert, then validated everything with Zod. I wanted to keep that contract exactly so my components and schemas wouldn't need to change - only the _internals of each query function would swap from PostgREST to Drizzle.

1// Before (Supabase / PostgREST)
2const { data } = await supabase
3 .from('recipes')
4 .select('*')
5 .eq('is_public', true)
6 .order('created_at', { ascending: false })
7
8// After (Drizzle)
9const data = await db
10 .select()
11 .from(recipes)
12 .where(eq(recipes.isPublic, true))
13 .orderBy(desc(recipes.createdAt))
14
15// Both paths end the same way - unchanged:
16const camel = objectToCamel(data)
17if (!isValidOf(recipeListSchema, camel)) return []
18return camel

The Nested jsonb Gotcha

My ingredients column is jsonb, and each ingredient looks like { name, amount, is_spice }. The old read path ran objectToCamel over the entire row, which also converted the nested is_spiceisSpice that my Zod schema expects. Drizzle returns the top-level columns however you name them in the schema, but it leaves jsonb contents exactly as stored.

The fix was to not get clever: keep running objectToCamel on the whole row on reads, and objectToSnake on the nested arrays on writes, exactly like before. The DB consistently stores is_spice; the app consistently sees isSpice. Don't fight the existing convention during a migration.

Timestamps as Strings

One small Drizzle detail bit me. By default Drizzle returns timestamp columns as JavaScript Date objects, but my Zod schema expected ISO strings (that's what PostgREST returned). The clean fix is telling Drizzle to hand back strings:

1createdAt: timestamp('created_at', { withTimezone: true, mode: 'string' })
2 .notNull()
3 .defaultNow()

Mutations Have to Move to the Server

Here's a structural change I didn't fully anticipate. With Supabase, my recipe create/edit/delete and bookmark toggles all ran in the browser via the Supabase JS client (with row-level security enforcing access). Drizzle talks to Postgres directly, which is server-only. So every mutation - and any read called from a client component - had to become a Server Action.

1'use server'
2
3export async function createRecipe(recipe: Omit<Recipe, 'id'>) {
4 const session = await auth()
5 if (!session?.user?.id) return null
6
7 const { ingredients, directions, ...rest } = recipe
8 const [data] = await db
9 .insert(recipes)
10 .values({
11 ...rest,
12 userId: session.user.id, // derived from the session, never the client
13 ingredients: objectToSnake(ingredients) as never,
14 directions: objectToSnake(directions) as never,
15 })
16 .returning()
17
18 revalidatePath('/')
19 revalidatePath('/recipes/[id]', 'page')
20 return data ? objectToCamel(data) : null
21}

This is actually a security upgrade: instead of trusting a user_id sent from the client, the action derives it from the server-side session.

The Bug That Cost Me an Hour: "Missing DATABASE_URL" in the Browser

After the rewrite, the home page rendered a client-side crash:

1Application error: a client-side exception has occurred

The console said Missing DATABASE_URL environment variable - which is the error my db/index.ts throws on import. But why was the database client being imported into the browser bundle?

The culprit was a barrel file. My queries/recipe/index.ts re-exported everything together:

1// The trap: a barrel mixing server-only reads with Server Actions
2export { getRecipes } from './getRecipes' // imports the db client
3export { getRecipeById } from './getRecipeById' // imports the db client
4export { createRecipe } from './createRecipe' // 'use server'

When a client component imported createRecipe from that barrel, the bundler pulled in the entire barrel module graph - including getRecipes, which imports the Neon client, which throws on the browser where DATABASE_URL doesn't exist.

The fix is to keep server-only modules out of any barrel that client code touches. Server Actions ('use server') are safe to import from the client - they become RPC stubs. Plain server functions are not.

1// Barrel now exports ONLY Server Actions - safe for client imports
2export { getAllTags } from './getAllTags'
3export { createRecipe } from './createRecipe'
4export { updateRecipe } from './updateRecipe'
5export { deleteRecipe } from './deleteRecipe'
6
7// Server Components import the reads directly from their files:
8// import { getRecipes } from '@/lib/db/queries/recipe/getRecipes'

Auth.js With a Single Locked Account

For a two-person private site, full signup is the wrong shape. I used Auth.js with the Credentials provider, backed by a single users row, and removed the signup UI entirely.

1export const { handlers, auth, signIn, signOut } = NextAuth({
2 session: { strategy: 'jwt' },
3 providers: [
4 Credentials({
5 authorize: async (credentials) => {
6 const [user] = await db
7 .select()
8 .from(users)
9 .where(eq(users.email, credentials.email as string))
10 .limit(1)
11 if (!user) return null
12 const ok = await bcrypt.compare(
13 credentials.password as string,
14 user.passwordHash,
15 )
16 return ok ? { id: user.id, email: user.email } : null
17 },
18 }),
19 ],
20 callbacks: {
21 jwt: ({ token, user }) => (user?.id ? { ...token, id: user.id } : token),
22 session: ({ session, token }) => {
23 if (token.id) session.user.id = token.id as string
24 return session
25 },
26 },
27})

To keep my existing components working, I kept the old useAuth() context but turned it into a thin adapter over next-auth/react's useSession/signIn/signOut. None of the consuming components had to change.

One detail that made the seed clean: I created the new users row with the same UUID as the original Supabase auth user. That way the rescued recipes.user_id and bookmarks.user_id foreign references still pointed at the right owner with zero remapping.

Storage on Vercel Blob

Vercel Blob is refreshingly boring. There's no public/private bucket setting to agonize over - visibility is set per upload in code, and del() is server-only so it lives behind the same authenticated API route as the upload.

1// upload
2const blob = await put(fileName, file, {
3 access: 'public',
4 contentType: file.type,
5})
6return NextResponse.json({ url: blob.url })
7
8// delete (separate handler, behind auth())
9await del(url)

The only next/image gotcha: you have to allow the blob host, or uploaded images won't render.

1images: {
2 remotePatterns: [
3 { protocol: 'https', hostname: '**.public.blob.vercel-storage.com' },
4 ],
5}

Two Caching Gotchas

Empty strings vs null. When I exported the rescued data to JSON, I "helpfully" converted empty optional fields (summary, tips) to null. But the original DB stored them as empty strings, and my Zod schema declared them z.string().optional() - which accepts undefined, not null. The whole list silently failed validation and returned []. I changed the seed to store '' to match the original model, and the recipes appeared. Match the existing data shape; don't "improve" it mid-migration.

unstable_cache persists to disk. While debugging the empty-list issue, I fixed the data but the page still showed nothing - even after restarting the dev server. It turns out unstable_cache writes to .next/cache, which survives restarts. It had cached the empty result from before my fix. A rm -rf .next/cache and the data showed up instantly. (I later dropped unstable_cache entirely in favor of route-level revalidation, which is simpler and avoids this trap.)

Why There's No Keep-Alive Cron

The obvious instinct after getting burned is "I'll set up a cron job to ping the database every few days so it never goes idle." On Supabase that's a common workaround. On Neon, don't.

Neon's free tier auto-suspends the compute after a few minutes of inactivity and auto-resumes in around half a second on the next request. It does not delete your project. The suspend is a feature - it's what keeps you under the free monthly compute-hour budget. A keep-alive cron would burn those hours for no reason and risk hitting the limit. The right amount of maintenance is zero.

Lessons Learned

A database backup is not a complete backup. pg_dump saves your rows, not your object storage. If you rely on a managed storage bucket, back that up separately - or you'll recover your recipes and lose your photos.

Understand which products you actually use. "Migrate off Supabase" sounds like a database task. It was three tasks - database, auth, and storage - and the database was the easy one.

Keep your contracts, swap the internals. By preserving the Zod schemas and the snake/camel conversion, the migration touched the data layer and almost nothing else. The blast radius stayed small.

Server-only code must never reach a client barrel. The Missing DATABASE_URL crash was entirely self-inflicted by a convenient index.ts. Server Actions are safe to import from the client; plain server functions that import a DB client are not.

Don't carry old workarounds into the new platform. The keep-alive cron that makes sense on Supabase is actively harmful on Neon. Re-evaluate your habits against the new platform's actual behavior.

Wrapping Up

What started as a "my project got deleted" panic turned into a clean weekend migration. The data came back from a forgotten backup file, the stack moved to platforms that scale to zero without losing anything, and the app ended up simpler than before - fewer moving parts, one login, direct database access, no inactivity timer hanging over it.

If you're running a low-traffic side project on a free tier with an aggressive inactivity policy, it's worth checking what actually happens when it goes idle. "Pauses and resumes" and "pauses then deletes" look the same right up until the day they don't.

Related Articles