on this page

All week your app worked. Your React frontend called http://localhost:3000, your Node backend answered, data showed up on the screen, life was good. Then you deployed: frontend to Cloudflare Pages, backend to Railway, both got real URLs, you opened the live site, and every single request failed. Console full of red. The exact same code that worked five minutes ago on your laptop.

Nothing is broken, exactly. The problem is that localhost was lying to you the whole time, and it was a comfortable lie. On your machine the frontend and backend were two programs sharing one computer, one network, one cozy little world where nothing needed permission to talk to anything. In production they are two separate machines, sitting at two different addresses, possibly in two different countries, and the browser treats them like strangers. Every convenience localhost handed you for free now has to be spelled out by hand.

Let’s go cause by cause, because it’s never one big problem. It’s four small ones stacked up wearing a trench coat.

The API base URL changes, and you can’t hardcode it

The first one is obvious once you see it. Your frontend code says “go fetch from http://localhost:3000/api/users.” On your laptop that resolves to your own backend. On Cloudflare Pages, localhost means the visitor’s own computer, which is not running your backend, so the request goes nowhere.

The base URL has to change to wherever your backend actually lives now, something like https://api.myapp.com or whatever Railway handed you (your-app.up.railway.app). The wrong fix is to paste that string into your code and push. It works, briefly, until you spin up a staging environment, or your backend URL changes, or you want to run locally again, and now you’re editing source and redeploying every time the address moves.

The right fix is a frontend environment variable. You store the URL outside your code and read it at build time. The catch that trips up every beginner is the naming prefix. Frontend build tools refuse to expose env vars to browser code unless the name starts with a specific prefix, so the tool knows you meant to make it public:

# Vite (React, Vue, Svelte)
VITE_API_URL=https://api.myapp.com

# Astro
PUBLIC_API_URL=https://api.myapp.com

# Next.js
NEXT_PUBLIC_API_URL=https://api.myapp.com

Then your code reads the variable instead of a literal string:

const API_URL = import.meta.env.VITE_API_URL;
const res = await fetch(`${API_URL}/api/users`);

Now read that word “public” again, slowly, because it is doing real work. These variables get baked into your JavaScript bundle at build time and shipped to the browser. Anyone who opens devtools can read them. That is fine for an API URL, which was never a secret. It is a catastrophe for a database password or an API key for some paid service, because you would be handing it to every visitor on earth. Frontend env vars are for things that are allowed to be seen. Real secrets stay on the backend, which we’ll get to.

If env vars in general are still fuzzy, the environment variables guide covers the whole idea from the ground up. Come back here for the frontend-talking-to-backend part.

HTTPS, and the mixed content wall

Say you fix the URL and point your frontend at http://api.myapp.com. Still broken, and now the console says something about “mixed content.”

Here’s the rule. Your deployed frontend is served over HTTPS (HyperText Transfer Protocol Secure, the encrypted version of the web’s request language). Every modern host gives you HTTPS automatically, so your site is https://. A page loaded over HTTPS is not allowed to make requests to a plain http:// address. The browser blocks it outright and refuses to even send the request. The logic is reasonable: you went to the trouble of encrypting the page, so letting it quietly pull data over an unencrypted connection would hand an eavesdropper the very thing you were protecting. An encrypted page calling an unencrypted API is a hole, so the browser bricks it up.

The fix is short: your backend needs HTTPS too. Use the https:// version of your backend URL, which Railway, Render, Vercel, and the rest hand you by default. You basically never want plain http to anything in production. If your API URL starts with http://, that’s the bug.

CORS, the one everyone hits

This is the famous one. You fix the URL, you use HTTPS, you reload, and the console hits you with:

Access to fetch at 'https://api.myapp.com/api/users' from origin
'https://myapp.com' has been blocked by CORS policy

People treat CORS like a curse. It’s actually a simple idea with an intimidating name. CORS is Cross-Origin Resource Sharing, and an “origin” is just the combination of protocol, domain, and port: https://myapp.com is one origin, https://api.myapp.com is a different one. On localhost everything shared an origin, so this never came up. In production your frontend and backend sit on different origins, and that’s where the browser gets protective.

The rule the browser enforces is this: your JavaScript on https://myapp.com is not allowed to read a response from a different origin unless that other origin gives explicit permission. The backend grants permission by sending a response header that names which origins are allowed in. No header, no permission, and the browser throws away the response before your code ever touches it, even though the server answered perfectly. The request often succeeds on the server side; the browser just won’t let you see the result.

The part that makes people pull their hair out: CORS is enforced by the browser, and it’s fixed on the backend. You can stare at your frontend code all day. The header has to come from the server. In an Express app, the cors middleware

Heads up. You're leaving raindev.fyi

This link heads to expressjs.com, an external site we don't control. Cool to keep going?

Continue
handles it:

import express from "express";
import cors from "cors";

const app = express();

