tutorial // Sep 10, 2021

How to Use the JavaScript Fetch API to Perform HTTP Requests

How to use the JavaScript fetch API to perform HTTP requests in the browser and in Node.js.

How to Use the JavaScript Fetch API to Perform HTTP Requests

Getting Started

For this tutorial, we're going to use the CheatCode Next.js Boilerplate for showcasing usage of fetch on the client and the CheatCode Node.js Server Boilerplate for showcasing usage of fetch on the server.

To get started, let's clone the Next.js boilerplate:

Terminal

git clone https://github.com/cheatcode/nextjs-boilerplate client

Next, cd into the project and install it's dependencies:

Terminal

cd client && npm install

After this, go ahead and start up the development server:

Terminal

npm run dev

Next, in another tab or terminal window, we want to clone the Node.js boilerplate:

Terminal

git clone https://github.com/cheatcode/nodejs-server-boilerplate server

Next, cd into the project and install the dependencies:

Terminal

cd server && npm install

Before we start the development server, we need to install two additional dependencies: isomorphic-fetch and faker:

Terminal

npm i isomorphic-fetch faker

With those two installed, go ahead and start up the server:

Terminal

npm run dev

With that, we're ready to get started.

Using the Fetch API in Node.js

Though it may seem a bit backward, for this tutorial, we're going to start our work on the server-side and then move to the client. The reason why is that we're going to set up some test routes that we can perform fetch requests against on the client. While we're there, too, we'll take a quick look at how to use fetch in a Node.js server environment.

/server/api/index.js

import graphql from "./graphql/server";

export default (app) => {
  graphql(app);

  app.get("/users", (req, res) => {
    // We'll implement an HTTP GET test route here...
  });

  app.post("/users", (req, res) => {
    // We'll implement an HTTP POST test route here...
  });

  app.get("/photos", (req, res) => {
    // We'll implement a server-side fetch request here...
  });
};

Inside of the Node.js boilerplate we cloned above, an Express.js server is already configured for us. In the file above, the boilerplate sets up the various APIs that it supports (by default, just a GraphQL API). Passed into the function being exported from this file is the Express app instance which is set up for us in the /index.js file in the project.

Here, beneath the call to the function where we set up our GraphQL server graphql() (we won't use this, we're just calling it out to save confusion), we define three routes:

  1. /users using app.get() which creates an Express.js route that only accepts HTTP GET requests.
  2. /users using app.post() which creates an Express.js route that only accepts HTTP POST requests.
  3. /photos using app.get() which an Express.js route that only accepts HTTP GET requests and will be where we use fetch to get data from a third-party API.

/server/api/index.js

import faker from "faker";
import graphql from "./graphql/server";

export default (app) => {
  graphql(app);

  app.get("/users", (req, res) => {
    const users = [...Array(50)].map(() => {
      return {
        name: {
          first: faker.name.firstName(),
          last: faker.name.lastName(),
        },
        emailAddress: faker.internet.email(),
        address: {
          street: faker.address.streetAddress(),
          city: faker.address.city(),
          state: faker.address.state(),
          zip: faker.address.zipCode(),
        },
      };
    });

    res.status(200).send(JSON.stringify(users, null, 2));
  });

  app.post("/users", (req, res) => {
    // We'll implement an HTTP POST test route here...
  });

  app.get("/photos", (req, res) => {
    // We'll implement a server-side fetch request here...
  });
};

Adding an import up top for the faker dependency we installed earlier, here, we're filling out the app.get() version of our /users route. Inside, our goal is to return some test data (we'll perform a fetch request from the client later and expect this data in return). For our data, we're using a little JavaScript trick.

The [...Array(50)] that we're mapping over here is saying "create a new JavaScript array in memory with 50 elements in it (these will just be undefined values) and then 'spread' or 'unpack' that array—using the ... spread operator—into the array wrapping that statement." Our goal here is to get 50 "placeholders" that we can replace using a JavaScript .map() method.

