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 = productionDATABASE_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.