app.use(
  cors({
    origin: "https://myapp.com", // your frontend's exact origin
    credentials: true,
  })
);

Notice it names the exact origin. A lot of Stack Overflow answers tell you to set origin: "*", which means “let any website in.” That makes the error go away, and for a public read-only API it’s sometimes fine. But the moment cookies enter the picture, the browser flat refuses to honor a wildcard, and "*" becomes a security problem anyway, because now any site on the internet can call your API with its users’ credentials. Name your real origin. It’s two seconds of typing and it’s the correct answer.

Cookies and auth across two domains

If your login uses cookies, prepare for the boss level. Cookies were built for the days when your whole app lived on one domain, so sending them across origins takes three separate switches, all flipped, or the cookie silently vanishes and your user looks logged out.

Walk it through. On login, the backend sets a cookie. For that cookie to ride along on requests to a different site, it needs two attributes:

res.cookie("session", token, {
  httpOnly: true,
  secure: true,       // only sent over HTTPS
  sameSite: "none",   // allowed to be sent cross-site
});

SameSite=None tells the browser this cookie may travel cross-site, and Secure is mandatory the moment you do that, which is another reason both ends need HTTPS. Switch one.

Switch two is on the frontend. By default fetch does not attach cookies to cross-origin requests. You have to ask:

await fetch(`${API_URL}/api/me`, {
  credentials: "include",
});

Switch three is back on the backend, and it’s two things at once. The CORS config needs credentials: true (the Access-Control-Allow-Credentials header), and it must name a specific origin, not "*". The browser will not send credentials to a wildcard origin, full stop. That’s the credentials: true plus exact origin you already saw in the CORS block above, and it’s not optional once cookies are involved.

Miss any one of the three and the symptom is identical and maddening: login looks like it works, then the very next request acts like you were never logged in. Three switches, all on, every time.

There is an escape hatch worth knowing. If instead of cookies you send the login token in an Authorization header (Authorization: Bearer <token>) and have your frontend store it and attach it to each request, most of the cookie ceremony evaporates. No SameSite puzzle, no Secure cookie dance. You still need CORS configured, but the cross-domain cookie headache mostly goes away. It’s a common reason API-first apps lean on tokens over cookies.

Where the real secrets live

We said frontend env vars are public. So where does the sensitive stuff go? On the backend, in backend environment variables, set in your host’s dashboard (Railway, Render, your VPS, wherever the server runs). The database connection string, the JWT signing secret, third-party API keys, all of it lives there and never leaves the server. The browser never sees these and has no way to.

The mental split is the whole game. Frontend env vars: public, baked into files the browser downloads, safe to expose, things like the API URL. Backend env vars: private, live only on the server, never shipped to anyone, things like passwords and signing keys. Put a secret in the wrong bucket and you’ve either broken the app or leaked the keys. If your backend host is new territory, the backend hosting guide covers where these servers actually run.

The classic production bugs, decoded

You will hit most of these at least once. When the live site breaks and localhost is fine, start here:

SymptomCauseFix
CORS policy error in consoleBackend isn’t allowing your frontend’s originAdd the cors middleware with your exact frontend origin
Mixed content blockedHTTPS page calling an http:// APIUse the https:// backend URL everywhere
undefined where the API URL should beEnv var missing or wrong prefixSet VITE_/PUBLIC_/NEXT_PUBLIC_ var, then rebuild
Requests hit your own machine and failAPI base URL still http://localhost:3000Read the URL from a frontend env var
Logged in, next request says logged outCookie not sent cross-siteSameSite=None; Secure, credentials: "include", and credentials: true on the backend
First request hangs ~30s, then worksFree-tier backend asleepIt’s cold-starting; expect it on free plans, or keep it warm

That last one catches everyone. Free tiers on Render and similar hosts spin your backend down when nobody’s using it, so the first request after a quiet spell wakes it up and stalls for half a minute. The frontend is up, the backend is just yawning. Not a bug, just a sleepy server.

The mental model to keep

Here’s the picture to carry with you. In development, your frontend and backend are roommates: one machine, one network, talking freely, no formalities. In production they’ve moved out and live at separate addresses, and the only way they communicate is shouting across the public internet over HTTPS. The browser, standing in the middle, enforces strict rules about who’s allowed to talk to whom and whether cookies get to come along.

Every shortcut localhost quietly handled, the shared origin, the no-CORS, the cookies that just worked, the URL that was always there, now has to be made explicit. Set the API URL through an env var, run everything over HTTPS, allow your exact origin in CORS, and flip all three cookie switches if you use sessions. None of it is hard once you’ve seen it once. It’s just invisible on your laptop and very visible the day you ship.

Which is the point: the first time you connect a real deployed frontend to a real deployed backend and watch data load on the live site, you’ve crossed from “app on my machine” to “thing on the internet other people can use.” That gap is exactly where most side projects stall out. Now you know what’s on the other side of it.