APIs, integration & security — in depth

How to Integrate an API in a Frontend Application

A pattern for reliably connecting frontends to backends through APIs.

Contributing Editor · · 12 min read
Cover illustration for “How to Integrate an API in a Frontend Application”
Developer Experience · September 9, 2026 · 12 min read · 2,741 words

Every frontend that talks to a backend does it through the same basic choreography: pick an endpoint, send a request, wait, parse what comes back, and tell the user what happened. Get that sequence wrong and you end up with the classic symptoms of a bad integration: buttons that spin forever, error messages that just say "undefined," and three different files all fetching the same user object. This piece walks through that sequence in order, so by the end you've got a pattern you can reuse on the next project instead of reinventing it under deadline pressure.

Quick framing before diving in. The frontend is everything the user actually touches, built in HTML, CSS, and frameworks like React, Angular, or Vue. The backend is the part nobody sees: Node with Express, Django, Rails, whatever's running the database and the business logic. The API is the door between those two rooms. That's not a nice-to-have architectural flourish, it's what lets a frontend team ship a redesign on Tuesday without waiting on the backend team to finish their migration. Most integration work falls into three buckets: pulling data onto a screen, pushing form data back, or keeping local state in sync with some external service. None of that works reliably without a repeatable pattern, which is exactly what the next eight sections lay out.

Reading the API documentation before writing any requests

Docs tell you things you cannot guess your way into. Available endpoints, which HTTP methods each one accepts, which parameters are required versus optional, how authentication works, what format the data comes back in (almost always JSON if it's a REST API), rate limits, and versioning. Skip this step and you're not coding, you're gambling.

Versioning matters more than it sounds like it should. If the docs show something like /api/v1/users, that v1 is a promise: the shape of that response won't change out from under you. Build against the wrong version, or ignore versioning entirely, and a backend update six months from now breaks your app with zero warning.

REST APIs map CRUD operations onto HTTP verbs in a pretty consistent way: GET reads, POST creates, PUT or PATCH updates, DELETE removes. The URL tells you the resource, the verb tells you the intent. Once that clicks, most REST APIs start to look the same regardless of who built them.

Frontend developers mostly run into three kinds of APIs: RESTful APIs (HTTP plus JSON), broader HTTP APIs that don't strictly follow REST conventions, and third-party SDKs for things like social login or payment processing. Different shapes, same underlying job.

Before writing a single line of frontend code, make a test call in Postman against the documented endpoint. This confirms the endpoint actually behaves the way the docs claim (docs lie, or go stale, more often than anyone wants to admit) and shows you the exact response shape you'll be parsing later. Most integration bugs don't come from a bad library choice or a missing try-catch. They come from someone who skimmed the docs, assumed a field was named userId when it's actually user_id, and found out three components deep.

Choosing between Fetch, Axios, and TanStack Query for making requests

Three tools dominate React-based frontends right now, and each one earns its place at a different level of complexity.

Fetch ships with the browser, so there's nothing to install. It returns a Promise, works fine with .then() chains or async/await, and it's a solid pick for a simple one-off request where pulling in a library would be overkill. The catch: Fetch does not throw on a 404 or a 500. A failed request looks exactly like a successful one until you manually check response.ok. Forget that check once, and you'll spend an afternoon debugging a "successful" request that returned an error page.

Axios is the third-party option, and it runs the same way in the browser and in Node, which keeps your API code consistent across environments. It serializes request bodies and parses JSON automatically, so there's less boilerplate. The real selling point is interceptors: reusable hooks that attach an auth token to every outgoing request, or catch every incoming error in one place instead of scattering try-catch blocks across fifty components. It also supports XSRF protection out of the box. Reach for Axios when the project needs interceptors, needs error handling that doesn't repeat itself in every file, or needs to run outside the browser.

TanStack Query (the project formerly known as React Query) is where things get genuinely different, not just a nicer syntax on top of the same idea. It handles fetching, caching, background re-fetching, loading states, error states, retries, and cache invalidation, all without you writing that logic yourself. The old pattern, a useEffect plus three separate useState calls for loading, error, and data, collapses into a single hook. It got renamed from React Query to reflect broader framework support, as it now supports Vue, Solid, and Svelte in addition to React. Reach for it when server state (data that lives remotely and needs to stay fresh) is the actual complexity in your app, not just an occasional fetch.

Rough rule of thumb: Fetch for a quick script or a single call, Axios when you need interceptors or cross-environment consistency, TanStack Query when server state is a real, ongoing problem rather than a footnote.

Authenticating requests securely from the frontend

Three standards show up constantly: OAuth 2.0 for delegating access without handing over a password, JWTs for stateless token-based auth, and access control mechanisms for scoping what a given user can actually do. A common pattern in frontend work is a short-lived JWT sent in the Authorization header. Keeping it out of the URL and limiting its lifespan shrinks the window of damage if it leaks.

If the app uses session cookies instead of tokens, CSRF protection needs to be explicit. CORS does not defend against CSRF. Different problem, different fix, and conflating the two is a common way security holes sneak into otherwise careful code.

Where you store the credential matters. API keys belong in environment variables, never hardcoded into source. JWTs in the browser force a tradeoff: localStorage is convenient but exposed to XSS, httpOnly cookies dodge that but open up CSRF risk if they're not configured correctly. There's no option that's free of risk, only options with different failure modes.

This is where Axios interceptors earn their keep again. A request interceptor reads the token from storage and slaps Authorization: Bearer <token> onto every outgoing call automatically. Authentication becomes something you configure once, not something you re-implement in every component that happens to hit the API.

One thing worth repeating because it trips people up constantly: CORS governs what a browser will allow, not what a server accepts from a direct call made outside the browser. A tool like Postman or a curl command doesn't care about CORS at all. Real authentication and authorization always have to live on the server. Trusting the client to gatekeep access is like putting a "no trespassing" sign on an open field, it might discourage the honest ones, but nobody else. And every request, no exceptions, should travel over TLS if it's touching real user data.

How CORS works and why it blocks requests that look correct

CORS is a browser-enforced rule that governs whether a web app can make requests to a different origin (a different domain, protocol, or port) than the one that served the page. It's a controlled loosening of the Same-Origin Policy, and it exists specifically to guard against unauthorized cross-origin requests.

Here's the part that confuses people the first time they hit it: the browser blocks the request on behalf of the user, regardless of whether the server was fine with it. The server has to explicitly say "yes, this origin can talk to me" through a response header. If it doesn't, the browser throws the request away even if the server returned a perfectly good 200.

The header doing all the work is Access-Control-Allow-Origin. Missing it, or returning the wrong origin, and the browser blocks the response no matter what the status code says.

A common shortcut that causes real trouble in production: setting Access-Control-Allow-Origin: *. That wildcard lets any website on the internet make requests using a visitor's browser session, which is a genuine liability for any API handling sensitive data or authenticated requests. Better practice looks like this: whitelist the specific origins that actually need access, restrict allowed methods to only what the frontend uses (if it only ever does GET and POST, don't leave DELETE open), and configure CORS centrally rather than endpoint by endpoint, since scattering it invites inconsistency.

