Fernando González Tostado
Posted on January 4, 2023
I've been recently using NextAuth for authenticating users in my projects. NextAuth makes the task super simple and with minimal boilerplate, secure and completely serverless. Love it!
Now that the authentication process is handled with NextAuth, we'll most certainly need to protect some routes, maybe the user details, or any other route that we shouldn't let unauthenticated users access.
While I like protecting routes in Nextjs with its built in middleware, this strategy won't work with NextAuth if you are using database sessions (only with JWT), or if you don't want to over complicate the process, this alternative will be perfect.
Time to get hands on
I expected that you already have your app with NextAuth, if you don't, check this app repository and the docs, it shouldn't take you more than 5 minutes.
The guard wrapper
We'll create a wrapper component that will use the session status with useSession
to determine whether the user should be allowed to access, or not.
// components/layouts/protectedLayouts.tsx
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
type Props = {
children: React.ReactElement;
};
/*
add the requireAuth property to the page component
to protect the page from unauthenticated users
e.g.:
OrderDetail.requireAuth = true;
export default OrderDetail;
*/
export const ProtectedLayout = ({ children }: Props): JSX.Element => {
const router = useRouter();
const { status: sessionStatus } = useSession();
const authorized = sessionStatus === 'authenticated';
const unAuthorized = sessionStatus === 'unauthenticated';
const loading = sessionStatus === 'loading';
useEffect(() => {
// check if the session is loading or the router is not ready
if (loading || !router.isReady) return;
// if the user is not authorized, redirect to the login page
// with a return url to the current page
if (unAuthorized) {
console.log('not authorized');
router.push({
pathname: '/',
query: { returnUrl: router.asPath },
});
}
}, [loading, unAuthorized, sessionStatus, router]);
// if the user refreshed the page or somehow navigated to the protected page
if (loading) {
return <>Loading app...</>;
}
// if the user is authorized, render the page
// otherwise, render nothing while the router redirects him to the login page
return authorized ? <div>{children}</div> : <></>;
};
This wrapper will allow authenticated users to see its children contents or redirect the user if he's not.
The subscription in the dependency array of the useEffect
hook will make sure that we check this logic every time that there's a navigation or session update.
I've also added a loading
return statement for the cases where the user reloads or navigates directly to the page so he doesn't get kicked out of the page before being sure about his session status.
The key is that this wrapper will only wrap the components that should be protected. Other components won't be affected by it. How can we make this while not having to manually wrap component by component and thus keeping our code DRYer?
By using the _app.tsx
component. Remember that this component runs in every route.
// pages/_app.tsx
// add requireAuth to AppProps
type AppPropsWithAuth = AppProps & {
Component: {
requireAuth?: boolean;
};
};
export default function App({ Component, pageProps }: AppPropsWithAuth) {
return <SessionProvider session={pageProps.session}>
{Component.requireAuth ? (
<ProtectedLayout>
<Component {...pageProps} />
</ProtectedLayout>
) : (
<Component {...pageProps} />
)}
</SessionProvider>;
}
In the return
statement from App
by using a ternary the ProtectedLayout
component will only wrap on the components where we add the requireAuth
property.
I've already updated the AppProps
so the component will accept this property and the compiler don't scream at us. If you don't use TS this is not necessary.
The protected components
Now, in any component where we want it to be protected we should add this property with a true
value like so:
// pages/protected/index.tsx
import { useSession } from 'next-auth/react';
const ProtectedPage = () => {
const { data: session } = useSession();
return (
<div>
<h1>Protected Page</h1>
<p>Hi {session?.user?.name}!</p>
</div>
);
};
// add the requireAuth property to the page component
ProtectedPage.requireAuth = true;
export default ProtectedPage;
If we don't add this property, the ternary logic will be false (since .requireAuth
is undefined) and the user will be free to navigate to that path regardless of his session status.
Redirecting after authentication
Finally, we'll make use of the redirectUrl
query param that it's passed to the redirection route when the user is not authorized.
We'll create a custom login page and override default one from NextAuth.
Our signIn
component could be something like this:
// pages/auth/signIn/index.tsx
import { signIn, useSession } from 'next-auth/react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
const SignInPage = () => {
const [isRedirecting, setIsRedirecting] = useState(false);
const { data: session } = useSession();
const router = useRouter();
useEffect(() => {
if (session && !isRedirecting && router.isReady) {
// display some message to the user that he is being redirected
setIsRedirecting(true);
setTimeout(() => {
// redirect to the return url or home page
router.push(router.query.returnUrl as string || '/' );
}, 2000);
}
}, [session, isRedirecting, router]);
const handleSignIn = () => {
signIn('discord');
};
return (
<div>
<h1>Sign In</h1>
<p>Sign in to access protected pages</p>
{session ? (
<div>
<p>Currently signed in as {session?.user?.name}</p>
<p>Redirecting to home page...</p>
<Link href='/'>
<button>Go to home</button>
</Link>
</div>
) : (
<button onClick={handleSignIn}>Sign In</button>
)}
</div>
);
};
export default SignInPage;
This page will have two uses, the first one, is of course displaying our login providers options to the user.
And second, NextAuth will redirect the user back to this login page, and we'll pick the router.query.returnUrl
string and if present, redirect the user to the page that he was trying to access before.
Remember the useEffect from components/layouts/protected.tsx
?
Don't forget to update the pathname to our new sign in page — /auth/signIn
.
useEffect(() => {
// check if the session is loading or the router is not ready
if (loading || !router.isReady) return;
// if the user is not authorized, redirect to the login page
// with a return url to the current page
if (unAuthorized) {
console.log('not authorized');
router.push({
pathname: '/auth/signIn',
query: { returnUrl: router.asPath },
});
}
}, [loading, unAuthorized, sessionStatus, router]);
We also must pass the new sign in path to [...nextAuth].ts
for the override.
// [...nextauth].ts
export default NextAuth({
// ...
pages: {
signIn: '/auth/signin', // Displays signin buttons
},
// ...
})
And that was it!
If you want to see the full code of the sample app please check the original code.
Sources:
Posted on January 4, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.