Skip to content
scsiwyg
sign insign up
get startedmcpcommunityapiplaygroundswaggersign insign up
โ† Stone Maps

The Tech Stack: Every Tool and Why It's There

#stonemaps#devlog#nextjs#build

The Tech Stack: Every Tool and Why It's There

Technology choices are hypotheses. You pick a tool because you believe it will solve a problem better than the alternatives, given your constraints, at this moment. Here's the full accounting for Stone Maps โ€” what's in the stack, why it's there, and where the bets might not pay off.

Next.js 15 with App Router

The framework choice was never seriously contested. Next.js with the App Router gives you server components, streaming, co-located API routes, and Vercel's first-class deployment support in a single package. The App Router specifically means server-rendered pages by default (good for performance and SEO), React Server Components for data-fetching without waterfalls, and a file-system routing model that's easy to navigate.

The tradeoff is complexity. Server Components, Client Components, and the boundary between them require ongoing attention. The 'use client' directive is easy to misplace. Debugging hydration mismatches is not pleasant. But for a full-stack TypeScript app deploying to Vercel, there's no better option.

Alternatives considered: Remix (good but smaller ecosystem), SvelteKit (appealing but TypeScript integration less mature), plain Express (too much to build ourselves).

TypeScript Strict Mode

strict: true in tsconfig.json. Every file. No escape hatches.

This is not a preference โ€” it's a discipline. Strict mode catches the class of bugs (implicit any, unchecked null/undefined, unintentional type widening) that cause production errors rather than build failures. The Vercel type-error incident โ€” where a TypeScript comparison that was always-false caused a build failure โ€” is a direct example of strict mode doing its job.

The cost is time during development, especially early. The benefit compounds as the codebase grows.

Supabase Postgres + PostGIS

Supabase is managed Postgres with PostGIS pre-installed, a good dashboard, and free-tier generosity that makes early-stage iteration cheap. PostGIS is non-negotiable for a place-aware app โ€” it turns the database into a spatial engine that can answer questions like "find posts within 1km of this coordinate" without application-level computation.

The connection string is the main complexity. Supabase offers both a direct connection and a pooled connection (PgBouncer). The direct connection is used in development (fewer moving parts); the pooled connection with ?pgbouncer=true is used in production where Vercel serverless functions open many short-lived connections and need connection pooling.

Drizzle ORM sits on top of Postgres. Drizzle is schema-first and TypeScript-native โ€” the schema files are TypeScript, migrations are generated from diffs, and queries are fully typed. The query API is close to raw SQL, which means less magic and easier debugging. The relational query API (db.query.posts.findMany({ where: ... })) handles joins cleanly.

