Implement User Authentication With Next.js and Firebase

Building your own authentication system can be a difficult and time-consuming task. Using existing platforms to handle your authentication flow for you will save you a lot of time and doesn’t even have to cost you any money. The free plan of Firebase, one of the most popular platforms than can handle authentication for you, allows any developer to quickly build secure authentication systems easily. It helps you build applications fast, without managing infrastructure. In this tutorial, we are going to use this platform to quickly implement our own authentication flow. We will do this using Next.js, Typescript, and TailwindCSS.

To follow along, you should have an existing project. You can follow these other tutorials on Better Programming to set your project up:


Overview

In this tutorial, we are going to implement the following things:

  • Implement a login, sign up, and reset password form.
  • Store additional user data in a Firestore DB and fetch this data whenever the user logs in..
  • Created a useAuth hook and use the Context API to provide the user data to all our components.
  • Show a loading spinner icon whenever we are fetching data.
  • Show error messages whenever something goes wrong.
  • Created a hook to redirect the user when entering an authenticated and not being logged in.
  • Create a logout button.

Create the Login and Sign Up Pages

Let’s start by setting up our authentication pages and make sure we can navigate between these in our app.

Create a file /pages/signup.tsx:

Let’s do the same for out Login page (/pages/login.tsx) and make sure that we can navigate between the two.

If you haven’t done it yet, start your server with yarn dev and navigate to /login in your browser to test if the page works.


Create the Forms

Now that we have our pages, let’s create the actual sign up and login forms. A great library for handling forms in React is react-hook-form. It helps you built performant, flexible, and extensible forms with easy-to-use validation. Let's install it first:

yarn add react-hook-form

Now we can create our sign up form:

Now import the SignUpForm component inside pages/signup.tsx and Replace the "todo" comment with the just created <SignUpForm />.

You can test the form by submitting the form and checking the logs in the console. It should show you an object that looks like this:

{
  name: Jake,
  email: jake@prins.com,
  password: secretpassword,
}

Also, try out if the validations are working. All fields should be required. The email should be validated to have the right format, and the password should at least be 6 characters long.

We are doing great, let’s do the same with the login form.

Create /components/forms/LoginForm.tsx and copy and paste your sign up form code. Change the name to LoginForm and remove the "name" input, because we only need email and password to log in. Also, adjust the LoginData interface

Your form should look like this:


Create a Firebase Project

Now that we have our forms ready, we need to integrate them with Firebase. Let’s start by setting up a Firebase Project.

First, we need to create a Firebase account. Go to https://firebase.google.com/, click the “Get started” button, and follow the instructions to create your project.

Once your project is created, you should register your app inside the Firebase console. From the project overview page, click the web icon to add Firebase to your web application. Once created, you will receive your firebase config, which should look something like this:

const firebaseConfig = {
  apiKey: 'xxxx',
  authDomain: 'your-project-name.firebaseapp.com',
  databaseURL: '<https://your-project-name.firebaseio.com>',
  projectId: 'your-project-name',
  storageBucket: 'your-project-name.appspot.com',
  messagingSenderId: 'xxx',
  appId: 'xxx',
  measurementId: 'G-xxx',
}

We should now activate the sign-up methods that we would like to add to our app. Navigate to Authentication and start by activating the Email/password method.

Add Firestore (optional)

If you want to save additional information during sign up, like a username or full name, you need to set up a database to save that data. Cloud Firestore is a flexible, scalable database from Firebase. It offers seamless integration with Firebase and other Google Cloud Platform products, like Cloud Functions. And just like Firebase, it starts completely free. Only when your application really starts to scale, you might exceed the free plan, but even then you only pay for what you use. A very interesting price model if you don’t want to spend a lot (or any) money when you are just starting out.

If you want to setup Firestore then navigate to Database and click the first Create database button to add Cloud Firestore to your project. Select the option to start in test mode. We will worry about the Firestore rules later.


Implement Firebase in Your Project

We are now ready to implement Firebase in our project.

First, install Firebase:

yarn add firebase

We need the save our Firebase configuration to some environment variables.

Next.js comes with built-in support for environment variables, which allows you to use .env.local to load environment variables and expose environment variables to the browser.

