Skip to content
Published on

Startup Tech Stack Selection Guide 2025: Technical Decisions from 0→1 to Series B

Authors

1. Tech Stack Philosophy

1.1 The Boring Technology Principle

Dan McKinley's "Choose Boring Technology" essay is the bible of startup technology selection. The core argument:

Innovation Tokens: Every team has a limited number of "innovation tokens." Each time you adopt a new, unproven technology, you spend a token. Since your business itself is already one innovation, there is no need to take risks in your technology stack as well.

┌─────────────────────────────────────────────────────┐
Innovation Token Allocation├─────────────────────────────────────────────────────┤
│                                                     │
Available Innovation Tokens: 3│                                                     │
Good Example:│  ├── Business model (innovation) ... 1 token used   │
│  ├── PostgreSQL (boring) ........... 0 tokens       │
│  ├── Next.js (proven) ............. 0 tokens        │
│  ├── Custom ML pipeline (innovation) 1 token used   │
│  └── Remaining: 1 token (reserve)│                                                     │
Bad Example:│  ├── Business model (innovation) ... 1 token used   │
│  ├── CockroachDB (novel) .......... 1 token used    │
│  ├── Bun Runtime (novel) .......... 1 token used    │
│  ├── Custom ML pipeline (innovation) no tokens!│  └── Remaining: 0 tokens (risky)│                                                     │
└─────────────────────────────────────────────────────┘

1.2 Monolith First

Martin Fowler's "Monolith First" principle: Do not start with microservices. Start with a monolith and split when necessary.

Why monolith first:

  • It is nearly impossible to draw service boundaries correctly up front
  • Development speed is faster (no inter-service communication overhead)
  • Debugging is easier (single process)
  • Transaction management is simple (no distributed transactions)
  • Deployment is straightforward (single artifact)
┌──────────────────────────────────────────────────┐
Architecture Evolution Path│                                                  │
Stage 0: Monolith (MVP)│  ├── Single Next.js app                          │
│  ├── Single DB (Supabase)│  └── Single deployment (Vercel)│           │                                       │
v (PMF achieved)Stage 1: Modular Monolith│  ├── Domain-based module separation              │
│  ├── Internal API boundaries defined             │
│  └── DB schema separation begins                 │
│           │                                       │
v (10+ team, traffic surge)Stage 2: Selective Service Extraction│  ├── Extract only bottleneck services            │
│  ├── Event-driven async processing               │
│  └── Caching layer added                         │
│           │                                       │
v (30+ team, global expansion)Stage 3: MSA (only when needed)│  ├── Independently deployable services           │
│  ├── Service mesh                                │
│  └── Centralized observability                   │
└──────────────────────────────────────────────────┘

1.3 PaaS-First Strategy

Do not spend time managing infrastructure. Start with PaaS and move to IaaS when needed.

ApproachInfra Management TimeFlexibilityCost (Early)Cost (Growth)
PaaS (Vercel, Railway)MinimalLowFree-$50Can get expensive
Managed (AWS ECS, GKE)MediumMedium$100-500Medium
Self-managed (K8s on EC2)HighHigh$200-1000Optimizable

Core Principle: In the early stages, development speed matters more than cost optimization. Spending time on infrastructure optimization before finding PMF is wasted effort.


2. Stage 0: MVP ($0-50/month)

┌───────────────────────────────────────────────┐
MVP Tech Stack (2025)├───────────────────────────────────────────────┤
│                                               │
Frontend:  Next.js 15 (App Router)Styling:   Tailwind CSS + shadcn/ui          │
Backend:   Next.js API Routes / Server Actions│
Database:  Supabase (PostgreSQL)Auth:      Supabase Auth / NextAuth.jsStorage:   Supabase Storage / Cloudflare R2Deploy:    VercelAnalytics: PostHog (self-hosted) / PlausiblePayments:  StripeEmail:     ResendMonitoring: Vercel Analytics + Sentry│                                               │
Total Monthly Cost: $0 ~ $50                 │
  (Vercel Free + Supabase Free Tier)│                                               │
└───────────────────────────────────────────────┘

2.2 Next.js + Supabase Project Setup

# Create project
npx create-next-app@latest my-startup --typescript --tailwind --app --src-dir
cd my-startup

# Core dependencies
npm install @supabase/supabase-js @supabase/ssr
npm install stripe @stripe/stripe-js
npm install resend
npm install zod react-hook-form @hookform/resolvers