None of this is something the frontend can fix on its own. CORS headers live on the server, full stop. A CORS error in the console is a signal to go talk to whoever owns the backend, not a sign your fetch call is written wrong. Handy way to confirm that fast: if a call succeeds in Postman and fails in the browser, that's not a coincidence, that's the CORS mechanism doing exactly what it's designed to do. Postman isn't a browser, so it never enforces the policy in the first place.

Parsing responses and keeping the UI honest about every state

Status code first, always. Anything in the 2xx range means proceed to parsing. A 4xx means the client did something wrong (bad request, missing auth, resource not found), and that needs to turn into a message a person can understand, not a raw "400" stamped on the screen. A 5xx means the server broke, and the honest move is telling the user the problem is on that end and giving them a retry option.

Fetch users, this is the line that bites you: check response.ok before calling response.json(). Fetch will not throw just because the server returned an error. Skip the check and a failed request will sail right through your success path.

Once the status checks out, response.json() turns the payload into a JavaScript object you can work with. Don't render it blind, though. Validate the shape first, because a missing field or an unexpected null buried in the response will crash a component or, worse, render silently wrong data that nobody notices until a user complains.

Every component wired to an API needs to account for three states, not just the one where everything works:

  • Loading. Show a spinner or a skeleton screen. A blank page reads as broken, even when it's just still loading.
  • Error. Show a message, and where it makes sense, a retry button. Leaving someone staring at nothing is the UX equivalent of a store with the lights off and the door unlocked.
  • Success. Render the data.