We see that happening here, returning an object describing a made-up user for each of the 50 placeholder elements. In turn, this will return us an array with 50 made-up user objects. To "make up" those users, we use the faker library—a tool for creating fake test data—to make a realistic test user for each iteration of our map (learn more about Faker's API here).

Finally, after we've created our array of users, we take that variable and using the res object from Express.js (this is passed as the second argument to the callback function for our route), and do two things:

  1. Set the HTTP status code to 200 using the .status() method (this is the standard HTTP code for "success").
  2. Using the ability to "chain" methods, call to the .send() method after setting the .status() on res, passing in a stringified version of our users variable (containing our array of users).

Here, using JSON.stringify() is necessary because only strings can be sent in response to HTTP requests. Later, on the client, we'll learn how to convert that string back into a JavaScript array.

/server/api/index.js

import faker from "faker";
import graphql from "./graphql/server";

export default (app) => {
  graphql(app);

  app.get("/users", (req, res) => {
    ...
    res.status(200).send(JSON.stringify(users, null, 2));
  });

  app.post("/users", (req, res) => {
    console.log(req.body);
    res.status(200).send(`User created!`);
  });

  app.get("/photos", (req, res) => {
    // We'll implement a server-side fetch request here...
  });
};

Next, for the app.post() version of our /users route, we keep things simple. Because the intent of an HTTP POST request is to create or insert some data into a database (or hand it off to another data source), here, we're just logging out the contents of req.body which is the parsed content sent to us via the request. This will come in handy later as we'll see how the options we pass to a fetch() request determine whether or not the body we pass on the client makes it to the server.

Finally, here, we repeat the same pattern we saw in the app.get() version of /users, calling to res, setting the .status() to 200, and sending back a string response (here, just a plain string signifying the receipt of the user).

/server/api/index.js

import faker from "faker";
import fetch from "isomorphic-fetch";
import graphql from "./graphql/server";

export default (app) => {
  graphql(app);

  app.get("/users", (req, res) => {
    ...
    res.status(200).send(JSON.stringify(users, null, 2));
  });

  app.post("/users", (req, res) => {
    console.log(req.body);
    res.status(200).send(`User created!`);
  });

  app.get("/photos", (req, res) => {
    fetch("https://jsonplaceholder.typicode.com/photos").then(
      async (response) => {
        const data = await response.json();
        res.status(200).send(JSON.stringify(data.slice(0, 50)));
      }
    );
  });
};

For our final route, we create another app.get() route, this time using the route /photos. For this route, we're going to use a server-side fetch() call to a third-party API and send the data we get back to the client-side of our app. Up top, you can see that we've imported the isomorphic-fetch dependency we installed earlier as fetch.

It's worth noting that there are multiple Node.js implementations of the fetch() API. While fetch() is built-in to modern web browsers, in Node.js, it needs to be polyfilled. Several packages have come up with their own solutions to do this, hence the variation. Another popular package you may see is node-fetch.

Here, we make a call to the /photos endpoint on the free JSON Placeholder API which returns us an array of objects with pointers back to stock photographs.

After our call to fetch(), we chain on a .then() callback—this signifies that we expect fetch() to return a JavaScript Promise—passing a function to that .then() method. Inside of that function, we take in the response to our request as an argument, also adding an async keyword before our function.

We do this because on the next line, we make a call to await in front of a call to response.json(). The idea here is that response is not handed to us by fetch() in any specific format. Instead, we take the raw response and using one of a few methods on that response object, we convert the response into the format that we want/need.

Here, response.json() is saying to convert the response into a JSON format. We use the await here because we expect response.json() (and its sibling methods like response.text()) to return a JavaScript Promise. With an await, we're saying "wait until this function has returned us a value that we can set to our data variable and then continue to the next line."

On the next line, we see a familiar call to res.status(200).send(), making sure to JSON.stringify() our data before sending it back to the request made from the client side of our app.

That does it for the server! Next, we're going to jump down to the client and see how fetch() works in the browser.

Using the Fetch API in the browser

Moving into the Next.js boilerplate we cloned earlier, to start, we're going to use the page-based routing feature of Next.js to create a new route on the client where we can test out our fetch() calls:

/client/pages/index.js

import React, { useState } from "react";

const Index = () => {
  const [data, setData] = useState([]);

  const getRequestWithFetch = (resource = "") => {
    // We'll make our GET requests using fetch here...
  };

  const postRequestWithFetch = () => {
    // We'll make a our POST request using fetch here...
  };

  return (
    <div>
      <button
        className="btn btn-primary"
        style={{ marginRight: "10px" }}
        onClick={() => getRequestWithFetch("users")}
      >
        GET Request (Users)
      </button>
      <button
        className="btn btn-primary"
        style={{ marginRight: "10px" }}
        onClick={() => getRequestWithFetch("photos")}
      >
        GET Request (Photos)
      </button>
      <button className="btn btn-primary" onClick={postRequestWithFetch}>
        POST Request
      </button>
      <pre style={{ background: "#eee", marginTop: "20px", padding: "20px" }}>
        <code>{data}</code>
      </pre>
    </div>
  );
};

export default Index;

In Next.js, pages (which are converted automatically to routes or URLs) are defined using React.js components. Here, we're using the function-based approach to defining a component in React which consists of a plain JavaScript function which returns some JSX markup (the markup language built for authoring components in React).

In the body of that function, too, we can define other functions and make calls to a special type of function unique to React called hooks.

Starting just inside the body of our function, we can see a call to one of these hook functions useState() (imported up top) which will allow us to set a dynamic state value and then access that value in our JSX markup and the other functions defined within our function component's body (a concept known as "closure functions," or, functions defined within functions in JavaScript).

Here, useState([]) is saying "creating an instance of a state value, setting the default value to an empty array []."

For the return value of that call, we expect to get back an array with two values: the first being the current value data and the second being a function we can use to update that value setData. Here, we use JavaScript array destructuring to access the contents of our array and simultaneously assign variables to the values at those positions in the array.

To clarify that, if we wrote this line like const state = useState([]), we'd need to follow that line with something like:

const data = state[0];
const setData = state[1];

Using array destructuring, we can avoid this entirely.

Jumping past our placeholder functions, next, looking at the JSX markup we're returning from our Index component function (what Next.js will render for our page), we can see that our actual UI is quite simple: we're rendering three buttons and a <pre></pre> block.

The idea here is that we have one button for each of our fetch() request types, followed by a code block where we're rendering the response to each request (triggered by the button click). Here, we can see the data variable we "plucked off" using array destructuring from our call to useState() being passed into the <code></code> tag nested inside of our <pre></pre> tag. This is where we'll ultimately store the response data from our fetch() requests (and see that data on screen).

Looking at each button, we can see the onClick attribute being assigned a value. For the first two buttons—which we'll be responsible for performing our GET request examples—we call to the function defined above getRequestWithFetch(), passing in a string describing the resource or path that we'd like to call to (this will make more sense in a bit).

For the last button, we just pass the function postRequestWithFetch directly as we do not need to pass any arguments when we call that function.

/client/pages/index.js

import React, { useState } from "react";

const Index = () => {
  const [data, setData] = useState([]);

  const getRequestWithFetch = (resource = "") => {
    fetch(`http://localhost:5001/${resource}`, {
      credentials: "include",
    }).then(async (response) => {
      const data = await response.json();

      // NOTE: Doing JSON.stringify here for presentation below. This is not required.
      setData(JSON.stringify(data, null, 2));
    });
  };

  const postRequestWithFetch = () => {
    // We'll make a our POST request using fetch here...
  };

  return (
    <div>
      ...
    </div>
  );
};

export default Index;

Looking at the getRequestWithFetch function we hinted at below, we can see the string we passed for our resource name being defined as the argument resource on our function. Inside of that function, we set up our call to fetch(). Something you'll notice is that, unlike on the server, we're not importing fetch() from anywhere.

This is because fetch is built-in to modern browsers as a global value (meaning it's automatically defined everywhere in the browser).

Looking at our call, just like we saw earlier, we call to fetch() passing a URL as the first argument. In this case, we're passing the URL for one of the GET routes we defined on our server earlier. This will dynamically change based on the value passed for resource, to either http://localhost:5001/users or http://localhost:5001/photos.

As the second argument to fetch(), we pass an options object. Here, we're just passing a single property credentials: "include". As we'll see when we implement our POST request, what we pass here determines how our request actually behaves. In this case, we're telling fetch() to include the browser's cookies in the request headers when it sends the request. Though we're not authenticating our requests on the server this is important to be aware of if you expect fetch() to behave like a browser (which automatically sends the cookies with its own requests).

Finally, here, down in the .then() callback (remember, fetch() will return us a JavaScript Promise), we use the async/await pattern to await response.json() to get the return data back in a JavaScript-friendly format—array or object—and then call to the setData() function we got back from our useState() hook function to set the response data for display down in our <pre></pre> tag.

/client/pages/index.js

import React, { useState } from "react";

const Index = () => {
  const [data, setData] = useState([]);

  const getRequestWithFetch = (resource = "") => {
    ...
  };

  const postRequestWithFetch = () => {
    fetch(`http://localhost:5001/users`, {
      method: "POST",
      credentials: "include",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        name: "test",
      }),
    }).then(async (response) => {
      const data = await response.text();
      setData(data);
    });
  };

  return (
    <div>
      ...
    </div>
  );
};

