PostgreSQL for Bangkok Business Apps: A Next.js Setup Guide
24 April 2026 · by Yunmin Shin
Why PostgreSQL for Bangkok Business Apps?
Most of the Bangkok businesses we build for aren't managing abstract "content" — they're managing appointments, orders, patients, and payments. A clinic booking system needs to know which treatment slots are taken. A restaurant discovery site needs to track which listings are claimed and which reviews are verified. A real estate site needs to filter hundreds of listings by price, district, and availability without falling over.
That's a relational data problem, and PostgreSQL is the correct default for it. It handles JSON columns natively for the semi-structured bits (treatment notes, listing metadata), supports proper indexing for filtered search, and enforces the constraints that keep a double-booked appointment slot from ever happening in the first place. We run PostgreSQL under every production site we operate, including the aesthetic clinic sites (thaifacialclinic.com, bangkokfillers.com, bangkokbotoxclinic.com) and the restaurant discovery site (snsstopper.com) — not just client work.
How Do You Connect PostgreSQL to Next.js?
Don't open a direct TCP connection per request. Serverless functions on Vercel spin up and tear down constantly, and a direct connection doesn't survive that cleanly — you'll exhaust Postgres's connection limit the first time a marketing campaign sends a burst of traffic to a clinic's booking page.
Supabase is our default for Bangkok projects: hosted Postgres with built-in pooling (Supavisor), a generous free tier for early-stage sites, row-level security if you need it, and a Singapore-region option that keeps latency low from Thailand. Neon is a solid alternative with a true serverless, scale-to-zero driver that's worth considering for low-traffic side projects.
npm install @supabase/supabase-js
# or, for direct SQL via an ORM
npm install pg
Create a lib/db.ts that exports a module-level singleton connection. Reusing the same client across requests in development avoids exhausting your pool before you've even deployed.
What ORM Should You Use?
Drizzle ORM is the default choice for TypeScript Next.js projects now. It's type-safe, generates SQL you can actually read, and has no query-engine binary adding latency to cold starts — which matters when a clinic's booking widget needs to respond before a patient gives up and calls a competitor instead.
Prisma is still a fine choice if developer experience matters more than raw serverless performance to you, but its query engine adds measurable latency on Vercel's smaller functions.
Define your schema in db/schema.ts, run drizzle-kit generate to produce migration files, and drizzle-kit migrate to apply them. Commit the migration files — they're the source of truth for your database history, not just a build artifact.
What Does a Real Schema Look Like? A Clinic Booking Example
Here's a simplified version of the kind of schema we actually run for an aesthetic clinic booking flow — patients, treatments, staff, and appointments, with room for LINE-based contact and PromptPay payment references:
export const patients = pgTable("patients", {
id: uuid("id").primaryKey().defaultRandom(),
nameEn: text("name_en"),
nameTh: text("name_th"),
phone: text("phone").notNull(),
lineUserId: text("line_user_id"), // set once they message the clinic's LINE OA
createdAt: timestamp("created_at").defaultNow(),
});
export const treatments = pgTable("treatments", {
id: uuid("id").primaryKey().defaultRandom(),
nameEn: text("name_en").notNull(),
nameTh: text("name_th").notNull(),
durationMinutes: integer("duration_minutes").notNull(),
priceThb: integer("price_thb").notNull(),
});
export const appointments = pgTable("appointments", {
id: uuid("id").primaryKey().defaultRandom(),
patientId: uuid("patient_id").references(() => patients.id),
treatmentId: uuid("treatment_id").references(() => treatments.id),
staffId: uuid("staff_id").references(() => staff.id),
startsAt: timestamp("starts_at", { withTimezone: true }).notNull(),
status: text("status").notNull().default("pending"), // pending, confirmed, cancelled
promptpayChargeId: text("promptpay_charge_id"), // reference to the payment gateway charge
});
Two things worth calling out. First, startsAt is stored withTimezone: true — always persist appointment times in UTC and convert to Asia/Bangkok (a fixed UTC+7 offset, no daylight saving to worry about) only at the display layer. Second, a unique constraint on (staff_id, starts_at) at the database level, not just application logic, is what actually prevents two patients getting booked into the same slot with the same doctor when two requests land within milliseconds of each other.
How Do You Handle Bilingual Thai/English Content and Search?
Most Bangkok clinic and restaurant sites serve both Thai and English speakers, so name and description fields are usually stored as paired _th / _en columns rather than a single localized field — it keeps queries and admin editing simple.
Search is the one place PostgreSQL needs help with Thai. Postgres's built-in full-text search (tsvector/tsquery) tokenizes on whitespace, and Thai is written without spaces between words — so to_tsvector('english', ...) is close to useless on Thai text. For basic "search by name" functionality, pg_trgm (trigram similarity) with a GIN index works reasonably well against both scripts and is easy to set up:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_treatments_name_th_trgm ON treatments USING gin (name_th gin_trgm_ops);
For anything beyond simple substring matching — a restaurant discovery site with real search-as-you-type — a dedicated search service (Meilisearch, Typesense) in front of Postgres is worth the extra piece of infrastructure.
How Do You Handle Migrations in Production?
Never run migrations automatically on application startup — with multiple serverless instances deploying near-simultaneously, you'll get a race condition where two instances try to apply the same migration at once. Instead:
- Run migrations as a separate CI/CD step before the new code deploys.
- Write backward-compatible migrations — add new columns as nullable first, backfill, then tighten the constraint in a follow-up migration.
- Let Drizzle track applied migrations automatically in its own metadata table.
For Bangkok projects on Vercel, add drizzle-kit migrate as a build step that runs before next build completes, so the schema is always ahead of the code that depends on it.
What About Connection Pooling Under Real Traffic?
A clinic running a promotion, or a restaurant listing getting shared on Facebook, can spike traffic well past normal levels with no warning. Without pooling, that spike exhausts Postgres's connection limit and every other tenant on that database starts failing too. Use Supabase's Supavisor (or PgBouncer in transaction mode if self-hosting) so serverless function invocations share a small pool of real database connections instead of each opening its own.
Ready to Build Something Fast?
Get a free quote. We reply within 24 hours.
Ready to build something fast and scalable?
Get a free project quote. We reply within 24 hours.
Get a Free Quote →