Performance Guide
Tours is a single Next.js 16 application using the App Router with React 19 Server Components, talking to PostgreSQL (Neon in production, local Postgres in development), with media on S3-compatible object storage (Cloudflare R2 by default). This guide describes the performance characteristics that actually exist in the shipped code and the practical knobs you can turn. There is no Redis, message queue, or external cache to operate — the architecture is intentionally simple, and its speed comes from doing work on the server, close to the database, and pushing media to a CDN.
Table of Contents
- Where Time Goes
- Server Components & the App Router
- Pooled Postgres Connections
- Query Discipline & Pagination
- Database Indexes
- In-Process Caching
- Image Pipeline & CDN Delivery
- Static & Dynamic Rendering
- Tuning Checklist
Where Time Goes
For a tours marketplace the hot paths are: rendering public tour listings and detail pages, running availability/quote lookups during checkout, and serving media. The performance strategy targets each:
| Hot path | Primary lever |
|---|---|
| Public tour browse / detail | Server Components + Next caching + indexed queries |
| Availability & quote | Indexed departure lookups, single-query fetches |
| Media (tour photos, branding) | R2/CDN edge delivery + pre-sized WebP variants |
| List pages (bookings, tours, reviews) | Pagination — never unbounded scans |
Server Components & the App Router
Pages under src/app render as React Server Components by default. Data is fetched on the server, directly adjacent to the database, and only the resulting HTML (plus minimal client JS) is sent to the browser. Interactive widgets opt in with "use client". This keeps client bundles small and avoids a separate API round-trip for the initial render — the page and its data are produced in one server pass.
Route handlers under src/app/api/v1 serve JSON for client-side interactions and for native/mobile clients, reusing the same server-side data functions so the SSR page and the API return identical, already-computed data.
Keep interactive code behind
"use client"and let everything else remain a Server Component. Every component you keep on the server is JavaScript the browser never has to download, parse, or hydrate.
Pooled Postgres Connections
Database access goes through src/lib/db.ts, which picks the right driver from the DATABASE_URL host:
- Neon (production) — reached via Neon's HTTP driver (
@neondatabase/serverless), ideal for serverless cold-starts where holding a persistent TCP connection per instance is impractical. The app is configured against Neon's pooled endpoint, so many short-lived serverless invocations share a bounded set of backend connections instead of exhausting Postgres. - Local / self-hosted Postgres — reached over the standard wire protocol via
postgres.jswith a real connection pool (max: 10, idle_timeout: 20).
Both are exposed through the same tagged-template interface, and both are lazily initialized — the client is created on first query, not at module load, so next build (which imports every route handler) needs no DATABASE_URL and opens no connections at build time. The client is cached on globalThis, so Next dev's hot-reload reuses one pool instead of leaking a new one on every module re-evaluation.
// src/lib/db.ts — one lazily-initialised, process-cached client, reused per instance
const globalForDb = globalThis as unknown as { _toursDbClient?: Client };
function getClient(): Client {
if (_client) return _client;
if (globalForDb._toursDbClient) return (_client = globalForDb._toursDbClient);
// isNeonUrl(url) ? neon(url) : postgres(url, { max: 10, idle_timeout: 20 })
}
Because the Neon HTTP driver treats each query as an independent request, favor a single query that returns everything a page needs over many small sequential queries in a loop.
Query Discipline & Pagination
The largest, cheapest wins come from how you query, not from infrastructure:
- One query per page, not one per row. Fetch a page's data in a single round-trip (joins/aggregates) rather than looping N sequential queries.
- Select only what you render. Avoid
SELECT *on wide tables (listings,bookings) in hot paths — name the columns the view actually uses. - Always paginate lists. List endpoints return a bounded page plus a meta envelope, never the whole table:
// paginated list endpoints return { data, meta }
return ok({
data,
meta: { total, page, per_page, total_pages, sort },
});
Keep the same LIMIT/OFFSET (or keyset) bounds in both the SSR page and its matching GET /api/v1/... endpoint so a large catalog never turns into an unbounded scan on either surface.
Database Indexes
Schema is created idempotently by ensure*() functions (CREATE TABLE IF NOT EXISTS + CREATE INDEX IF NOT EXISTS) — there is no separate migration tool. These functions also create indexes on the high-traffic lookup and foreign-key columns. Representative indexes (recall listings = tours, hosts = operators, units = departures):
| Index | Column(s) | Speeds up |
|---|---|---|
idx_listings_owner |
owner_id |
An operator's own tours |
idx_units_listing |
listing_id, parent_unit_id |
Loading a tour's departures |
idx_bookings_customer |
customer_id |
A traveler's bookings |
idx_bookings_unit_dates |
unit_id, check_in, check_out |
Departure availability lookups |
idx_bookings_product |
product_type, product_id |
Bookings for a given tour |
idx_listing_reviews_prop |
listing_id, is_published |
A tour's published reviews |
idx_extras_scope |
applies_to, is_active |
Bookable extras / add-ons |
Schema files (src/product/booking/schema.ts, src/product/master/schema.ts, src/lib/vendor-schema.ts) define indexes across the data model. Because they use IF NOT EXISTS, re-running npm run db:init (or any boot path that calls the ensure* helpers) is safe and idempotent.
When you add a new high-frequency
WHEREorORDER BYcolumn, add a matchingCREATE INDEX IF NOT EXISTSin the relevantensure*()function so production picks it up automatically on next init.
In-Process Caching
There is no external cache server. The only caches in the code are small, in-process TTL caches — one per instance, best-effort, no cross-instance coordination required:
| Cache | Location | TTL | Purpose |
|---|---|---|---|
| License status | src/lib/license/index.ts |
60 s | Avoids re-reading the app_license row and re-verifying the token on every hot public page render |
| Storage config / client | src/lib/r2.ts |
60 s | Caches the active storage connection and its S3 client so the bucket config isn't decrypted and re-instantiated on every upload/list |
// src/lib/r2.ts and src/lib/license/index.ts both use a 60s window
const TTL = 60_000;
With short TTLs this is exactly the intent — recent-enough config without a coordination layer. Don't add per-request re-verification on top of these; rely on the window.
Image Pipeline & CDN Delivery
Uploaded images are processed server-side with sharp (src/lib/image-process.ts) and stored on S3-compatible object storage via src/lib/r2.ts.
What the pipeline does on upload:
- Resizes/crops each source into a set of preset sizes. A tour photo preset, for example, generates
1200×900,400×300, and160×120variants so the UI can request an appropriately-sized image instead of the full original. - Converts to a chosen format — WebP by default, PNG/JPG selectable — at a configurable quality (default
82). - Honors EXIF orientation (
.rotate()), uses attention-based cropping forcoverfits, and never upscales logos/icons. - Drops the original by default (only the generated variants are stored), unless the admin opts to keep it.
All of this is configurable from Admin → Settings → Theme Options → Images (stored in theme_settings): output format, quality, per-preset sizes, and whether to keep originals.
// src/lib/image-process.ts — defaults, overridable per preset from theme_settings
const quality = 82; // img_quality
const deleteOriginal = true; // img_delete_original
// tour photo preset sizes: [[1200,900],[400,300],[160,120]]
Serving:
- Stored objects are served from the storage provider's public URL — Cloudflare R2's CDN edge by default; DigitalOcean Spaces, AWS S3, and Google Cloud Storage are also supported via the same S3 client.
- Next.js
<Image>handles responsive sizing and lazy-loading of these CDN-hosted assets.
Smaller WebP variants plus CDN edge delivery mean browsers download appropriately-sized images close to the user, off your app server. Keep the default WebP format and reasonable preset sizes unless a buyer specifically needs PNG/JPG.
Static & Dynamic Rendering
The App Router statically renders where the data permits and renders dynamically where it must. Content pages with stable slugs (e.g. blog and docs detail routes) use generateStaticParams to pre-render at build time, while data-driven admin and portal pages render per request. The cron and backup routes are explicitly dynamic (export const dynamic = "force-dynamic" / streamed responses) because they must run fresh.
You don't configure this globally — it follows from how each route fetches data. Pages that read per-request session or live DB state render dynamically; static-parameter content pages are pre-rendered and then served from cache.
Tuning Checklist
Database
- Keep
DATABASE_URLpointed at your Neon pooled endpoint; the pool is sized for serverless fan-out. - Add a
CREATE INDEX IF NOT EXISTSfor any new hotWHERE/ORDER BYcolumn, then re-runnpm run db:init. - Fetch a page's data in one query; avoid sequential per-row queries.
- Select only the columns you render; avoid
SELECT *on wide tables in hot paths. - Paginate every list endpoint and its matching SSR page with the same bounds.
Assets
- Keep media on R2 (or another S3 provider) so it is served from a CDN, not your app server.
- Leave image output as WebP and keep "delete original" on unless originals are required.
- Tune per-preset sizes in Theme Options so you store only the dimensions the UI actually uses.
App
- Keep interactive code behind
"use client"; let everything else remain a Server Component. - Rely on the 60 s license/storage caches — don't add per-request re-verification.
- Run on a platform/region close to your Neon database (the bundled
vercel.jsonusesbom1) to minimize DB latency.
© CreativeCape Solutions · creative-cape.com · support@creative-cape.com