An integration only counts as finished once the data's interpreted correctly, the UI responds appropriately to all three states above, and the user is never left guessing what's happening. "The fetch ran" is not the finish line.

Handling errors in a way that doesn't leave the user guessing

Errors in an API integration break down into four rough categories. Network errors mean the request never made it anywhere, no connectivity, a timeout, a DNS failure. HTTP errors mean the server got the request and rejected or failed it, a 4xx or 5xx. Parsing errors mean the server said 200 but handed back a body that doesn't match what you expected. Rate limit errors, specifically 429s, mean the API is telling you to slow down.

Mechanically, you're either wrapping an async/await call in try-catch, or chaining .catch() onto a promise. Same job, different syntax, pick whichever fits your codebase.

Axios response interceptors are where global error handling actually becomes practical: catch specific error codes in one place and handle them consistently, all without repeating that logic in every component that makes a request. TanStack Query adds another layer on top of that by retrying failed requests automatically, with the retry behavior fully configurable. That's the direct payoff of the tool choice made earlier, not a separate feature bolted on afterward.

Whatever the underlying error, the message a user sees should never be the raw exception. "Something went wrong, please try again," paired with a retry button, beats a stack trace or a blank screen every single time. Nobody outside the engineering team benefits from seeing the word "undefined."

Set a request timeout too (Axios supports this natively) so a stalled connection doesn't leave a spinner running forever. And when a 429 comes back, tell the user to wait rather than treating it as a generic error. They didn't do anything wrong, the API just needs a breather.

Organizing API code so it doesn't scatter across the codebase

Putting API calls directly inside components feels fast at first and turns into a mess fast, too. Logic gets duplicated across files, testing gets harder because every test now needs a live network call, and changing one URL means grepping through the entire codebase hoping you found every instance.

The fix is a dedicated service layer. One file or folder per domain: api/users.js, api/products.js, and so on. Each function in there calls the HTTP client and hands back a clean result. The component that uses it never touches raw Axios or Fetch internals directly, it just asks for data and gets data. Keep the base URL and default headers in one config file, and switching between dev, staging, and production becomes a one-line change instead of a search-and-replace operation.

For React specifically, wrap those service calls in custom hooks, something like useUsers or useProduct. The component declares what it needs, not the mechanics of how to go get it.

When fetched data needs to live across a bunch of components at once, a state layer like Redux or Context API stops the same request from firing repeatedly across multiple places. That said, add it when the need is real and showing up in practice, not preemptively because it seemed like the "grown-up" architecture. Extra state management layered on top of a simple app just adds weight nobody asked for.

Secrets go in environment variables, never committed to source control. And where data doesn't change often, cache it, so the app isn't hitting the network for the same response every few seconds. TanStack Query does this automatically. Fetch and Axios don't, so caching there takes deliberate design instead of coming for free.

Testing the integration at each layer before shipping

Three layers, each catching a different kind of mistake.

Manual testing in Postman comes first: confirm the endpoint's behavior, the response shape, and the authentication flow before any frontend code exists. It also doubles as the fastest way to reproduce a bug someone reported, since you can isolate the API call from everything the UI might be doing wrong on top of it.

Unit tests, using something like Jest or Mocha, check individual service functions. Mock the HTTP client so the test suite isn't making real network calls every time it runs, and assert that the function transforms the response correctly and throws when it should.

Integration tests check that a component renders correctly given a mocked API response, covering loading, success, and error states the same way the UI needs to handle them live.

The error path deserves specific attention here, not an afterthought. Does the UI show a message when the server returns a 500? Does it retry after a network timeout? Does it redirect the user when a 401 comes back? Those are the situations users actually hit, far more often than the clean happy path everyone tests first out of habit.

Frontend Mentor's API integration challenges are a decent real-world benchmark for what "done" looks like across a range of difficulty. Their Advice Generator app (built on the Advice Slip API) sits at the newbie level, the GitHub user search app is a junior-level project, and IP Address Tracker, the Dictionary web app, and the Weather app (built on Open-Meteo) sit at the intermediate tier, with IP Address Tracker combining two APIs at once. Across all of them, the challenge only counts as solved when loading states, error states, and real data all render correctly, not the moment the fetch call returns a 200.

Once the first version works, go back and refactor toward the service-file structure covered earlier. Getting something working and getting something organized are two different milestones, and treating them as the same step is how technical debt gets baked in on day one.

More in Developer Experience