# UI library
npx shadcn@latest init
npx shadcn@latest add button card input form dialog toast

# Dev tools
npm install -D prettier eslint-config-prettier
npm install -D @types/node

2.3 Supabase Configuration

// src/lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function createClient() {
  const cookieStore = await cookies()

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // Cannot set in Server Components - ignore
          }
        },
      },
    }
  )
}

// src/lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}

2.4 Authentication Implementation

// src/app/auth/login/page.tsx
'use client'

import { createClient } from '@/lib/supabase/client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'

export default function LoginPage() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [loading, setLoading] = useState(false)
  const router = useRouter()
  const supabase = createClient()

  const handleLogin = async (e: React.FormEvent) => {
    e.preventDefault()
    setLoading(true)

    const { error } = await supabase.auth.signInWithPassword({
      email,
      password,
    })

    if (error) {
      alert(error.message)
    } else {
      router.push('/dashboard')
      router.refresh()
    }
    setLoading(false)
  }

  const handleGoogleLogin = async () => {
    await supabase.auth.signInWithOAuth({
      provider: 'google',
      options: {
        redirectTo: `${window.location.origin}/auth/callback`,
      },
    })
  }

  return (
    <div className="flex min-h-screen items-center justify-center">
      <form onSubmit={handleLogin} className="w-full max-w-md space-y-4 p-8">
        <h1 className="text-2xl font-bold">Sign In</h1>
        <input
          type="email"
          placeholder="Email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          className="w-full rounded border p-2"
          required
        />
        <input
          type="password"
          placeholder="Password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          className="w-full rounded border p-2"
          required
        />
        <button
          type="submit"
          disabled={loading}
          className="w-full rounded bg-blue-600 p-2 text-white"
        >
          {loading ? 'Signing in...' : 'Sign In'}
        </button>
        <button
          type="button"
          onClick={handleGoogleLogin}
          className="w-full rounded border p-2"
        >
          Sign in with Google
        </button>
      </form>
    </div>
  )
}

2.5 Stripe Payment Integration

// src/app/api/stripe/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@/lib/supabase/server'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(req: NextRequest) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const { priceId } = await req.json()

  const session = await stripe.checkout.sessions.create({
    customer_email: user.email,
    line_items: [{ price: priceId, quantity: 1 }],
    mode: 'subscription',
    success_url: `${req.nextUrl.origin}/dashboard?success=true`,
    cancel_url: `${req.nextUrl.origin}/pricing?canceled=true`,
    metadata: {
      userId: user.id,
    },
  })

  return NextResponse.json({ url: session.url })
}

// src/app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@supabase/supabase-js'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const supabaseAdmin = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function POST(req: NextRequest) {
  const body = await req.text()
  const sig = req.headers.get('stripe-signature')!

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session
      const userId = session.metadata?.userId

      if (userId) {
        await supabaseAdmin
          .from('subscriptions')
          .upsert({
            user_id: userId,
            stripe_customer_id: session.customer as string,
            stripe_subscription_id: session.subscription as string,
            status: 'active',
            plan: 'pro',
          })
      }
      break
    }
    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription
      await supabaseAdmin
        .from('subscriptions')
        .update({ status: 'canceled' })
        .eq('stripe_subscription_id', subscription.id)
      break
    }
  }

  return NextResponse.json({ received: true })
}

2.6 MVP Cost Analysis

ServiceFree TierPaid TriggerCost
Vercel100GB bandwidth, unlimited deploysTraffic increasePro: $20/mo
Supabase500MB DB, 1GB storageUsage increasePro: $25/mo
StripeUsage-basedRevenue starts2.9% + $0.30/txn
Resend100 emails/dayConversion testing$20/mo
Sentry5K events/moError volume$26/mo
PostHog1M events/moAdvanced analytics$0 (self-hosted)

Total MVP Cost: $0-50/month (essentially free until paid conversion)


3. Stage 1: Product-Market Fit ($200-500/month)

3.1 Technology Changes After PMF

After finding PMF, upgrades are needed in these areas:

┌───────────────────────────────────────────────┐
Stage 1 Tech Stack Upgrades├───────────────────────────────────────────────┤
│                                               │
Database:   Supabase Pro -> larger instance   │
Cache:      + Redis (Upstash)Queue:      + BullMQ (Redis-based)Search:     + Meilisearch / TypesenseCDN:        + CloudflareMonitoring: + Better Stack / Grafana CloudCI/CD:      GitHub Actions enhanced          │
Error:      Sentry Pro│                                               │
Monthly Cost: $200 ~ $500                    │
└───────────────────────────────────────────────┘

