Integrations & Secrets

Mini-project: pull data from an external API using secrets — safely.

18 min read·5 parts·intermediate

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.

Avoid
Publishable vs secret
A publishable key (like a Supabase anon key or a Stripe publishable key) is designed to sit in the browser — it's rate-limited and paired with server-side rules. A secret key (OpenAI, Stripe secret, Resend, most third-party APIs) is not. If the docs say "keep this secret", it does not go in src/.
Do
The safe pattern
The browser calls your server function. Your server function reads the secret from process.env, calls the external API, and returns only the fields the UI needs. The key never crosses the network to the client.
Figure 1.1
LEAK — secret imported into Reactbrowserbundle.js contains sk_...scraper finds the keysomeone else's billPROXY — secret only in server envbrowserserver functionreads process.env.KEYexternal API — trimmed reply
Left: the key ships in the bundle and anyone can grab it. Right: the key stays on the server; the browser never sees it.
The one-line test
Open the deployed site, hit 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 prefixed VITE_ 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 .env committed 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 called FX_API_KEY. Then in a server function read it as process.env.FX_API_KEY inside the handler. Confirm it works locally and after publish.
Read env inside the handler
Reading 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.
Zod at the door
Every server function that touches an external API should start with an 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.

Figure 4.1
browseramount + codesconvertCurrency()validate60s cachehit? returnFX APIwith x-api-keyUI cardrate + totalkey never leaves the middle boxes
The client only knows about its own server function. The upstream API and the key live on the other side of a wall.
fx.lovable.app
Convert
Amount
1,000
From
MYR
To
USD
Result
USD 212.40
rate 0.2124 · cached 42s ago
  1. 1Get the key

    Sign up for a free FX API (exchangerate.host, openexchangerates, etc.). Copy the API key.

  2. 2Store the secret

    Ask the agent to add FX_API_KEY. It opens a secure form — never paste the key into chat or code.

  3. 3Server function

    createServerFn with a Zod validator for { from, to, amount }. Read process.env.FX_API_KEY inside the handler. Return { rate, converted }.

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

  5. 5UI form

    Two 3-letter inputs, one amount, a submit button. Call the server function with useServerFn and render the result card.

  6. 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.
Before you ship any integration
Five-line checklist: (1) key in secrets manager, not code. (2) server function validates input. (3) response is trimmed, not raw. (4) failure path returns a friendly message. (5) grep the deployed bundle for the key — nothing.

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.