You don’t have to pick one. The most common shape for a real app on Render is two services in one Blueprint: a static site for the frontend and a web service for the API.
This step covers the architecture, the wiring, and the one constraint that surprises every team - static sites can’t reach other services over Render’s private network.
What the topology looks like
flowchart LR
user["Browser"]
subgraph render [Render]
static["my-frontend<br/>(static site)"]
api["my-api<br/>(web service)"]
db[(app-db)]
end
user -->|"GET app.example.com<br/>(HTML/JS bundle)"| static
user -->|"fetch api.example.com/users<br/>(public URL)"| api
api -->|"private network"| db
Read the arrows carefully:
- The browser fetches HTML/JS from the static site over the public CDN.
- The browser then makes API calls directly to the web service over its public URL - not through the static site.
- The web service can talk to its database over the private network as usual.
The static site is a passive bundle of files. It never proxies traffic. Once delivered, it lives in the user’s browser and makes its own requests to the API.
Both services in one Blueprint
services: - type: web runtime: static name: my-frontend buildCommand: npm ci && npm run build staticPublishPath: dist routes: - type: rewrite source: /* destination: /index.html envVars: - key: VITE_API_URL value: https://my-api.onrender.com
- type: web name: my-api runtime: node plan: starter buildCommand: npm ci && npm run build startCommand: npm start healthCheckPath: /health envVars: - key: ALLOWED_ORIGIN value: https://my-frontend.onrender.com - key: DATABASE_URL fromDatabase: name: app-db property: connectionString
databases: - name: app-db plan: basic-256mbTwo services, one database, all wired in one file. The wiring uses the same primitives covered in cross-service wiring - fromDatabase for the API, plain value for the URLs.
The big constraint: no private network for static sites
This trips up teams every time. The instinct is “the static site lives on Render, the API lives on Render - they should be able to talk privately.” But the static site isn’t running anywhere - it’s just files. The traffic is browser → API, not static-site → API.
What this means in practice:
- The API needs a public URL the browser can hit. That’s its
*.onrender.comURL or a custom domain. - The API needs CORS configured to accept requests from the static site’s origin (since they’re different origins).
- You can’t avoid the public hop by “using
fromService” in the static site’s env vars -fromServicewould only give you internal hostnames the browser can’t resolve anyway.
If you want the API hidden from the public internet, you can’t pair it with a static site. Pair it with a web service that does SSR or proxies the API server-side, which keeps the API as a pserv (private service).
Wiring the API URL into the static site
The static site needs to know the API’s URL at build time - env vars on a static site bake into the bundle during npm run build. Two patterns work:
Pattern A: Hardcode the onrender.com URL
envVars: - key: VITE_API_URL value: https://my-api.onrender.comQuickest path. The static site builds with VITE_API_URL=https://my-api.onrender.com in the bundle, and fetch(import.meta.env.VITE_API_URL + "/users") works from the browser.
Pattern B: Custom domains for both
domains: - app.example.comenvVars: - key: VITE_API_URL value: https://api.example.comdomains: - api.example.comWhat you ship in production. Both services have stable custom domains, the API URL never changes when you rename services, and your users see clean URLs.
Configuring CORS on the API
Different origins (my-frontend.onrender.com and my-api.onrender.com) means CORS. The browser blocks cross-origin requests unless the API explicitly opts in.
The minimum:
import express from "express";import cors from "cors";
const app = express();app.use( cors({ origin: process.env.ALLOWED_ORIGIN, credentials: true, }),);Then on the API service:
envVars: - key: ALLOWED_ORIGIN value: https://my-frontend.onrender.comAllowing * works for fully public APIs. Don’t use it for anything that uses cookies or auth - that’s exactly what Access-Control-Allow-Origin: * blocks (it’s incompatible with credentials: true). Be specific.
When the hybrid pattern is the wrong answer
The hybrid is the default for SPAs. There are cases where rolling everything into one service is simpler:
- You already use SSR. Next.js with
next start, Nuxt with the Node adapter, SvelteKit with the Node adapter - they all serve the frontend HTML and answer API routes from the same web service. There’s no benefit to splitting. - You need server-side auth on every page. A single SSR web service that owns auth is simpler than coordinating a static site and a separate auth service.
- Your “API” is just a thin BFF (backend-for-frontend). If 90% of its job is rendering HTML for one specific frontend, it’s an SSR app, not an API.
The hybrid wins when:
- The frontend is a true SPA that calls the API as one of many possible clients (also: a mobile app, a CLI, a third-party integration).
- You want the cost and performance benefits of a CDN-served frontend.
- You want the frontend and the backend to scale independently.
A complete hybrid Blueprint with project + environments
Putting it together with the projects pattern from the advanced Blueprint patterns tutorial, here’s a production-grade two-environment shape:
projects: - name: my-app environments: - name: production databases: - name: app-db plan: basic-256mb
services: - type: web runtime: static name: my-frontend buildCommand: npm ci && npm run build staticPublishPath: dist routes: - type: rewrite source: /* destination: /index.html envVars: - key: VITE_API_URL value: https://my-api.onrender.com
- type: web name: my-api runtime: node plan: starter buildCommand: npm ci && npm run build startCommand: npm start healthCheckPath: /health envVars: - key: ALLOWED_ORIGIN value: https://my-frontend.onrender.com - key: DATABASE_URL fromDatabase: name: app-db property: connectionStringAdd a staging environment by copying the production block under a new name: staging. Same structure, smaller plans, isolated database - straight out of the projects pattern.
What you learned
- The hybrid pattern: static SPA frontend + web service API in one Blueprint, paired with a database the API talks to over the private network
- Static sites are not on the private network - the browser calls the API directly over the public URL
- Inject the API URL into the frontend at build time via env vars (`VITE_API_URL`, `NEXT_PUBLIC_API_URL`, etc.)
- Configure CORS on the API to accept the static site's origin; don't use `*` if you need cookies or auth
- Roll everything into one SSR web service when your frontend is server-rendered or you need server-side auth on every page