3.2 Adding Redis Cache

// src/lib/cache.ts
import { Redis } from '@upstash/redis'

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})

export async function getCached<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttl: number = 3600 // 1 hour
): Promise<T> {
  const cached = await redis.get<T>(key)
  if (cached !== null) {
    return cached
  }

  const data = await fetcher()
  await redis.set(key, data, { ex: ttl })

  return data
}

export async function invalidateCache(pattern: string) {
  const keys = await redis.keys(pattern)
  if (keys.length > 0) {
    await redis.del(...keys)
  }
}

// Usage
// const user = await getCached(
//   `user:${userId}`,
//   () => db.user.findUnique({ where: { id: userId } }),
//   1800 // 30 minutes
// )

3.3 Background Job Queue

// src/lib/queue.ts
import { Queue, Worker, Job } from 'bullmq'
import { Redis } from 'ioredis'

const connection = new Redis(process.env.REDIS_URL!, {
  maxRetriesPerRequest: null,
})

export const emailQueue = new Queue('email', { connection })
export const analyticsQueue = new Queue('analytics', { connection })

const emailWorker = new Worker(
  'email',
  async (job: Job) => {
    const { to, subject, template, data } = job.data
    console.log(`Sending email to ${to}: ${subject}`)
    // await resend.emails.send(...)
  },
  {
    connection,
    concurrency: 5,
    limiter: {
      max: 100,
      duration: 60000, // 100 per minute
    },
  }
)

emailWorker.on('completed', (job) => {
  console.log(`Email job ${job.id} completed`)
})

emailWorker.on('failed', (job, err) => {
  console.error(`Email job ${job?.id} failed:`, err)
})

3.4 Database Optimization

-- Index optimization
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
CREATE INDEX CONCURRENTLY idx_orders_user_created
  ON orders(user_id, created_at DESC);
CREATE INDEX CONCURRENTLY idx_products_category_status
  ON products(category_id, status) WHERE status = 'active';

-- Partitioning (when event tables grow large)
CREATE TABLE events (
  id BIGSERIAL,
  user_id UUID NOT NULL,
  event_type TEXT NOT NULL,
  payload JSONB,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (created_at);

CREATE TABLE events_2025_01 PARTITION OF events
  FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE events_2025_02 PARTITION OF events
  FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');

4. Stage 2: Growth ($2K-10K/month)

4.1 Growth Stage Tech Stack

┌───────────────────────────────────────────────┐
Stage 2 Tech Stack├───────────────────────────────────────────────┤
│                                               │
Compute:     AWS ECS Fargate / GKEDatabase:    RDS PostgreSQL Multi-AZCache:       ElastiCache Redis ClusterCDN:         CloudFront + S3Queue:       SQS / RabbitMQSearch:      ElasticSearch / OpenSearchCI/CD:       GitHub Actions + ArgoCDMonitoring:  Datadog / Grafana StackLog:         CloudWatch / LokiIaC:         Terraform│                                               │
Team Size: 10-30Monthly Cost: $2,000 ~ $10,000└───────────────────────────────────────────────┘

4.2 Kubernetes Adoption

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-server
  labels:
    app: api-server
spec:
  replicas: 3
  selector:
    matchLabels:
      app: api-server
  template:
    metadata:
      labels:
        app: api-server
    spec:
      containers:
      - name: api
        image: my-registry/api-server:latest
        ports:
        - containerPort: 3000
        resources:
          requests:
            memory: "256Mi"
            cpu: "250m"
          limits:
            memory: "512Mi"
            cpu: "500m"
        readinessProbe:
          httpGet:
            path: /health
            port: 3000
          initialDelaySeconds: 5
          periodSeconds: 10
        livenessProbe:
          httpGet:
            path: /health
            port: 3000
          initialDelaySeconds: 15
          periodSeconds: 20
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-server-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api-server
  minReplicas: 3
  maxReplicas: 20
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70

4.3 Terraform Infrastructure as Code

# terraform/main.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }

  backend "s3" {
    bucket = "my-startup-terraform-state"
    key    = "production/terraform.tfstate"
    region = "us-east-1"
  }
}

