Sooner or later your app needs to talk to someone else's — a weather API, an FX rate, a shipping quote, a language model. That means keys. And the moment you paste a secret key into React, you've published it to the world. This lesson is about the one pattern that keeps integrations safe: the app never touches the key, only the server does.
Part 1 · Why secrets can't live in the frontend
Every line of code in your React app ends up in a JavaScript bundle the browser downloads. If a secret is in that bundle, every visitor — including bots scraping public sites for leaked keys — has a copy within seconds of your first page view.
src/.process.env, calls the external API, and returns only the fields the UI needs. The key never crosses the network to the client.view-source: or the Network tab, and search the JS bundle for the first few characters of your key (sk_, AIza, whatever it starts with). Zero matches is the only acceptable answer.Part 2 · The right place for secrets in Lovable Cloud
Lovable Cloud has a secrets manager. Add a secret there and it becomes an environment variable available to server functions only — never to the browser. Ask the agent to add one and it opens a secure form; the value never appears in the codebase or the chat.
process.env.MY_KEY— server-only. Read it inside.handler(), not at the top of the file. This is where API keys, webhook secrets, and service tokens belong.import.meta.env.VITE_*— the opposite. Anything prefixedVITE_is bundled into the browser. Use this only for values that are safe to publish (a publishable Stripe key, a public Mapbox token).- Never a
.envcommitted to the repo. Even if it's git-ignored today, one bad merge and it's in the history forever. Use the secrets manager.
Add a secret calledFX_API_KEY. Then in a server function read it asprocess.env.FX_API_KEYinside the handler. Confirm it works locally and after publish.
process.env.X at the top of a module can return undefined — env injection happens when the function runs, not when it's imported. Always read the value inside .handler().Part 3 · The integration pattern — server function as a proxy
The pattern is always the same: validate the input, attach the secret, call the external API, trim the response, return it. The client calls a typed function; it never learns the shape of the upstream API or the value of the key.
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
const Input = z.object({
from: z.string().length(3),
to: z.string().length(3),
amount: z.number().positive().max(1_000_000),
});
export const convertCurrency = createServerFn({ method: "POST" })
.inputValidator((raw) => Input.parse(raw))
.handler(async ({ data }) => {
const key = process.env.FX_API_KEY!;
const url = `https://api.fx.example/latest?base=${data.from}&symbols=${data.to}`;
const res = await fetch(url, { headers: { "x-api-key": key } });
if (!res.ok) throw new Error("Rate service unavailable");
const json = await res.json();
const rate = json.rates[data.to];
// Return only what the UI needs — never the raw upstream payload.
return { rate, converted: data.amount * rate };
});- Validate input with Zod. The client is untrusted. Reject anything that isn't a 3-letter code and a positive number before you call the upstream.
- Trim the response. Return
{ rate, converted }, not the entire upstream JSON. Less bandwidth, less accidental leakage, cleaner types. - Handle upstream failure explicitly. If the API is down, throw a message the user can read — not a stack trace with headers in it.
- Never log the key. A stray
console.log(headers)can print a secret into build logs that are readable by anyone with repo access.
inputValidator. It's your one chance to reject junk before it costs you a real API call.Part 4 · Mini-project — a currency converter
A tiny app: pick a base currency, a target currency, and an amount. Hit convert, get the number. Behind the scenes it uses a real FX API — with the key hidden — and caches rates for 60 seconds so a hundred users hitting the page don't burn through the free quota.
- 1Get the key
Sign up for a free FX API (exchangerate.host, openexchangerates, etc.). Copy the API key.
- 2Store the secret
Ask the agent to add FX_API_KEY. It opens a secure form — never paste the key into chat or code.
- 3Server function
createServerFn with a Zod validator for { from, to, amount }. Read process.env.FX_API_KEY inside the handler. Return { rate, converted }.
- 4Cache layer
Simple in-memory Map keyed by `${from}:${to}` with a 60s TTL. Skip the fetch on cache hit — same UX, a fraction of the calls.
- 5UI form
Two 3-letter inputs, one amount, a submit button. Call the server function with useServerFn and render the result card.
- 6Error state
Handle failure: 'Rate service unavailable — try again in a minute.' Never surface the upstream error verbatim.
Build the currency converter as described. When it works, publish it, open the deployed site, and search the JS bundle for your API key. You should find nothing.
Part 5 · Polish & pitfalls
Rate limits and retries
Every free tier has a ceiling. Two things save you:
- Cache on the server, not the client. A shared cache across users is far more effective than a per-browser one — one fetch serves everyone for the next minute.
- Retry once, then give up. Transient 5xxs deserve one polite retry with a small delay. A retry storm turns a blip into an outage.
Rotating keys
Keys leak — from screenshots, from old repos, from ex-teammates. Rotate them on a schedule, and immediately if you suspect exposure. Because the key lives in one place (the secrets manager), rotation is: generate a new one, paste it in, redeploy, revoke the old one. No code change.
Logging without leaking
- Log which upstream failed and the status code — never the request headers, body, or URL query with the key in it.
- If you must debug locally, log
key.slice(0, 4) + "…", not the full string. - Assume every log line will eventually be seen by someone you didn't invite. Write it accordingly.
App-wide vs per-user secrets
There are two shapes of secret and they live in different places:
- App-wide keys (your OpenAI key, your Stripe secret, your FX key) → secrets manager, one value for the whole app.
- Per-user tokens (a user's Google OAuth token so they can access their calendar) → an encrypted per-user row in the database, obtained through an OAuth flow. Never a shared secret.
Integrations cheat sheet
Prompts you'll reuse
- "Add a secret called
X_API_KEY" → secure form, never in code. - "Create a server function that calls [API] using
X_API_KEY, validated with Zod, returning only [fields]" → the proxy. - "Cache the result for 60 seconds keyed by the inputs" → free-tier friendly.
- "Show a friendly error toast if the upstream fails" → polished failure.
- "Rotate
X_API_KEY" → agent walks you through paste → redeploy → revoke old.
Rules of thumb
- Secret in env, code in git. Never the other way around.
- Server proxies, client renders. The browser never sees the upstream URL or key.
- Validate before you spend. Zod at the door of every server function.
- Trim the response. Return the fields the UI uses, nothing more.
- Assume every key will leak eventually. Build rotation and revocation into the plan from day one.
Sign up with my invite link and Lovable will drop 10 bonus credits into your account — enough to try everything in this course.
Claim 10 credits