Go to your .env.local file (or create it if it's not there yet). Then, past in the example below and change the dummy data with your own Firebase credentials.

NEXT_PUBLIC_FIREBASE_API_KEY="yourapikey"
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN="yourappname.firebaseapp.com"
NEXT_PUBLIC_FIREBASE_DATABASE_URL="<https://appname.firebaseio.com>"
NEXT_PUBLIC_FIREBASE_PROJECT_ID="yourappname"
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET="yourappname.appspot.com"
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID="yoursenderid"
NEXT_PUBLIC_FIREBASE_APP_ID="yourappid"
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID="yourmeasurementid"

Only if you are on a Next.js version lower then 9.4 you should create a next.config.ts file and export your environment variables yourself.

Great, we can now access our data like this: process.env.NEXT_PUBLIC_FIREBASE_API_KEY. Double-check if .env.local is added to your .gitignore, so you don't accidentally commit this data. Also keep in mind that when you deploy your application, you first need to set your production environment variables. When deploying on Vercel you can configure secrets in the Environment Variables section of the project in the Vercel dashboard.

Next up, create a file /config/firebase.ts and add your configuration.

In this file, we first declare our Firebase configuration and then initialize Firebase in our project with firebase.initializeApp(firebaseConfig).

As you can see, we also exporting some functions that we are going to need when we’re developing our application.


Sign Up

Now that we are ready to use Firebase in our project, let’s go to our SignUpForm component and start by creating our signUp function.

...
import { auth } from 'config/firebase';
...
const signUp = ({ name, email, password }) => {
 return auth
  .createUserWithEmailAndPassword(email, password)
  .then((response) => {
   console.log(response)
  })
  .catch((error) => {
   return { error };
  });
};

Now you can try out your form:

const onSubmit = (data: SignUpData) => {
  return signUp(data).then((user) => {
    console.log(user)
  })
}

If you now try out the form, you will sign up for your firebase project. Go to your Firebase console, and go to the Authentication overview. You should see the submitted data here.

Amazing! With just those couple of lines, you built a sign-up form!

That’s great, but what happens to the name property? Well, if we want to store additional data, we also want to create the user in our Firestore database. So let’s create a function that creates the user document in our database and make sure that the UID of the record is the same.

...
import { auth } from 'config/firebase';
...

const createUser = (user) => {
 return db
  .collection('users')
  .doc(user.uid)
  .set(user)
  .then(() => {
   console.log("Success")
  })
  .catch((error) => {
   console.log(error)
  });
};

We can now call this function as soon as the user has successfully signed up. Go ahead and update your signUp function:

const signUp = ({ name, email, password }) => {
  return auth
    .createUserWithEmailAndPassword(email, password)
    .then((response) => {
      return createUser({ uid: response.user.uid, email, name })
    })
    .catch((error) => {
      return { error }
    })
}

If you now try again, you should get an error message, because you cannot sign up twice with the same email address. Make sure you have deleted the first entry in your Firebase console and then try again (or try with a different email).

After submitting the sign-up form, go and check inside Authentication and Database in the Firebase console to see if both records are created.

Great, you should now have a working sign up form!

Handle state

Based on the success response we can now redirect the user to a different route after they signed up, but the only problem is that we are going to lose our state. On the redirected page, we still need to be able to tell if the user is logged in. We also want to use the data of the current user in multiple places across our application.

At this point, we need to think about the global state. We could use a state management library like Redux, but that also introduces a lot of complexity and isn’t always what you want. Especially now that React has Context and Hooks, we don’t really need a state management library at this point.


Create an Authentication Provider

Context provides a way to pass data through the component tree without having to pass props down manually at every level.

Let’s start by creating our useAuthProvider hook and move our signUp and createUser functions to this hook. We also set the user state after success or error with useState.

Create a file called hooks/useAuth and add the following code:

Now let’s use Context to create a Provider. Add the following code above the code you just placed in hooks/useAuth.tsx

Great — now wrap your AppComponent with this provider so we use this useAuth hook everywhere in our app. In your _app.tsx:

import { AppProps } from 'next/app'
import { AuthProvider } from 'hooks/useAuth'

export default function MyApp({ Component, pageProps }: AppProps): any {
  return (
    <AuthProvider>
      <Component {...pageProps} />
    </AuthProvider>
  )
}

Let’s also make a page that we can redirect to if we are successfully logged in: pages/dashboard.tsx.

Cool, now let’s try it out! If you want to try again with the same email then first delete your user data from Firebase before you submit the form (or use a different email). If everything is okay you should now be redirected to the dashboard page.

Noice! This is starting to look more like it. Let’s use our momentum and keep going.


Login

Let’s create our login feature. Inside useAuth.tsx add the following function.

const signIn = ({ email, password }) => {
  return auth
    .signInWithEmailAndPassword(email, password)
    .then((response) => {
      setUser(response.user)
      return response.user
    })
    .catch((error) => {
      return { error }
    })
}

Make sure you export it.

...
return { user, signUp, signIn };
...

Now inside LoginForm.tsx call that function on submit:

...
const LoginForm: React.FC = () => {
 const { register, errors, handleSubmit } = useForm();
 const auth = useAuth();
 const router = useRouter();const onSubmit = (data: LoginData) => {
  return auth.signIn(data).then(() => {
   router.push('/dashboard');
  });
 };
 ...

Cool, now let’s try if this works.

You should now be able to log in, accept we are missing the name. This is because the name is not coming from Firebase Authentication, but is stored in our Firestore database. We need to listen to the auth state change and get the data from the DB.

Inside the useAuthProvider hook add this function:

const getUserAdditionalData = (user: firebase.User) => {
  return db
    .collection('users')
    .doc(user.uid)
    .get()
    .then((userData) => {
      if (userData.data()) {
        setUser(userData.data())
      }
    })
}

Then, call this function after a successful sign-in request.

const signIn = ({ email, password }) => {
  return auth
    .signInWithEmailAndPassword(email, password)
    .then((response) => {
      setUser(response.user)
      getUserAdditionalData(user)
      return response.user
    })
    .catch((error) => {
      return { error }
    })
}

Great, now this should fix our problem. But it does not fix everything, because Firebase also helps us to keep users logged in. But after the user is logged in when they re-enter our application, we also need to fetch that additional data.

So let’s also subscribe to the onAuthStateChanged event and call getUserAdditionalData to fetch that data:

const handleAuthStateChanged = (user: firebase.User) => {
  setUser(user)
  if (user) {
    getUserAdditionalData(user)
  }
}
useEffect(() => {
  const unsubscribe = auth.onAuthStateChanged(handleAuthStateChanged)

  return () => unsubscribe()
}, [])

If you refresh the page, you should see your name. Magic! 🎩

To improve this some more, we could also make sure that whenever the user’s document is updated, we also update the user state in our application. We can do this by adding another effect to the useAuth hook that subscribes to the user’s document and updates the state whenever it changes.

useEffect(() => {
  if (user?.uid) {
    // Subscribe to user document on mount
    const unsubscribe = db
      .collection('users')
      .doc(user.uid)
      .onSnapshot((doc) => setUser(doc.data()))
    return () => unsubscribe()
  }
}, [])

Logout

We are now making sure we fetch the user data from our DB and update our local state so the user knows he or she is logged in. This is great, but we are not done yet. Let’s also make sure that the user can log out.

Add this signOut function to the useAuthProvider:

...
const signOut = () => {
  return auth.signOut().then(() => setUser(false));
};
...

We want to be able to call this function anywhere, so don’t forget to export it.

...
return { user, signUp, signIn, signOut };
...

Now, all we have to do is make a button that calls this function when it’s clicked. Add this button to your pages/dashboard.tsx:

...
<button
 onClick={() => auth.signOut()}
 className="w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-500 focus:outline-none focus:border-indigo-700 focus:shadow-outline-indigo active:bg-indigo-700 transition duration-150 ease-in-out"
\>
 Sign out
</button>

Try it out!

If it’s working, you will end up with a white screen. That’s not really what we want. We actually never want users to be able to enter /dashboard if they are not logged in, so let's make a hook that will redirect users if they are not logged in.

Create the following hook at hooks/useRequireAuth.tsx

import { useEffect } from 'react'
import { useRouter } from 'next/router'
import { useAuth } from 'hooks/useAuth'

export const useRequireAuth = () => {
  const auth = useAuth()
  const router = useRouter()
  useEffect(() => {
    if (auth.user === false) {
      router.push('/login')
    }
  }, [auth, router])

  return auth
}

Now use this hook inside dashboard.tsx . Replace the old const auth = useAuth() with our new hook:

...
const auth = useRequireAuth()
...

Again, magic! 🎩

You are doing amazing. We build a working sign up and login flow. Nice! Now grab some coffee, you deserved it. When you come back, we still need to code some more.


Reset Password

Because what happens when a user forgets their password? We need to handle this as well, so let’s add a function to our useAuth hook to send a reset password link.

const sendPasswordResetEmail = (email) => {
  return auth.sendPasswordResetEmail(email).then((response) => {
   return response;
  });
 };
 ...

return {
  user,
  signUp,
  signIn,
  signOut,
  sendPasswordResetEmail,
};

Now create a ResetPasswordForm component where the user can enter their email. On submit it should call that new sendPasswordResetEmail function.

Great. Now create the reset-password page that will show our new ResetPasswordForm component.

Also, add a link to this new reset-password page on our login form. Add this code right below the password input:

...
<div className="mt-4 flex items-end">
 <div className="text-sm leading-5">
  <Link href="/reset-password">
   <a
    href="#"
    className="font-medium text-indigo-600 hover:text-indigo-500 focus:outline-none focus:underline transition ease-in-out duration-150"
   >
    Forgot your password?
   </a>
  </Link>
 </div>
</div>
...

Nice. Now test things out and see your brand new authentication flow!

here is always room for some improvements. Let’s handle our loading states and error messages.


Loading States and Error Messages

It’s great that we now have a working authentication flow, but there is still a lot of room for improvement. Let’s start by handling the loading states and make sure we show an error message when something goes wrong.

Inside your SignInForm component, import useState and use it to set the state for isLoading and error. When we submit the form we set isLoading to true and clear the error. After the response is returned we set isLoading back to false and only if an error is present we set the error.

import { useState } from 'react';const \[isLoading, setIsLoading\] = useState(false);

const [error, setError] = useState(null);const onSubmit = (data: LoginData) => {
 setIsLoading(true);
 setError(null);
 return auth.signIn(data).then((response) => {
  setIsLoading(false);
  response.error ? setError(response.error) : router.push('/dashboard');
 });
};

If the error is present, we want to show it inside our form:

{
  error?.message && (
    <div className="mb-4 rounded border border-dashed border-red-600 p-2 text-center text-red-500">
      <span>{error.message}</span>
    </div>
  )
}

We also like to show the user that we are fetching data, so let’s change the text of our submit button whenever isLoading is true.

Because we are using this button on all of our forms, let’s create a reusable button component that we can import in different places.

interface ButtonProps {
  title?: string;
  isLoading?: boolean;
}
const Button = ({
  isLoading,
  title,
  children,
  ...buttonProps
}: ButtonProps &
  React.ButtonHTMLAttributes<HTMLButtonElement>): JSX.Element => {
  return (
    <button
      className="focus:shadow-outline-indigo flex w-full justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none active:bg-indigo-700"
      {...buttonProps}
    >
      {isLoading ? 'Loading...' : title}
      {children}
    </button>
  )
}

export default Button

Now we can use our button inside our form components like this:

import Button from 'components/elements/Button';

...
<Button title="Login" type="submit" isLoading={isLoading} />
...

That’s a lot better. We can make it even nicer by showing a spinner icon instead of the “Loading…” text.

At icons/spinner.tsx, create the spinner icon component:

const Spinner = (props: React.SVGProps<SVGSVGElement>): JSX.Element => {
  return (
    <svg viewBox="0 0 100 100" {...props}>
      <path d="M90.9 58.2c-3.8 18.9-20.1 32.7-39.7 33-22 .4-40.7-17-41.9-39C8 28.5 26.9 8.8 50.4 8.8c19 0 35.5 13.1 40 31.2.3 1.2 1.4 2.1 2.7 2.1 1.8 0 3.1-1.7 2.7-3.5C90.6 18.1 72 3.3 50.4 3.3c-27.2 0-49 23.4-46.6 51.1 2.1 23 21 41.2 44 42.4C71.6 98 91.7 81.9 96.2 59.4c.3-1.7-1-3.3-2.7-3.3-1.3-.1-2.4.8-2.6 2.1z" />
    </svg>
  )
}
export default Spinner

Now in our Button component, replace the loading text with the spinner component:

{isLoading ? (
 <Spinner width="20" fill="white" className="animate-spin" />
) : (
 title
)}

Note: make sure you are at least on Tailwind version 1.7 so you can use the animate-spin class.

Noice! Now go ahead and do the same for the sign-up and reset-password forms and call it a day.


Summary

  • We have implemented different pages for our authentication forms.
  • We have implement login, sign up, and reset password forms.
  • We store additional user data in a Firestore DB and fetch this data whenever the user logs in.
  • We implement a logout button.
  • We have created a useAuth hook and used the Context API to provide the user data to all our components.
  • We show a loading spinner icon whenever we are fetching data.
  • We show error messages whenever something goes wrong.
  • We have created a hook to redirect the user when entering an authenticated and not being logged in.

That’s it! Thanks for reading. I hope it was helpful. Good luck with the next steps of your project.

Stay up to date

Get notified when I publish something new, and unsubscribe at any time.