TT Lab

한국어 English 日本語

Get started
Blog

Blog

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

Hear it discussed · 6 minTwo hosts talk this post through

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:

┌──────────────────────────────────────────────────┐
│        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.js       │
│  Storage:   Supabase Storage / Cloudflare R2  │
│  Deploy:    Vercel                            │
│  Analytics: PostHog (self-hosted) / Plausible │
│  Payments:  Stripe                            │
│  Email:     Resend                            │
│  Monitoring: 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 / Typesense        │
│  CDN:        + Cloudflare                     │
│  Monitoring: + Better Stack / Grafana Cloud   │
│  CI/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 / GKE           │
│  Database:    RDS PostgreSQL Multi-AZ         │
│  Cache:       ElastiCache Redis Cluster       │
│  CDN:         CloudFront + S3                 │
│  Queue:       SQS / RabbitMQ                  │
│  Search:      ElasticSearch / OpenSearch      │
│  CI/CD:       GitHub Actions + ArgoCD         │
│  Monitoring:  Datadog / Grafana Stack         │
│  Log:         CloudWatch / Loki               │
│  IaC:         Terraform                       │
│                                               │
│  Team Size: 10-30                             │
│  Monthly 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

11.3 Premature Microservices

When a team of 3 adopts microservices:

11.4 Ignoring Technical Debt

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

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
Sign in to like

Comments

No comments yet.

Sign in to leave a comment