resource "aws_db_instance" "main" {
  identifier     = "my-startup-production"
  engine         = "postgres"
  engine_version = "16.1"
  instance_class = "db.r6g.large"

  allocated_storage     = 100
  max_allocated_storage = 500
  storage_encrypted     = true

  multi_az               = true
  backup_retention_period = 14

  performance_insights_enabled = true
  monitoring_interval          = 60

  tags = {
    Environment = "production"
    Service     = "database"
  }
}

resource "aws_elasticache_replication_group" "main" {
  replication_group_id       = "my-startup-cache"
  description                = "Redis cache cluster"
  node_type                  = "cache.r6g.large"
  num_cache_clusters         = 2
  automatic_failover_enabled = true
  multi_az_enabled           = true
  engine_version             = "7.0"
  port                       = 6379

  at_rest_encryption_enabled = true
  transit_encryption_enabled = true
}

5. Stage 3: Scale ($10K+/month)

5.1 Scale Stage Architecture

┌─────────────────────────────────────────────────────────┐
Stage 3 Architecture├─────────────────────────────────────────────────────────┤
│                                                         │
│  ┌──────────┐    ┌──────────┐    ┌──────────┐          │
│  │CloudFront│───>ALB/NLB  │───>K8s      │          │
│  │  + WAF   │    │          │    │ Cluster  │          │
│  └──────────┘    └──────────┘    └──────────┘          │
│                                       │                 │
│                     ┌─────────────────┼──────────┐     │
│                     │                 │          │     │
│                     v                 v          v     │
│              ┌───────────┐   ┌───────────┐ ┌────────┐ │
│              │API Gateway│Worker    │ │ Cron   │ │
│              │  Service  │   │  Service   │ │Service │ │
│              └─────┬─────┘   └─────┬─────┘ └───┬────┘ │
│                    │               │            │      │
│      ┌─────────────┼───────────────┼────────────┤     │
│      │             │               │            │     │
│      v             v               v            v     │
│  ┌────────┐  ┌─────────┐   ┌──────────┐  ┌────────┐ │
│  │RDS     │  │Redis    │   │  Kafka   │  │  S3    │ │
│  │Multi-AZ│  │Cluster  │   │ Cluster  │  │        │ │
│  └────────┘  └─────────┘   └──────────┘  └────────┘ │
│                                    │                  │
│                              ┌─────┴─────┐           │
│                              │ElasticSearch│           │
│                              │  Cluster   │           │
│                              └───────────┘           │
│                                                       │
Team Size: 30+Monthly Cost: $10,000+└─────────────────────────────────────────────────────────┘

5.2 Event-Driven Architecture (Kafka)

// src/events/producer.ts
import { Kafka, Partitioners } from 'kafkajs'

const kafka = new Kafka({
  clientId: 'my-startup',
  brokers: (process.env.KAFKA_BROKERS || '').split(','),
  ssl: true,
  sasl: {
    mechanism: 'scram-sha-256',
    username: process.env.KAFKA_USERNAME!,
    password: process.env.KAFKA_PASSWORD!,
  },
})

const producer = kafka.producer({
  createPartitioner: Partitioners.DefaultPartitioner,
  idempotent: true,
})

export async function publishEvent(topic: string, event: {
  type: string
  payload: Record<string, unknown>
}) {
  await producer.connect()

  const message = {
    key: event.payload.id as string || crypto.randomUUID(),
    value: JSON.stringify({
      ...event,
      timestamp: new Date().toISOString(),
      version: '1.0',
    }),
    headers: {
      'event-type': event.type,
      'content-type': 'application/json',
    },
  }

  await producer.send({
    topic,
    messages: [message],
  })
}

5.3 ElasticSearch Integration

// src/lib/search.ts
import { Client } from '@elastic/elasticsearch'

const esClient = new Client({
  node: process.env.ELASTICSEARCH_URL!,
  auth: {
    apiKey: process.env.ELASTICSEARCH_API_KEY!,
  },
})

