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.
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:
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.
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 eitherisGapiLoaded
oruser
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
:
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).