VOOZH about

URL: https://dev.to/uaslimcreate/vercel-render-hybrid-deployment-why-i-split-my-stack-and-how-34n2

⇱ Vercel + Render Hybrid Deployment: Why I Split My Stack (and How) - DEV Community


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:

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

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:

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

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:

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.