export async function searchProducts(query: string, filters?: {
  category?: string
  minPrice?: number
  maxPrice?: number
  page?: number
  size?: number
}) {
  const must: Record<string, unknown>[] = []
  const filter: Record<string, unknown>[] = []

  if (query) {
    must.push({
      multi_match: {
        query,
        fields: ['name^3', 'description', 'tags^2'],
        type: 'best_fields',
        fuzziness: 'AUTO',
      },
    })
  }

  if (filters?.category) {
    filter.push({ term: { category: filters.category } })
  }

  if (filters?.minPrice || filters?.maxPrice) {
    const range: Record<string, number> = {}
    if (filters.minPrice) range.gte = filters.minPrice
    if (filters.maxPrice) range.lte = filters.maxPrice
    filter.push({ range: { price: range } })
  }

  const result = await esClient.search({
    index: 'products',
    body: {
      query: {
        bool: { must, filter },
      },
      from: ((filters?.page || 1) - 1) * (filters?.size || 20),
      size: filters?.size || 20,
      highlight: {
        fields: { name: {}, description: {} },
      },
    },
  })

  return {
    total: (result.hits.total as { value: number }).value,
    hits: result.hits.hits.map((hit) => ({
      ...hit._source,
      score: hit._score,
      highlights: hit.highlight,
    })),
  }
}

6. Frontend Stack

6.1 Framework Comparison

CriterionNext.js 15RemixSvelteKit
RenderingSSR/SSG/ISR/RSCSSR/CSRSSR/SSG/CSR
Server ComponentsYes (RSC)NoNo (uses runes)
RoutingFile-based (App Router)File-basedFile-based
Data FetchingServer Components, fetchLoader/ActionLoad functions
DeploymentVercel-optimized, anywhereAnywhereAnywhere
EcosystemVery largeMediumGrowing
Learning CurveMedium-HighMediumLow-Medium
Startup RecStrongly recommendedRecommendedSmall teams

Startup conclusion: As of 2025, Next.js is the safest choice. It leads in ecosystem, hiring market, and deployment infrastructure.

6.2 UI Component Strategy

Recommended Stack:
├── Tailwind CSS ........... Utility-first CSS
├── shadcn/ui .............. Copy-paste components (ownership)
├── Radix UI ............... Accessible headless components
├── Framer Motion .......... Animations
└── Lucide Icons ........... Icon set

Alternatives:
├── Mantine ................ All-in-one UI library
├── Ark UI ................. Headless from Chakra UI team
└── Park UI ................ Ark UI + Tailwind

6.3 State Management

LibraryComplexityUse CaseStartup Rec
React useState/useContextMinimalLocal stateDefault
ZustandLowGlobal stateStrongly recommended
JotaiLowAtomic stateRecommended
TanStack QueryMediumServer stateEssential
Redux ToolkitHighComplex stateNot recommended (early)
// Zustand example - simple store
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface CartStore {
  items: Array<{ id: string; name: string; price: number; quantity: number }>
  addItem: (item: { id: string; name: string; price: number }) => void
  removeItem: (id: string) => void
  clearCart: () => void
  total: () => number
}

export const useCartStore = create<CartStore>()(
  persist(
    (set, get) => ({
      items: [],
      addItem: (item) =>
        set((state) => {
          const existing = state.items.find((i) => i.id === item.id)
          if (existing) {
            return {
              items: state.items.map((i) =>
                i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
              ),
            }
          }
          return { items: [...state.items, { ...item, quantity: 1 }] }
        }),
      removeItem: (id) =>
        set((state) => ({
          items: state.items.filter((i) => i.id !== id),
        })),
      clearCart: () => set({ items: [] }),
      total: () =>
        get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
    }),
    { name: 'cart-storage' }
  )
)

7. Backend Stack

7.1 Language Comparison (Startup Context)

CriterionNode.js (TS)GoPythonJava/Kotlin
Dev SpeedFastMediumFastSlow
PerformanceMediumVery HighLowHigh
Hiring DifficultyEasyMediumEasyMedium
Full-stack PossibleYes (Next.js)NoLimitedNo
EcosystemVery LargeLargeVery Large (ML)Very Large
Startup RecBestWhen performance mattersML/DataEnterprise

7.2 Recommendations by Startup Type

B2C SaaS -> Node.js (TypeScript) + Next.js
├── One full-stack dev can build everything
├── Vercel deployment minimizes infra concerns
└── Frontend-backend type sharing

B2B SaaS -> Node.js or Go
├── Go if API performance is critical
├── Node.js if rapid feature development matters
└── Both are good choices

AI/ML Startup -> Python + TypeScript
├── ML pipelines in Python
├── API server in FastAPI (Python) or Next.js (TS)
└── Frontend in Next.js

Fintech -> Go or Java/Kotlin
├── High performance and reliability needed
├── Static type system safety
└── Strong ecosystem for financial regulation

