Skip to main content
All posts

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

16 May 2026

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)

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:

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"]

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"],
)

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,
});

In Vercel, set per-environment:

In Render:

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
)

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'

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)}
        )

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:

HopLatency
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

ServiceTierMonthly cost
VercelHobbyFree
RenderStarter (512MB)$7
NeonFree tier (0.5 CU)Free
Valkey on RenderStarter$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.

Building something like this?

I build production-grade Python + React applications. Let's talk about your project.

Get in touch