export default Index;

Next, for our postRequestWithFetch() function, we repeat a similar process as our GET request. Here, though, we hardcode our URL (we only have one POST route on the server) and, because we're doing a request other than a GET, we set a method option to POST. If we do not do this, fetch() will assume we're trying to perform a GET request or "fetch" some data.

Below this, we can see the same credentials: "include" as our GET request (again, purely for awareness here). Next, the important part, because this is a POST request, we add a body option set to a stringified JavaScript object with some test data on it. Remember, HTTP requests can only pass strings back and forth. To make this work, in the headers option, we add the HTTP Content-Type header, setting it to application/json. This is important. This communicates to the server that the data we're sending in the body should be parsed as JSON data.

/server/middleware/bodyParser.js

import bodyParser from "body-parser";

export default (req, res, next) => {
  const contentType = req.headers["content-type"];

  if (contentType && contentType === "application/x-www-form-urlencoded") {
    return bodyParser.urlencoded({ extended: true })(req, res, next);
  }

  return bodyParser.json()(req, res, next);
};

To make sense of this, quickly, on the server-side of our app, the Node.js boilerplate we're using has something known as a middleware function which is run whenever a request comes into the server, just before it's handed off to our Express.js routes. Here, we can see at the bottom of the middleware function that parses the HTTP request body to a JSON format.

