DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

Vercel + Render Hybrid Deployment: Why I Split My Stack (and How)

Most tutorials deploy everything to one platform. Most production apps shouldn't.

Here's the hybrid stack I use for every project — React on Vercel Edge, FastAPI on Render Docker — and exactly why each service is on the platform it's on.

The Stack at a Glance

Frontend  →  Vercel Edge CDN      (React 19 + Vite)
Backend   →  Render Docker        (FastAPI + Uvicorn)
Database  →  Neon Postgres         (Frankfurt, eu-central-1)
Cache     →  Valkey (Redis-compat) (Render internal network)
Enter fullscreen mode Exit fullscreen mode

Each choice is deliberate. Let me explain the reasoning.

Why Vercel for the Frontend

Vercel does one thing better than anyone else: deploying JavaScript frontends at the edge.

What you get:

  • Global CDN with ~50 PoPs — sub-50ms TTFB worldwide
  • Automatic branch previews on every PR
  • Edge middleware for rewrites, redirects, A/B testing
  • Zero-config HTTPS, HTTP/2, Brotli compression
  • Image optimisation via next/image (or custom transforms)

For a React SPA built with Vite, Vercel's CDN just serves static files. The edge network means the HTML/JS/CSS is physically close to your user — before their first API call.

The free tier is genuinely production-ready for personal projects and small SaaS. Bandwidth limits only matter at scale.

Why Render for the Backend

FastAPI needs a real server — persistent process, filesystem access, long-running connections for WebSockets. Serverless doesn't work well for this.

Render Docker gives you:

# The Dockerfile I use for FastAPI on Render
FROM python:3.14-slim

WORKDIR /app

# Install deps in a separate layer for cache efficiency
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

# Non-root user for security
RUN adduser --disabled-password --gecos '' appuser
USER appuser

EXPOSE 8000

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
Enter fullscreen mode Exit fullscreen mode

Render auto-deploys on push to main, handles HTTPS termination, and gives you proper logs. The --workers 2 gives you two Uvicorn processes per instance — enough for most early-stage SaaS traffic.

Render's internal network is important: your Valkey/Redis instance talks to your FastAPI instance on a private network. No public exposure, no auth tokens needed, ~0.1ms latency.

The CORS Configuration

Split deployment means your API and frontend are on different domains. CORS must be explicit:

# main.py
from fastapi.middleware.cors import CORSMiddleware

ALLOWED_ORIGINS = [
    "https://your-app.vercel.app",         # Vercel preview domain
    "https://yourdomain.com",              # Production custom domain
    "https://*.vercel.app",                # All branch previews
]

# Add localhost in dev
if settings.ENVIRONMENT == "development":
    ALLOWED_ORIGINS.extend([
        "http://localhost:5173",           # Vite dev server
        "http://localhost:3000",
    ])

app.add_middleware(
    CORSMiddleware,
    allow_origins=ALLOWED_ORIGINS,
    allow_credentials=True,               # Required for cookies (refresh tokens)
    allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
    allow_headers=["Authorization", "Content-Type", "X-Request-ID"],
)
Enter fullscreen mode Exit fullscreen mode

allow_credentials=True is required if you're using HTTP-only cookies for refresh tokens (which you should be).

Environment Variables: The Right Pattern

Never hardcode the API URL. Use Vite environment variables:

// src/lib/api.ts
const BASE_URL = import.meta.env.VITE_API_URL;

if (!BASE_URL) {
  throw new Error('VITE_API_URL is not set');
}

export const apiClient = axios.create({
  baseURL: BASE_URL,
  withCredentials: true,    // Send cookies cross-origin
  timeout: 10_000,
});
Enter fullscreen mode Exit fullscreen mode

In Vercel, set per-environment:

  • Production: VITE_API_URL = https://api.yourdomain.com
  • Preview: VITE_API_URL = https://api-staging.onrender.com

In Render:

  • ENVIRONMENT = production
  • DATABASE_URL = postgresql+asyncpg://... (from Neon)
  • REDIS_URL = redis://... (Render internal)
  • ENCRYPTION_KEY_PRIMARY = ...

Database: Neon Postgres in Frankfurt

For GDPR compliance, EU data stays in the EU. Neon's eu-central-1 (Frankfurt) region puts Postgres physically close to both the Render instance (also Frankfurt) and the user base.

Neon's connection pooling via PgBouncer is essential for serverless/edge workloads:

# database.py
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker

# Use the pooled connection string from Neon dashboard
# It routes through PgBouncer — handles connection limits properly
DATABASE_URL = os.environ["DATABASE_URL"]

engine = create_async_engine(
    DATABASE_URL,
    pool_size=5,           # Per-worker pool
    max_overflow=10,
    pool_pre_ping=True,    # Detect stale connections
    pool_recycle=300,      # Recycle connections every 5 min
)

AsyncSessionLocal = sessionmaker(
    engine, class_=AsyncSession, expire_on_commit=False
)
Enter fullscreen mode Exit fullscreen mode

The pool_pre_ping=True is important on Neon — serverless databases can pause and the connection will appear valid but fail on first use without it.

CI/CD: GitHub Actions → Both Platforms

One push to main triggers both deploys in parallel:

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: '3.14' }
      - run: pip install -r requirements.txt
      - run: pytest --tb=short -q
        env:
          DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
          ENCRYPTION_KEY_PRIMARY: ${{ secrets.TEST_ENCRYPTION_KEY }}

  deploy-backend:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - name: Trigger Render deploy
        run: |
          curl -X POST "${{ secrets.RENDER_DEPLOY_HOOK }}"

  deploy-frontend:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: npm ci && npm run build
        env:
          VITE_API_URL: ${{ secrets.VITE_API_URL }}
      - uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          vercel-args: '--prod'
Enter fullscreen mode Exit fullscreen mode

Tests gate both deploys. Neither platform gets a broken build.

Health Checks and Monitoring

Render can restart unhealthy instances automatically:

# routes/health.py
from fastapi import APIRouter
from sqlalchemy import text

router = APIRouter()

@router.get("/health")
async def health_check(db: AsyncSession = Depends(get_async_db)):
    try:
        await db.execute(text("SELECT 1"))
        return {"status": "ok", "db": "connected"}
    except Exception as e:
        return JSONResponse(
            status_code=503,
            content={"status": "unhealthy", "error": str(e)}
        )
Enter fullscreen mode Exit fullscreen mode

In Render settings: set health check path to /health, threshold to 3 failures. Render will replace the instance automatically.

The Latency Profile

With Frankfurt for both Render and Neon:

Hop Latency
Browser → Vercel Edge (nearest PoP) ~10–30ms
JS/CSS/assets served from edge ~5ms
API call: Browser → Render (Frankfurt) ~20–60ms (EU users)
FastAPI → Neon Postgres ~3–8ms (same region)
FastAPI → Valkey Redis ~0.5–2ms (internal network)

For EU users, total page load feels fast. For users outside EU, the API calls will be slower — if that matters, you'd add a Render instance in another region or put a CDN in front of the API.

Cost at Small Scale

Service Tier Monthly cost
Vercel Hobby Free
Render Starter (512MB) $7
Neon Free tier (0.5 CU) Free
Valkey on Render Starter $7
Total ~$14/month

At this price point, you have a fully production-grade stack with proper separation of concerns, GDPR-compliant EU data residency, CI/CD, health checks, and auto-deploy.

Scale to the next tier when you need it — the architecture doesn't change.


Running this stack on a project? I'd be happy to review your setup. Get in touch.

Top comments (0)