How To Build A Frameworkless Authentication System For Next.js
Publish Date - 1st June 2022
Last Updated - 5th March 2023
I tend to stay away from frameworks, most of the time.
That sounds weird because most of my JavaScript projects are in Next.js/React.js. As a developer, I am not always in charge of the architectural decisions. If the techlead or CEO wants to use a framework, then a framework we will use.
When I was running Frosty Response, I often chose to not use frameworks because there are too many unknowns (things that I cannot be sure of) that happen in the background (ever had a hydration error?).
There is another issue with frameworks. That is when a framework requires another framework. This is something I call a multi-level frameworks issue.
That is when a framework - like Next.js - have require another framework for something specific. In this specific case, Next_Auth is the authentication framework used by 90% of Next.js apps.
NextAuth not fully support email and password authentication. They make it intentionally difficult so that the user is shoe horned into using OAuth 2.0
As a user of tools, I understand opinionated software. As a developer, I do not understand opinionated development tools. I like to have the freedom to make micro changes should the need arise.
So I developed a frameworkless system for authenticating users in Next.js, without nextauth.
Note - This flow uses TypeScript. Note2 - I will ommit imports and exports from the examples Note3 - I am assuming you have a database with usernames and passwords already
Contents
- Setting Up The Application For Authentication
- Creating the Global State for Authentication with Context API
- Building the Sign In Form
- Creating the Verification API Route
- Important Connections Between These Files
- Creating the Protected Route
- Wrapping the Application with the Protected Route and Auth Contect Provider
NOTE: Authentication is a complex topic. As I learn more about security and authentication best practice, this process will be updated.
Setting Up The Application for Authentication
There are two main steps to setting up this authentication process.
Firstly, the packages. Secondly the types.
Packages
When you install jsonwebtoken, you also need to install the types
project>$ npm install jsonwebtoken
project>$ npm install --save @types/jsonwebtoken
Setting up types for the project
Let's create the types for the entire project in one file. We will be using these types throughout the script.
Create a file called types.tsx
//for the Auth Context export interface AuthContextInterface { token: string | null; isLoggedIn: boolean; login: (token: string) => void; logout: () => void; } //for wrapper components export interface childrenProps { children: React.ReactNode } //For ProtectedRoute wrapper component interface ProtectedRouteProps { children: React.ReactElement; } // for the submit form export interface eventType { (e: React.FormEvent<HTMLFormElement>): void; } // for the login information sent from the signin form export interface login { username: string; password: string; }
Creating the Global State for Authentication with Context API
Create a file called AuthContext.tsx
.
InitialToken Variable
Firstly, we need to create a single variable to hold the state of our initial token value. This initialToken
value will be passed into our state later.
let initialToken: string | null
This must be a union type with null because initialToken
will be getting its updated state from localStorage.getitem()
function . Which could be null. More on this later.
Create Context
In this file we will be creating the global state for authentication. This will include:
- the jwt token
- the login boolean state
- the login function, and,
- the logout function.
For these variables,we need to create our initial values and pass them into the createContext
function.
Create the AuthContext
variable and assign it to the createContext function. This function takes an object which will be the initial values for the variables defined above:
export const AuthContext = createContext<AuthInterface>({ token: '', isLoggedIn: false, login: (token) => {}, logout: () => {}, });
This should be exported because it will be used from outside of this file.
The AuthContextProvider
Finally, we create the AuthContextProvider
which will be used as a wrapper. This will wrap around our application in _app.js
. More on that later.
I have marked up the code to explain each part:
export const AuthContextProvider = (props: UserProviderProps) => { //our initial state using the initialToken variable created earlier const [token, setToken] = useState(initialToken); // a variable to define if the user is loggedin or not. // login is defined by having a present jwt token. // more on jwt later let userIsLoggedIn: boolean; // check if there is a token. return boolean value if (token) { userIsLoggedIn = true; } else { userIsLoggedIn = false; } useEffect(() => { // on load, check for a token in local storage initialToken = localStorage.getItem('token'); // set the token to the value of the token setToken(initialToken); // run once on load }, []); const logoutHandler = () => { setToken(null); localStorage.removeItem('token'); }; //save the token to the local storage // we will use this function later in the login form const loginHandler = (token: string) => { // also update the state to the new token value setToken(token); localStorage.setItem('token', token); }; // create context value object whose value is to be made available to all components using this context. const contextValue = { token: token, isLoggedIn: userIsLoggedIn, login: loginHandler, logout: logoutHandler, }; return ( <AuthContext.Provider value={contextValue}> {props.children} </AuthContext.Provider> ); }; export default AuthContext;
Our AuthContext is now ready to use. Next, we need our sign in form.
Building a Sign In Form
The form I will be using is the is a standard form so I will not focus on that. Here, I will focus on the important parts: the submitHandler
and AuthContext.
Create a file called SignInForm.tsx
. This is a Next.js page route.
First, in our form page component, we use our AuthContext
via useContext
. This is the most important part that connects the Auth context to the sign in form:
export default function SignInForm() { ... const authCtx = useContext(AuthContext); ... }
We then add the state variables for the username and login:
export default function SignInForm() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); ... const authCtx = useContext(AuthContext); ... }
We also need to use the useRouter
to redirect the user to a page after login.
Now, initialise the router:
export default function SignInForm() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const router = useRouter(); const authCtx = useContext(AuthContext); // submit handler will be here }
Now, we need to create the submitHandler
function that triggers when the form is submitted. I have marked up the code to explain each step:
const submitHandler: eventType = async function (e) { e.preventDefault(); //create object for submited state. //this is updated by the form on submit. const loginDetails: login = { username, password, }; // fetch - post username and password to api // we will look at the API in the next section await fetch('/api/SignIn', { method: 'post', headers: { Accept: 'application/json', 'Content-Type': 'application/json', }, //send the login info as a string to the api route body: JSON.stringify(loginDetails), }) // convert response to JSON .then((data) => data.json()) // use response .then((data) => { //run authcontext login function with the token send from the backend API authCtx.login(data.token); router.replace('/newuser'); /* console.log(data); */ }); };
To summarise where we are so far:
- We have a global context called
AuthContext
that holds the login function and the current login state based on if we have a token or not. - We have a sign in form that takes the username and password submitted by the user. This is sent to the backed API for verification. If verified, a token is sent back and saved to local storage
So, lets create the API route.
Creating the Verification API Route
There are some benefits to frameworks. One of them is the /api
folder in Next.js. It allows you to create api routes (Node.js) code directly inside the project. The routes created inside the api folder are instantly available depending on the name of the file.
If you notice, in the SignInForm.tsx
(the previous step) we sent the post request to /api/SignIn
. That is what we will create now.
In this example, create a file called SignIn.tsx
.
The TypeScript function for the api handler is:
export default async function Handler( req: NextApiRequest, res: NextApiResponse ) { try { if (req.method === 'POST') { // all code below will be inside here } } catch (err) { console.log(err) }
First we access the post request body that was sent in. And validate that the username or password are not empty:
// Get user input with types const { username, password }: login = req.body; // Validate user input if (!(username && password)) { res.status(400).send('All input is required'); }
Then, we use our database search function to get the user assigned to that username. Here, I am assuming you have your own function for this. This script is database agnostic. Whether you use PostgreSQL, a JSON file or MongoDB, it doesnt matter. Just search for a user and put that function here:
// Validate if user exist in our database const user = getOneUser(username);
If user not found, send error:
if (user === undefined) { res.status(400).json({ res: 'no user' }); }
If a user is found, verify the password. This part is important. Password encryption (hashing) and verification is performed by a package called bcryptjs. I will write a seperate article about password encryption and verification. For now, assume this function returns a true or false depending of if the sent in password is matches the records:
const isValid = await verifyPassword(password, user.password);
Finally, if the token is value (true) then sign the token with jwt and add it to the user object. Then return the token in the response back to the post request:
if (isValid) { // Create token const token = sign( { username: username, }, 'SECRET_KEY' ); //add token to user object user.token = token; } res.status(200).json({ token: user.token });
What is happening here?
Our post API received the username and password (login details) from a post request. The login details is matched to the database to find a user. If a user is found, the password is verified. If the password is verified the token is signed (created) and sent back as a response to the post request.
Important Connections Between These Files
Inside the SignInForm.tsx, we send a post request to the backend API.
Inside the second then()
block of the post request, we use the AuthCtx.login()
function. This function takes data.token
.
data.token
comes from the key value pair sent back to the post request as a response:
//SignIn.tsx res.status(200).json({ token: user.token });
The AuthCtx.login()
function then saves the token to localStorage:
//AuthContext.tsx const loginHandler = (token: string) => { setToken(token); localStorage.setItem('token', token); };
When a page is loaded, the useEffect()
function inside the AuthContext.tsx
file runs. It sets the initialToken value to what is in the localStorage:
//AuthContext.tsx useEffect(() => { initialToken = localStorage.getItem('token'); setToken(initialToken); }, []);
When this runs, the initialToken variable is set to a new value. This updates the state.
The login state variable userIsLoggedIn
value is then retested in the same file. Now that there is a token, it will be set to true:
//AuthContext.tsx if (token) { userIsLoggedIn = true; } else { userIsLoggedIn = false; }
Creating The Protected Route
The final file we need to create is a wrapper component that protects the entire application. This wrapper runs the authentication checks defined above for every component it wraps.
Created a file calleed ProtectedRoute.tsx
export default function ProtectedRoute({ children }: ProtectedRouteProps) { const authCtx = useContext(AuthContext); const isLoggedIn = authCtx.isLoggedIn; if (isLoggedIn == false) { return ( <> <div className="plain-page"> <SignInForm /> </div> </> ); } else { return children; } }
in this file, we are returning the sign up box if not loggedin, or the children components. This is a wrapper component, therefore the children will be based on the path we visit.
Wrapping the Application with the Protected Route and Auth Contect Provider
Finally, We need to wrap _app.js
(the entire app) with our AuthContextProvider
and the ProtectedRoute
wrapper component.
export default function App({ Component, pageProps }: AppProps) { return ( <AuthContextProvider> <ProtectedRoute> <Component {...pageProps} /> </ProtectedRoute> </AuthContextProvider> ); }
And we are done. Frameworkless authentication is completed. We can use username and passwords to create authentication.
Bye for now.