Why Storing Keys In Environment Variables Isn't Enough

Front-end developers frequently work with APIs that require secret keys for authentication. The common reflex is to stash those keys in an environment variable, assuming that keeps them safe. However, environment variables do not protect keys from anyone comfortable with browser dev tools. Any key referenced on the client side is exposed to the user, regardless of where it is defined in your source code.

The solution is to move API calls to the server side. Next.js makes this particularly straightforward with its built-in API routes, which let you run server-side code within the same project. This approach keeps your secret keys off the client entirely. The same principle applies to any framework, but Next.js provides the most direct path.

Project Setup And Structure

Begin by creating a new Next.js application:

npx create-next-app [name-of-your-app]

For this walkthrough, we'll focus on the core files. The styling will be omitted for brevity. A complete example repository is available for reference.

|--pages
|   |-- api
|   |   |-- serverSideCall.js  
|   |-- _app.js
|   |-- index.js
|__ .env.local

The app relies on the following key directories and files:

  • pages/api
    This directory provides a backend within your Next.js app. Every file inside it becomes an API endpoint. For instance, pages/api/user.js exposes an endpoint at the corresponding URL path.
const getData = async() => {
  fetch("/api/users")
   .then(response => response())
   .then(response => console.log(response.data))
   .catch(err => console.log(err)
}
  • pages/_app.js
    Acts as the root component where all pages and their props are mounted. Components are passed through as pageProps.
function MyApp({ Component, pageProps }) {
  return (
    <React.Fragment>
      <Head>
        <meta name="theme-color" content="#73e2a7" />
        <link rel="icon" type="image/ico" href="" />
      </Head>
      <Component {...pageProps} />
    </React.Fragment>
  );
}

export default MyApp;
  • pages/index.js
    The default route that gets rendered when the dev server starts.
npm run dev
  • .env.local
    Stores the API key that will be consumed server-side.

Making The Server-Side API Call

Writing the API call in an API route is how you protect your secret. While in development, process.env.api_key works to access the variable. Once you deploy to platforms like Vercel or Netlify, the environment switches to production, and the Node.js process object is no longer available on the client. That's why the call must happen on the server.

export default async function serverSideCall(req, res) {
    const {
      query: { firstName, lastName },
    } = req;

    const baseUrl = `https://api.example-product.com/v1/search?
        lastName=${lastName}&firstName=${firstName}
        &apiKey=${process.env.KEY}
    `;
    const response = await fetch (baseUrl);
    res.status(200).json({
    data: response.data,
  });
}

The exported function, serverSideCall, receives req (request) and res (response) arguments. The req object includes several built-in middlewares. One of the most useful is req.query, which contains the query parameters from the incoming request.

By destructuring query, you can pass those values as parameters to the external API. This is how form input gets relayed to the third-party service without exposing the API key.

const {
  query: { firstName, lastName },
} = req;

The base URL for the external API is constructed using the destructured query properties, and the apiKey is pulled from the .env file via the server-side process object.

const baseUrl = `https://api.kelvindata.com/rest/v1/searchv2?  lastName=${lastName}&firstName=${firstName}&apiKey=${process.env.KEY}`;

Once the external API responds, you need to send that data back to the client. The snippet assigns the asynchronous axios call to a variable, response. The res argument then uses the status method to send a JSON response back, attaching the data from the external call.

const response = await axios.get(baseUrl);
res.status(200).json({
  data: response.data,
});

Consuming The Endpoint From A React Component

With the server-side function ready, the next step is building a form that sends input values to that endpoint. The form has two fields, which will be passed as query parameters.

import React from "react";

const Index = () => {
  const [data, setData] = React.useState([]);
  const [firstName, setFirstName] = React.useState("");
  const [lastName, setLastName] = React.useState("");

  const getuserData = async () => {
    // api call goes here
  };

  const handleSubmit = (e) => {
     e.preventDefault();
     getuserData();
  };

  return (
     <React.Fragment>
       <form onSubmit={handleSubmit}>
          <label htmlFor="firstname">First name</label>
          <input
            type="text"
            name="firstname"
            value={firstName}
            placeholder="First Name"
            onChange={(e) => setFirstName(e.target.value)}
          />
          <label htmlFor="lastname">Lastname</label>
          <input
            type="text"
            name="lastname"
            value={lastName}
            placeholder="Lastname"
            onChange={(e) => setLastName(e.target.value)}
          />
           <button>Search</button>
        </form>
        <div className="results-from-api"></div>
    </React.Fragment>
 );
};

export default Index;

Because this component receives data from an API, it manages its own local state using React Hooks.

const [userData, setUserData] = React.useState([]);
const [firstName, setFirstName] = React.useState("");
const [lastName, setLastName] = React.useState("");

The firstName and lastName state variables hold the text typed by the user. The data state variable stores the response array for rendering with JavaScript's map() method.

Within the component, the request is made using axios. Instead of a typical https:// URL, the base URL is the relative path to the server-side API route.

const getuserData = async () => {
fetch(`/api/usersfirstName=${firstName}&lastName=${lastName}`, {
       headers: {
         Accept: "application/json",
       },
})
  .then((response) => response)
  .then((response) => {
    setData(response.data.data); 
    console.log(response.data.data);
  })
  .catch((err) => console.log(err));
};

The client-side request mirrors the logic found in serverSideCall.js, including the necessary fetch headers and the state variables being assigned to the query parameters.

Alternative Approaches

Beyond Next.js API routes, a couple of other strategies can accomplish the same level of key protection:

  • Netlify Lambda functions can be used to shield API keys, but they require a separate configuration and deployment workflow. For Next.js projects, API routes are typically more convenient.
  • Server-side rendering with Next.js is another way to hide keys. A video by Ijemma Onwuzulike explains this in detail.