Gaming/Realtime -> Go or Rust
├── High concurrency handling
├── WebSocket server performance
└── Low latency

7.3 API Design Patterns

// src/app/api/v1/products/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createClient } from '@/lib/supabase/server'

const createProductSchema = z.object({
  name: z.string().min(1).max(200),
  description: z.string().max(5000).optional(),
  price: z.number().positive(),
  category: z.string(),
  tags: z.array(z.string()).max(10).optional(),
})

export async function GET(req: NextRequest) {
  const searchParams = req.nextUrl.searchParams
  const page = parseInt(searchParams.get('page') || '1')
  const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100)
  const category = searchParams.get('category')

  const supabase = await createClient()

  let query = supabase
    .from('products')
    .select('*', { count: 'exact' })
    .eq('status', 'active')
    .range((page - 1) * limit, page * limit - 1)
    .order('created_at', { ascending: false })

  if (category) {
    query = query.eq('category', category)
  }

  const { data, count, error } = await query

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 })
  }

  return NextResponse.json({
    data,
    pagination: {
      page,
      limit,
      total: count || 0,
      totalPages: Math.ceil((count || 0) / limit),
    },
  })
}

export async function POST(req: NextRequest) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  const body = await req.json()
  const parsed = createProductSchema.safeParse(body)

  if (!parsed.success) {
    return NextResponse.json(
      { error: 'Validation failed', details: parsed.error.flatten() },
      { status: 400 }
    )
  }

  const { data, error } = await supabase
    .from('products')
    .insert({ ...parsed.data, user_id: user.id })
    .select()
    .single()

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 })
  }

  return NextResponse.json({ data }, { status: 201 })
}

8. Database

8.1 PostgreSQL vs MySQL

CriterionPostgreSQLMySQL
JSON SupportJSONB (indexable)JSON (limited)
Full-text SearchBuilt-in (tsvector)Built-in (FULLTEXT)
ExtensionsRich (PostGIS, etc.)Limited
ReplicationLogical/PhysicalPhysical (default)
PerformanceRead/write balancedRead optimized
Startup RecStrongly recommendedOK if team knows MySQL

8.2 BaaS Comparison

ServiceDB EngineStarting PriceFeaturesStartup Rec
SupabasePostgreSQLFreeAuth, Storage, Realtime includedBest for MVP
NeonPostgreSQLFreeServerless, branchingGood
PlanetScaleMySQL (Vitess)FreeBranching, zero-downtime schemaGood
TursoSQLite (libSQL)FreeEdge, very fastSmall apps
CockroachDBPostgreSQL compatPaidDistributed SQL, globalLarge apps

8.3 When to Add Redis

When to introduce Redis:
├── Same query runs 100+ times per second
├── API response time exceeds 500ms
├── Session management needed
├── Real-time features (leaderboard, counters) needed
├── Rate limiting required
└── Distributed locking needed

When you do NOT need Redis yet:
├── DAU under 1,000
├── DB queries under 20ms
├── Next.js built-in cache is sufficient
└── Cost is a constraint

9. Infrastructure

9.1 When to Move from PaaS to IaaS

When to move from Vercel/Railway to AWS/GCP:

1. Cost Threshold
   ├── Vercel Pro costs exceed $500/month
   ├── Bandwidth costs more than compute
   └── Function execution time limits are hit

2. Technical Requirements
   ├── Long-running processes needed (5+ minutes)
   ├── WebSocket is a core feature
   ├── GPU computing needed
   └── Custom network configuration needed

3. Regulatory/Compliance
   ├── Data residency regulations
   ├── SOC 2, HIPAA certification needed
   └── VPC isolation required

When NOT to move:
├── Monthly cost under $500
├── Traffic is predictable and stable
├── Serverless architecture is sufficient
└── No dedicated DevOps staff

9.2 Cloud Comparison

CriterionAWSGCPAzureVercel
Startup Credits$10K-100K$200K (Google for Startups)$150KNone (free tier)
Service BreadthBestHighHighFrontend focused
Learning CurveHighMediumHighLow
Startup RecSeries A+ML-focusedEnterpriseMVP-PMF

9.3 Cost Optimization Checklist

  1. Spot/Preemptible Instances (up to 90% savings)
  2. Reserved Instances 1-year commitment (up to 40% savings)
  3. Right-sizing: Adjust instance sizes based on actual usage
  4. Auto-scaling: Scale based on traffic
  5. S3 Lifecycle Policies: Move old data to Glacier
  6. CloudFront Caching: Minimize origin requests
  7. NAT Gateway Cost Awareness: Data transfer costs add up
  8. Dev/Staging Environments: Auto-shutdown nights/weekends

