Logo

Next.js private routes with Google API useUser hook

If you use any of the google API libraries in your website, it means you are using gapi. Using it with Next.js can be a bit tricky due to its nature of being 100% client side (as expected). This guide shows a neat way of dealing with private routes in your Next.js app.

🔧 Retrieving profile information with gapi

First, we need to load the gapi script client side. This is already tricky as it is a script that you have to load and then wait for it to initialise. Usually, the code will look something like this:

const script = document.createElement('script');

script.id = 'gapi-script';
script.src = 'https://apis.google.com/js/api.js';
script.async = true;
script.onload = () => console.log('loaded!');

document.body.appendChild(script);

Now, this is not very useful yet as it just makes sure you download the script and is loaded. In order to make use of its clients like people, we need to load that specific client with:

await new Promise((res, rej) => {
  if (window.gapi) {
    window.gapi.load('client', res);
  }
});
window.gapi.client.setToken({ access_token: <YOUR_APP_ACCESS_TOKEN> });
await window.gapi.client.load('https://www.googleapis.com/discovery/v1/apis/people/v1/rest');

At this point, you should be able to access and use window.gapi.client.people. Okay, let's organise all this code in a way that is reusable for our project. In order to do that, we are going to create a custom hook called useGapiClient that makes sure:

  • gapi script is downloaded and loaded
  • gapi.client is loaded
  • all the client libraries we want to use in our project are loaded too.
src/hooks/useGapiClient.ts
import React from 'react';

// This avoids Next.js server side crashing with window not being defined
const isBrowser = typeof window !== 'undefined';

/**
 * Use this in any component that needs Gapi. This will make sure we load GAPI and trigger the
 * callback so a re-render happens. A typical use case:
 *
 * const [isGapiLoaded] = useGapiClient();
 * if (isGapiLoaded) {
 *    <do your stuff>
 * }
 */
export default function useGapiClient() {
  const [isLoaded, setIsLoaded] = React.useState<boolean>(
    isBrowser && !!window.gapi && !!window.gapi.client,
  );
  React.useEffect(() => {
    if (!window.gapi || !window.gapi.client) {
      const script = document.createElement('script');

      script.id = 'gapiScript';
      script.src = 'https://apis.google.com/js/api.js';
      script.async = true;
      script.onload = () => loadGapiClient(setIsLoaded);

      document.body.appendChild(script);

      return () => {
        document.body.removeChild(script);
      };
    }

    return () => {};
  }, [isLoaded]);

  return [isLoaded];
}

/**
 * This function makes sure gapi client is properly loaded together with the
 * client apis we will be using.
 */
async function loadGapiClient(callback: React.Dispatch<React.SetStateAction<boolean>>) {
  if (!window.gapi.client) {
    await new Promise((res, rej) => {
      if (window.gapi) {
        window.gapi.load('client', res);
      }
    });
    window.gapi.client.setToken({ access_token: localStorage.getItem('accessToken') as string });
    await window.gapi.client.load('https://www.googleapis.com/discovery/v1/apis/people/v1/rest');
    await window.gapi.client.load('https://www.googleapis.com/discovery/v1/apis/drive/v3/rest');
    callback(true);
  }
}

Note an important part here where we pass a callback to loadGapiClient and then call it as callback(true) once everything is loaded. This ensures that the hook renders again because it triggers a state update so we then returning isLoaded being true. This hook is now ready to be used in any of our components as follows:

src/app/pages/user/page.tsx
import React from 'react';

import useGapiClient from '@/hooks/useGapiClient';

export default function UserPage(): JSX.Element {
  const [isGapiLoaded] = useGapiClient();
  const [userInfo, setUserInfo] = React.useState({
    name: '...',
    email: '...',
    image: '',
  });

  React.useEffect(() => {
    async function load() {
      const res = await gapiClient.people.people.get({
        resourceName: 'people/me',
        personFields: 'names,emailAddresses,photos',
      });
      setUserInfo({
        name: res.result.names![0].displayName as string,
        email: res.result.emailAddresses![0].value as string,
        image: res.result.photos![0].url as string,
      });
    }

    if (isGapiLoaded) {
      load();
    }
  }, [isGapiLoaded]);

  return (
    <div>JSON.stringify(userInfo)</div>
  );
}

🔒 Private routes

We already have a powerful hook useGapiClient that allows us to load and use any Google API in our app. Our next step is to protect the private routes from our App using this. To do so, we will use yet another custom hook called useUser and the swr library which you can install with yarn add swr. Note this hook is heavily inspired from this example but we've adapted it to using gapi instead.

src/hooks/useUser.ts
import React from 'react';
import useSWR from 'swr';
import { useRouter } from 'next/navigation';

import useGapiClient from '@/hooks/useGapiClient';

type User = {
  name: string,
  email: string,
  image: string,
  isLoggedIn: boolean,
};

export default function useUser() {
  const router = useRouter();
  const [isGapiLoaded] = useGapiClient();
  const { data: user } = useSWR<User>(isGapiLoaded ? '/api/user' : null, getUser);

  React.useEffect(() => {
    if (!user) return;

    if (!user.isLoggedIn) {
      router.push('/account/login');
    }

  }, [isGapiLoaded, user]);

  return { user };
};

The hook is super simple. Basically the logic is as follows:

  • ⬆️ Load gapi client.
  • 👤 Call getUser if gapi is loaded, if not we skip the call.
  • ❓ We only call useEffect when either isGapiLoaded or user change values because those are the values that affect our logic.
  • 🤷 If user is undefined, we return because we are not ready yet (gapi is not loaded).
  • ▶️ If user is not logged in, we send the user to /account/login so we can perform the action logging in.
  • ✅ Else it means the user is logged in so we will just return the user information.

The getUser function looks like this:


async function getUser(): Promise<User> {
  try {
    const res = await window.gapi.client.people.people.get({
      resourceName: 'people/me',
      personFields: 'names,emailAddresses,photos',
    });

    return {
      name: res.result.names![0].displayName as string,
      email: res.result.emailAddresses![0].value as string,
      image: res.result.photos![0].url as string,
      isLoggedIn: true,
    };
  } catch (e: any) {
    if (e.status === 401) {
      return {
        name: '',
        email: '',
        image: '',
        isLoggedIn: false,
      };
    }

    throw new Error(`Unknown error retrieving profile information: ${e.message}`);
  }
}

Note that with this simple logic, we are considering the user to be logged in if we are able to retrieve their profile information. If we receive a 401: Unauthorized back then we consider the user not logged in. Any other error we just bubble up. You can of course use any custom logic in getUser, whatever works for your app.

And then, you can use this to protect any Page in your app. In my case, I would recommended to have a shared layout for all those pages where you can reuse such logic. For example src/app/dashboard/layout.tsx:

src/app/dashboard/layout.tsx
import React from 'react';

import useUser from '@/hooks/useUser';

export default function DashboardLayout({
  children,
}: React.PropsWithChildren): JSX.Element {
  const { user } = useUser();

  if (!user || user.isLoggedIn === false) {
    return <div>Loading...</div>;
  }

  return (
    {children}
  );
}

💚 Conclusion

useUser hook is very simple to use and provides a re-usable way of protecting any page you need in your app. Note that we haven't explore SWR library options but there are plenty of them to re-validate data, dealing with errors, etc. I recommend you to have a look at it to optimise the requests you do to your dependencies.

If you enjoyed the post feel free to react to it in the Github tracker (for now).