
How I Cut My Database Costs by 80% With Three Next.js Patterns
How I Cut My Database Costs by 80% With Three Next.js Patterns
By Mark A · September 30, 2026
I opened my Neon dashboard last week and saw a number that made me stop scrolling: $76. One project. One month. On a serverless Postgres plan that's supposed to scale to zero when nobody's using it.
The project is an e-commerce site — about 270 products, a blog, some landing pages. Nothing exotic. It runs on Next.js 14 with the App Router, deployed on Vercel, backed by Neon's serverless Postgres. The kind of stack you'd read about in a "modern full-stack" tutorial. And it was burning compute like a dedicated server running 24/7.
What made it worse: I have another project — a salon management SaaS with active daily users, a booking engine, a POS system, email campaigns, the works — and it used 1.12 CU-hours for the entire month. Same stack. Same database provider. Nearly identical deployment architecture.
Something was fundamentally wrong with how the first project was built. So I audited the entire codebase, and what I found applies to every Next.js app running on serverless Postgres.
What Are CU-Hours and Why Should You Care?
If you're on Neon (or any serverless Postgres provider), you're billed on Compute Unit hours — essentially how much CPU and memory your database endpoint uses over time. When nobody's querying your database, the endpoint suspends and you pay nothing. When a request hits, it wakes up, serves the query, and goes back to sleep.
The catch: if something keeps waking it up — a cron job, a crawler, a misconfigured page — it never suspends. A 0.25 CU endpoint running 24/7 for 30 days costs you 180 CU-hours at baseline. But if your app causes connection bursts that scale the endpoint up (even briefly), you can blow past that number without the monitoring graph showing anything unusual.
My e-commerce site hit 320 CU-hours. The graph looked flat and quiet. The bill didn't.
What the Audit Found
I went through every file in the codebase and found three categories of waste that were compounding on each other.
Every Public Page Was force-dynamic
In Next.js App Router, export const dynamic = 'force-dynamic' tells the framework to server-render the page on every single request. No caching. No ISR. Every visitor, every bot crawl, every Google Ads click triggers a full render with live database queries.
My product detail pages — all 270 of them — each ran three database queries per visit: the product itself, its categories, and related products. With Google Ads actively driving traffic to these pages, the database was getting hammered with queries that returned the same data over and over.
The blog pages, CMS pages, landing pages, lookbook pages — all force-dynamic. Every crawler visit to /sitemap.xml triggered five separate database queries to build the sitemap from scratch.
Uncached Database Calls in Shared Functions
The platform has a module system where features can be toggled on/off from the admin panel. The function that checks these toggles — getModuleOverrides() — hit the database directly every time it was called. It was called from 20+ places across the app: page renders, API routes, layout components.
The homepage alone made roughly 10 uncached database calls on every render: content overrides, media settings, section visibility toggles, testimonials, portfolio items, layout preferences, hero variant, form configuration, and overlay settings. Every single one went straight to Postgres.
Cron Jobs That Never Let the Database Sleep
The email campaign cron ran every 15 minutes. Even when the email engine was toggled off in the admin panel, the cron still fired, connected to the database to check the toggle, found it was off, and disconnected. That's 96 database connections per day just to learn "do nothing."
The daily notification cron was similar — it ran at 8 AM every day whether or not the notification module was even enabled for that deployment.
Why One Project Used Almost Nothing
The salon management SaaS (Coterie) used 1.12 CU-hours for the entire month of September while under active development. Here's why:
Its homepage was pure static JSX. No database calls. No settings to resolve. Just components rendering from config files.
It had no sitemap.ts. No dynamic sitemap means no database queries from crawlers.
It had no module override system. Feature toggles were handled at the config level, not the database level.
It used the PrismaNeon serverless adapter. This was actually present in both projects, so it wasn't the differentiator — but it's worth noting as a baseline requirement.
The lesson isn't that Coterie was built better in every way. It's that it had fewer surfaces where a database call could fire without anyone noticing. The e-commerce site had more features, which meant more places where an uncached query could hide.
The Three Patterns That Fix Everything
1. ISR Over force-dynamic for Public Pages
The fix for every public-facing [slug] page was one line:
// Before
export const dynamic = 'force-dynamic'
// After
export const revalidate = 60
ISR (Incremental Static Regeneration) caches the rendered page and only rebuilds it every 60 seconds at most. If 1,000 people visit the same product page in a minute, the database sees one query instead of 1,000. The data is at most 60 seconds stale, which is perfectly fine for product listings, blog posts, and landing pages that change only when an admin edits them.
I converted six route groups: shop/[slug], blog/[slug], pages/[slug], lookbooks/[slug], lp/[slug], and the homepage itself.
2. unstable_cache for Settings and Config Queries
Next.js provides unstable_cache (stable in practice despite the name) for caching server-side data fetches. Anything that reads from a settings table, a config table, or any data that changes only when an admin saves something gets wrapped:
import { unstable_cache } from 'next/cache'
export const getModuleOverrides = unstable_cache(
async () => {
const rows = await prisma.setting.findMany({
where: { key: { startsWith: 'modules_override_' } },
})
return Object.fromEntries(
rows.map(r => [r.key.replace('modules_override_', ''), r.value === 'true'])
)
},
['module-overrides'],
{ revalidate: 3600, tags: ['public-settings'] }
)
The tags: ['public-settings'] part is critical. When an admin saves settings, the save handler calls revalidateTag('public-settings'), which busts every cache that depends on it. The data is fresh within seconds of saving, and the database sees one query per hour instead of one per page view.
I wrapped: getModuleOverrides(), resolveContent(), getMediaSettings(), getSectionVisibility(), the chatbot system prompt query, and the sitemap builder.
3. Environment Variable Kill Switches for Crons
Cron jobs that check a database toggle before doing work still wake the database to check. The fix is a two-layer gate:
export async function GET(req: Request) {
// Gate 1: env var — exits with ZERO database cost
if (process.env.CRON_EMAIL_ENABLED === 'false') {
return NextResponse.json({ reason: 'Disabled via env var' })
}
// Gate 2: database toggle — fallback for admin UI control
const gate = await prisma.setting.findUnique({
where: { key: 'emailEngineActive' }
})
if (gate?.value !== 'true') {
return NextResponse.json({ reason: 'Email engine is OFF' })
}
// ... actual work
}
Set CRON_EMAIL_ENABLED=false in your Vercel environment variables and the cron returns immediately without ever touching the database. No redeploy needed to toggle it back on — just change the env var.
The Silent Killer: A Server Component in Root Layout
This one deserves its own section because it's the kind of bug that's invisible until you audit for it.
I had a ChatbotLoader component in app/layout.tsx — the root layout that wraps every page in the app. It's a server component that checks whether the chatbot module is enabled before rendering the chat widget:
// The problematic version
export default async function ChatbotLoader() {
const dbOverride = await prisma.setting
.findUnique({ where: { key: 'modules_override_aiChatbot' } })
.catch(() => null)
const enabled = dbOverride
? dbOverride.value === 'true'
: isModuleEnabled('aiChatbot')
if (!enabled) return null
return <AIChatbot />
}
This raw prisma.setting.findUnique() call executed on every single page render — public pages, admin pages, ISR rebuilds, bot crawls, everything. It kept the database endpoint from ever fully suspending.
The fix was one import change:
// The fixed version
import { isModuleEnabledServer } from '@/lib/dev/getModuleOverrides'
export default async function ChatbotLoader() {
const enabled = await isModuleEnabledServer('aiChatbot')
if (!enabled) return null
return <AIChatbot />
}
Now it goes through the cached getModuleOverrides() function — one database query per hour instead of one per page view.
This exact pattern existed in every deployment built from the same base template. Fixing it in the base repo means every future project inherits the fix.
Security Wins Along the Way
Performance audits tend to surface security issues because you're reading every route handler. I found three admin API routes under /api/admin/ with no authentication at all:
custom-forms/route.ts— anyone could list and create formscustom-forms/[id]/route.ts— anyone could read form details and submissionscommerce/items/bulk-categories/route.ts— anyone could bulk-modify product categories
These routes sat behind the /admin UI (which is protected by middleware), but the API endpoints themselves had no auth() check. A direct HTTP request would succeed without any session.
I also added security headers that were completely missing from the base configuration:
// next.config.js
{
source: '/:path*',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
],
}
Basic, but they weren't there.
The Audit Checklist
If you're running Next.js on serverless Postgres, run through this list:
Search your codebase for
force-dynamic. Every public-facing page with this export is a database query multiplier. Replace withexport const revalidate = 60(or whatever staleness you can tolerate).Check your root layout for database calls. Any server component in
app/layout.tsxthat queries the database runs on every page render. Cache it or move it.Wrap every settings/config query in
unstable_cache. If the data only changes when an admin saves something, it doesn't need a live query on every request. UserevalidateTag()to bust the cache on save.Add env var kill switches to every cron route. Exit before any database import when disabled. The database check should be Gate 2, not Gate 1.
Check your sitemap.ts. If it queries the database, cache it. Crawlers hit this frequently.
Grep for
prisma.in any file undercomponents/. Server components that import Prisma directly are the ones most likely to be making uncached calls from shared layouts.Check your Neon autoscaling range. If it's set to 0.25 → 8 CU (the default), change it to 0.25 → 1. The 8x scaling during connection bursts from
force-dynamicpages multiplies your bill dramatically.Audit your
/api/admin/routes for auth. Middleware protects the UI, not the API. Every handler needs its ownauth()check.
The projected savings from these changes: a drop from ~6 CU-hours/day to under 1 CU-hour/day, which over a month takes the bill from $76 down to roughly $12–15.
The real win is baking these patterns into the base template. Every new project deployed from it starts at Coterie-level efficiency instead of discovering the problem six months later in a billing alert.
Mark A is the founder of TechBuild.me, where he builds and deploys TechBOS — a modular white-label SaaS platform for businesses across the US, UK, Australia, and New Zealand.
Ready to Build Something?
Book a free 30-minute strategy session — we'll map out exactly what your business needs online.