10. Hiring and Tech Stack

10.1 How Tech Stack Affects Hiring

Technology choices directly determine your hiring pool:

TechnologyDeveloper PoolJunior RatioSenior Hiring Difficulty
React/Next.jsVery largeHighMedium
TypeScriptLargeMediumMedium
PythonVery largeHighHard (ML field)
GoSmallLowHigh
RustVery smallVery lowVery high
Java/KotlinVery largeHighMedium
PostgreSQLLargeMediumMedium
KubernetesMediumLowHigh

10.2 Hiring-Friendly Tech Stacks

Easy to hire for:
├── Next.js + TypeScript (frontend)
├── Node.js + Express/Nest.js (backend)
├── PostgreSQL / MySQL (database)
├── Redis (cache)
├── Docker + GitHub Actions (DevOps)
└── AWS (infrastructure)

Hard to hire for:
├── Svelte / SolidJS (frontend)
├── Rust / Elixir (backend)
├── CockroachDB / ScyllaDB (database)
├── Nomad / Consul (orchestration)
└── Pulumi / CDK (IaC)

11. Common Mistakes

11.1 Resume-Driven Development

Bad decision patterns:
├── "I want K8s experience" -> K8s adoption (DAU 100)
├── "Rust is trending" -> Rust API server (team of 2)
├── "MSA is the standard" -> 3 people managing 10 services
├── "GraphQL is great" -> GraphQL for simple CRUD
└── "Google uses monorepos" -> Turborepo for team of 3

Good decisions:
├── "What is the simplest way to solve this?"
├── "Can we maintain this in 6 months?"
├── "Is the team already familiar with this?"
├── "Is it easy to hire for?"
└── "Is the migration cost manageable?"

11.2 Premature Optimization

  • Creating 40 DB indexes upfront (without knowing query patterns)
  • Building 3-layer caching (100 users/day traffic)
  • Setting up global CDN (all users in one country)
  • Load balancer + auto scaling (5% CPU utilization)

11.3 Premature Microservices

When a team of 3 adopts microservices:

  • Entire days spent debugging inter-service communication
  • Data inconsistency from distributed transaction issues
  • Managing 10 deployment pipelines
  • 2 weeks just to set up monitoring dashboards
  • Result: 50% slower development, 300% higher infrastructure costs

11.4 Ignoring Technical Debt

On the other hand, accumulating too much technical debt is also problematic:

  • 6 months of development without tests makes refactoring impossible
  • Tens of thousands of lines of untyped JavaScript
  • Hard-coded configuration values scattered everywhere
  • No logging/monitoring makes incident response impossible

Balance: Apply the 80/20 rule at the MVP stage. Invest 80% of quality in the 20% of core code.


12. Real Examples

12.1 Vercel Stack

Vercel (Next.js creator):
├── Frontend: Next.js (own product)
├── Backend: Go + Node.js
├── Database: PlanetScale (MySQL) -> Neon (PostgreSQL)
├── Cache: Redis (Upstash)
├── Monorepo: Turborepo-based
├── Deploy: Own platform
├── Monitoring: Custom + Datadog
└── Note: Heavy dogfooding of own products

12.2 Linear Stack

Linear (project management tool):
├── Frontend: React + TypeScript
├── Backend: Node.js + TypeScript
├── Database: PostgreSQL
├── Cache: Redis
├── Sync: CRDT-based real-time synchronization
├── Deploy: Google Cloud
├── Monitoring: Custom built
└── Note: Offline-first, local data priority

12.3 Cal.com Stack

Cal.com (open-source scheduling):
├── Frontend: Next.js + TypeScript
├── Backend: tRPC + Prisma
├── Database: PostgreSQL
├── Auth: NextAuth.js
├── Email: Multiple provider support
├── Deploy: Vercel
├── Monorepo: Turborepo
└── Note: Open source, monorepo, type-safe with tRPC

12.4 Key Takeaways

Common patterns across these examples:

  1. Proven technologies dominate (PostgreSQL, Redis, TypeScript)
  2. Monolith or modular monolith as starting architecture
  3. TypeScript as the default language choice
  4. PaaS-first approach (Vercel, Google Cloud managed)
  5. Innovation investment only in core differentiators