If we didn't set the Content-Type header in our fetch() request back on the client, our request body (req.body in our route handler on the server) would be an empty object. Once we set this header, however, the server responding to our request knows "what to do" and receives our request body as intended.

/client/pages/index.js

import React, { useState } from "react";

const Index = () => {
  const [data, setData] = useState([]);

  const getRequestWithFetch = (resource = "") => {
    ...
  };

  const postRequestWithFetch = () => {
    fetch(`http://localhost:5001/users`, {
      method: "POST",
      credentials: "include",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        name: "test",
      }),
    }).then(async (response) => {
      const data = await response.text();
      setData(data);
    });
  };

  return (
    <div>
      ...
    </div>
  );
};

export default Index;

Focusing back on our postRequestWithFetch function on the client, in the .then() callback, we use a similar flow to what we saw before with async/await, however this time, instead of response.json() we use response.text(). This is because the response we send back from the server for our POST request is just a plain string (as opposed to a stringified object like in our other requests). Once we've got our data, we pop it on to state with setData().

That's it! Now we're ready to take this for a spin:

Wrapping up

In this tutorial, we learned how to perform HTTP requests using the JavaScript fetch() API. We started on the server, defining routes to send our requests to from the client, also learning how to use fetch() via the isomorphic-fetch library from within Node.js. Next, on the client, we learned how to run HTTP GET and POST requests, learning about the proper options to pass to ensure our server can understand our request.

Written By
Ryan Glover

Ryan Glover

CEO/CTO @ CheatCode