The one roughness: PostGIS geometry types require `sql`` template literals for spatial operations, breaking out of the typed query builder. This is a known Drizzle limitation with non-standard Postgres types.

Auth.js v5

Auth.js (formerly NextAuth) handles sessions. We use the credentials provider โ€” email and password โ€” with bcrypt hashing and JWT sessions stored in cookies.

The choice to avoid OAuth (Google, GitHub) was deliberate for early access. OAuth adds complexity (callback URLs, provider configuration, token refresh) and creates a dependency on external identity providers. For 50 users, a username and password is simpler, faster to implement, and gives us full control over the auth flow.

The main limitation: no magic links, no passkeys, no social sign-in. These will matter for the wider launch. Auth.js supports all of them, so the upgrade path is clear.

Vercel AI SDK

The AI SDK provides provider-agnostic interfaces to OpenAI, Anthropic, and Google. The same streamText() call works with any of them โ€” provider selection is a runtime configuration:

typescript
switch (process.env.AI_PROVIDER) {
  case 'anthropic': return anthropic(model);
  case 'google': return google(model);
  default: return openai(model);
}

This matters because AI models improve rapidly. Being locked to one provider at the API level would mean rewriting AI integration every time we want to switch. The abstraction makes provider changes a config change.

Voice mode uses OpenAI's Realtime API directly via WebRTC โ€” the AI SDK doesn't support real-time audio streaming, so that integration is bespoke.

Cloudflare R2

R2 is S3-compatible object storage without egress fees. For a media-heavy journaling app, egress fees on a per-request basis (every image load) would become significant at any real scale. R2 eliminates that entirely.

The integration is straightforward: AWS SDK v3, S3Client pointed at the R2 endpoint. Presigned URLs for uploads (client uploads directly to R2, no bandwidth through our API). A proxy route for downloads (/api/r2/[...key]) that generates presigned GET URLs and returns 302 redirects.

The proxy adds a hop to every image request. If throughput ever becomes a concern, a CDN with R2 as origin would eliminate it.

Upstash Redis

Upstash is serverless Redis โ€” an HTTP API, not a persistent TCP connection. This is the critical distinction in Vercel's environment: serverless functions can't maintain long-lived TCP connections across invocations, but they can make HTTP requests. Upstash works natively in this model.

We use it exclusively for rate limiting. The withRateLimit() wrapper calls Upstash to increment a counter and check whether the limit is exceeded. The counter is shared across all Vercel function instances, which is the whole point โ€” in-memory counters would be per-instance and would allow far more than the intended rate.

Mapbox GL

The map is the most visible feature that has a hard technical dependency. Mapbox GL JS renders WebGL vector maps that are fast, styleable, and accurate. The alternatives don't match it for interactive map performance.

The cost is a session-based pricing model past the free tier. For early access it's free; for growth it's a variable cost that we'll need to monitor.

NEXT_PUBLIC_MAPBOX_TOKEN is the client-side API key, scoped to specific domains in Mapbox's dashboard. Tokens in client bundles are expected โ€” Mapbox's security model is domain restriction, not token secrecy.

Serwist

Serwist (@serwist/next) is the PWA layer โ€” it wraps Next.js's build to compile a service worker from app/sw.ts. The service worker precaches static assets, handles navigation fallback to /offline, and is disabled in development to avoid cache confusion.

The service worker is about 30 lines of code. The heavy lifting โ€” manifest injection, precache list generation, cache strategy selection โ€” is done by the build plugin.

Stripe

Stripe handles checkout, payment processing, and webhooks. The hosted Checkout Session means we never touch card data โ€” Stripe renders the payment form, handles 3DS authentication, and tells us the result via webhook.

The webhook-based model is the right pattern for payments: don't trust client-side success redirects, wait for the server-to-server confirmation. Webhook signatures are verified on every incoming event.

For an app where the physical stone is the entry point, Stripe's support for shipping address collection and international payments matters. We haven't needed to handle tax collection yet โ€” at early-access volume, that complexity is deferred.

Sentry + PostHog

Sentry for errors; PostHog for analytics. Both are disabled by default in the .env template โ€” the env vars are commented out, so they only activate when explicitly configured.

In production, Sentry catches unhandled exceptions with stack traces. The captureException() wrapper adds route context so exceptions are grouped meaningfully.

PostHog is configured but not actively used yet. The plan is to enable it for the wider launch, when knowing which features users actually use becomes more important than not adding SDK weight during rapid iteration.

What Would Be Different

With hindsight:

Auth.js v5 is rough. The v5 API is substantially different from v4 and the documentation hasn't fully caught up. Several hours were spent debugging behaviors that turned out to be v5-specific quirks. We'd still use it โ€” the credentials provider works, session management is solid โ€” but the upgrade cost was higher than expected.

The dual database driver is a leaky abstraction. The postgres.js (local) / Neon HTTP (production) split works, but occasional divergence in how the two drivers handle edge cases has caused subtle bugs. A single driver across environments would be cleaner; we'd evaluate Neon's local development story more carefully next time.

Upstash might be over-engineered for the current scale. At 50 users, in-memory rate limiting per function instance would be fine. Upstash adds latency (one HTTP round-trip per request) and a dependency. At actual scale it's the right choice โ€” at this scale it might not be necessary.

Everything else: no regrets.

The Tech Stack: Every Tool and Why It's There ยท scsiwyg