13. Quiz

Q1: What are "Innovation Tokens" in the Boring Technology principle, and why should you conserve them?

A: Innovation Tokens is a concept proposed by Dan McKinley, representing the number of new/unproven technologies a team can handle. Every team has a limited number (typically around 3), and each unproven technology adoption consumes one.

Why conserve them: The startup's business model itself is already one innovation, using up 1 token. If you spend all remaining tokens on the tech stack, you have no capacity to handle problems when they arise. Proven (boring) technologies produce only predictable issues with well-known solutions, reducing operational burden.

Q2: Why is the Next.js + Supabase + Vercel combination recommended for the MVP stage?

A: This combination is recommended because:

  1. Development speed: Next.js full-stack capabilities enable development without frontend/backend separation
  2. Cost: All three services have generous free tiers ($0-50/month)
  3. Zero infrastructure management: Vercel handles deployment/CDN/SSL; Supabase handles DB/Auth/Storage
  4. Type safety: Full-stack type sharing with TypeScript
  5. Scalability: Supabase runs on PostgreSQL, making future migration straightforward
  6. Ecosystem: Easy integration with shadcn/ui, Stripe, Resend, etc.
  7. Hiring: React/Next.js has the largest developer pool

The key is not spending time on infrastructure before finding PMF.

Q3: When should you transition from PaaS (Vercel) to IaaS (AWS)?

A: Transition criteria:

  1. Cost: Vercel costs exceed $500/month (equivalent workload is cheaper on AWS)
  2. Technical constraints: Long-running processes (5+ min), WebSocket-based real-time features, GPU computing needed
  3. Regulation: Data residency requirements, SOC 2/HIPAA certification, VPC isolation needed
  4. Team: Dedicated DevOps/infrastructure staff has been hired

When NOT to transition: Monthly cost under $500, serverless architecture is sufficient, no dedicated DevOps staff. The key is transitioning "when needed," not preparing in advance.

Q4: Why should startups NOT adopt microservices from the start?

A: Reasons not to start with microservices:

  1. Service boundary failure: Before PMF, domains keep changing, making correct service boundaries impossible
  2. Operational overhead: Inter-service communication, distributed transactions, service discovery complexity explodes
  3. Debugging difficulty: Distributed system debugging is 10x harder than monolith
  4. Infrastructure cost: Each service needs its own deployment pipeline, monitoring, logging (3x+ cost)
  5. Team size mismatch: A 3-5 person team managing 10 services is unrealistic

Correct approach: Start monolith, extract services selectively only after sufficient domain understanding and at bottleneck points.

Q5: How does tech stack choice impact hiring?

A: Tech stack directly impacts hiring in several ways:

  1. Pool size: React/TypeScript developers are very numerous; Rust/Elixir developers are extremely scarce
  2. Hiring speed: Popular stacks attract more applicants, enabling faster hiring
  3. Hiring cost: Rare technologies command premium salaries (e.g., senior Go devs cost 20-30% more than senior Node.js devs)
  4. Onboarding: Known technologies mean onboarding in under a week; new technologies take a month or more
  5. Retention: Unpopular technologies create career anxiety, increasing turnover

Recommendation: TypeScript + React + PostgreSQL + AWS is the most hiring-friendly combination for most markets.


References

  1. McKinley, D. "Choose Boring Technology." https://boringtechnology.club/
  2. Fowler, M. "Monolith First." Martin Fowler's blog.
  3. Next.js Documentation (2025). https://nextjs.org/docs
  4. Supabase Documentation. https://supabase.com/docs
  5. Vercel Documentation. https://vercel.com/docs
  6. Stripe Documentation. https://stripe.com/docs
  7. Terraform AWS Provider Documentation.
  8. "The Twelve-Factor App." https://12factor.net/
  9. Kleppmann, M. "Designing Data-Intensive Applications." O'Reilly.
  10. Newman, S. "Building Microservices, 2nd Edition." O'Reilly.
  11. Basecamp. "Getting Real." https://basecamp.com/gettingreal
  12. Linear Engineering Blog. https://linear.app/blog
  13. Cal.com GitHub Repository. https://github.com/calcom/cal.com
  14. AWS Startup Guides. https://aws.amazon.com/startups/
  15. Google for Startups Cloud Program. https://cloud.google.com/startup
  16. Y Combinator Library - Technical Decisions. https://www.ycombinator.com/library