In a previous post, I discussed how to detect a user's authenticated state when using SSR. In this post I propose a similar architecture that works with static optimization. The architecture described here also works with SSR, which makes it much more appealing than one which only works with SSR and doesn't work at all with static optimization.
First, let's review some key points which will inform the requirements of our architecture:
- The client doesn't validate authentication; the server does validation. The client just needs to know whether or not the user is authenticated. In other words, just a boolean: true or false.
- The traditional methods for clients to detect authenticated state is through either a) global data populated during render by the server or b) detecting the presence of a session cookie.
- Statically optimized pages are rendered on the server during the build, but not at runtime. Therefore, we can't have the server populate global data on the page for detecting authenticated state.
- We want to avoid having our session cookies stolen by 3rd party scripts, so we'll store the session token or ID in an HttpOnly cookie. Unfortunately, that also prevents our client-side JS from reading it.
Therefore, we need something else.
HttpOnly cookies are sent to the server, so we need a server endpoint that tells us whether the user is authenticated. It could be an endpoint for retrieving the user profile: if the profile is returned, the user is authenticated; if we get a 401, the user isn't authenticated. Or it could just be an endpoint built specifically for this (e.g. /checkAuth
) which returns a 200 or 401.
As before, we will use the Context API to store our authenticated state. It will be initialized when the page loads by making a request to our API, as we just discussed. But until that request returns, the authenticated state is unknown. You might be able to assume false, but if you choose to render or redirect pages based on the authenticated state, then it's best not to make that assumption. So our context will also contain an isLoading
boolean which we can use to show a loading indicator until the authentication response is returned and we know what to do.
import React from 'react';
const AuthContext = React.createContext({
isAuthenticated: false,
isLoading: true,
setAuthenticated: () => {}
});
export const AuthProvider = ({ children }) => {
const [isAuthenticated, setAuthenticated] = React.useState(false);
const [isLoading, setLoading] = React.useState(true);
React.useEffect(() => {
const initializeAuth = async () => {
const response = await fetch('/api/checkAuth');
setAuthenticated(response.status === 200);
setLoading(false);
};
initializeAuth();
}, []);
return (
<AuthContext.Provider
value={{
isAuthenticated,
isLoading,
setAuthenticated
}}
>
{children}
</AuthContext.Provider>
);
};
export function useAuth() {
const context = React.useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
export function useIsAuthenticated() {
const context = useAuth();
return context.isAuthenticated;
}
Now our end goal is to have two HOCs, withAuth
and withoutAuth
that will render or redirect pages based on the authenticated state. The majority of their code is shared so we'll create a withAuthRedirect
HOC that they'll use.
import { useRouter } from 'next/router';
import { useAuth } from '../providers/Auth';
function DefaultLoadingFallback() {
return <p>Loading...</p>;
}
/**
* Support client-side conditional redirecting based on the user's
* authenticated state.
*
* @param WrappedComponent The component that this functionality
* will be added to.
* @param LoadingComponent The component that will be rendered while
* the auth state is loading.
* @param expectedAuth Whether the user should be authenticated for
* the component to be rendered.
* @param location The location to redirect to.
*/
export default function withAuthRedirect({
WrappedComponent,
LoadingComponent = DefaultLoadingFallback,
expectedAuth,
location
}) {
const WithAuthRedirectWrapper = props => {
const router = useRouter();
const { isLoading, isAuthenticated } = useAuth();
if (isLoading) {
return <LoadingComponent />;
}
if (typeof window !== 'undefined' && expectedAuth !== isAuthenticated) {
router.push(location);
return <></>;
}
return <WrappedComponent {...props} />;
};
return WithAuthRedirectWrapper;
}
Now we're ready to create the other two HOCs.
import withAuthRedirect from './withAuthRedirect';
/**
* Require the user to be authenticated in order to render the component.
* If the user isn't authenticated, forward to the given URL.
*/
export default function withAuth(WrappedComponent, location = '/login') {
return withAuthRedirect({
WrappedComponent,
location,
expectedAuth: true
});
}
import withAuthRedirect from './withAuthRedirect';
/**
* Require the user to be unauthenticated in order to render the component.
* If the user is authenticated, forward to the given URL.
*/
export default function withoutAuth(WrappedComponent, location = '/profile') {
return withAuthRedirect({
WrappedComponent,
location,
expectedAuth: false
});
}
Those two HOCs may be used like this:
export default withAuth(function ProfilePage() { ... });
We've accomplished our goal of an architecture which allows us to detect authentication client-side using an HttpOnly cookie and static optimization.
There is an example app using this pattern. It's also available in TypeScript.
Top comments (6)
Thanks for the example. I'm in the process of adding Firebase authentication.
I noticed that
/api/checkAuth
was being called on every page render. By adding[isAuthenticated]
as a dependency inuseEffect()
, it only calls checkAuth on initial load/refresh and login/logout.Actually, having an empty array
[]
makes it run only once. See the big yellow note that explains this at the end of the optimization section in the React hook docs.Hmm. Interesting. I'm aware of that.
For some reason though, it was calling checkAuth every time I navigated to a new page. So I guess the question is, is that the expectation?
Anyway, thanks again. I've expanded on your sample to use Firebase authentication. Also instead of having to mark each page as requiring authentication or not, I default to require authentication and have a withoutAuth HOC for those pages that are meant to be public.
It finally worked!!
Thanks for sharing man ;)
Doesn’t this just render a loading page until the client takes over? In that case, what’s the point in server side rendering?
Yes, that's how it works. It doesn't use server-side rendering. The page is statically generated during a build step.