Render Tutorials
Web service or static site?

The hybrid pattern - static frontend + API web service

⏱ 8 min

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

render.yaml
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-256mb

Two 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.com URL 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 - fromService would 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

render.yaml - frontend snippet
envVars:
- key: VITE_API_URL
value: https://my-api.onrender.com

Quickest 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

render.yaml - frontend snippet
domains:
- app.example.com
envVars:
- key: VITE_API_URL
value: https://api.example.com
render.yaml - api snippet
domains:
- api.example.com

What 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:

server.js - Express + cors
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:

render.yaml - api envVars
envVars:
- key: ALLOWED_ORIGIN
value: https://my-frontend.onrender.com

Allowing * 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:

render.yaml
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: connectionString

Add 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.

Your hybrid Blueprint has a static `my-frontend` and a private `my-api`. The frontend's `VITE_API_URL` is set to `http://my-api:10000` (the internal hostname). The deploy succeeds but `fetch(VITE_API_URL + '/users')` from the browser returns 'failed to fetch'. Why?

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