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

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.js with 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.

TS
// 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:
TS
// 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 WHERE or ORDER BY column, add a matching CREATE INDEX IF NOT EXISTS in the relevant ensure*() 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
TS
// 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, and 160×120 variants 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 for cover fits, 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.

TS
// 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_URL pointed at your Neon pooled endpoint; the pool is sized for serverless fan-out.
  • Add a CREATE INDEX IF NOT EXISTS for any new hot WHERE/ORDER BY column, then re-run npm 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.json uses bom1) to minimize DB latency.

© CreativeCape Solutions · creative-cape.com · support@creative-cape.com