DEV Community

Qunling Wang
Qunling Wang

Posted on

Building an App with GitHub and Credentials Authentication in Next.js 15 with Sanity

Authentication is a crucial part of any modern application. In this guide, we will walk through how to set up authentication using GitHub and credentials (email/password) in a Next.js 15 app, while persisting user data in Sanity.

Tech Stack

  • Next.js 15: A React framework for building web applications.
  • NextAuth.js: Authentication library for Next.js.
  • Sanity: A headless CMS to store user data.
  • Tailwind CSS (optional): For styling.

Prerequisites

Before getting started, ensure you have:

  • A GitHub OAuth App set up.
  • A Sanity project created.
  • A Next.js 15 app initialized.

Step 1: Setting Up Next.js and Installing Dependencies

First, create a new Next.js 15 project and install the required dependencies:

npx create-next-app@latest my-app --ts
cd my-app
npm install next-auth @sanity/client dotenv
Enter fullscreen mode Exit fullscreen mode

Step 2: Configuring NextAuth.js

NextAuth.js handles authentication. Create an auth.ts file inside the lib directory:

import { AUTHOR_BY_EMAIL, AUTHOR_BY_GITHUB_ID_QUERY } from '@/sanity/lib/queries';
import { writeClient } from '@/sanity/lib/write-client';
import NextAuth, { Profile, User } from 'next-auth';
import GitHub from 'next-auth/providers/github';
import { client } from './sanity/lib/client';
import Credentials from 'next-auth/providers/credentials';
import { signInSchema } from './lib/zod';
import { z } from 'zod';
import bcrypt from 'bcryptjs';

export const { handlers, signIn, signOut, auth } = NextAuth({
    providers: [
        GitHub,
        Credentials({
            credentials: {
                email: {},
                password: {},
                action: { label: 'Action', type: 'text' },
            },
            authorize: async (credentials) => {
                try {
                    let user = null;
                    const { email, password, action='login' } = await signInSchema.parseAsync(credentials);


                    if (action === 'register') {

                        const existingUser = await writeClient.fetch(AUTHOR_BY_EMAIL, { email });

                        if (existingUser) {
                            throw new Error('User already exists');
                        }

                        const hashedPassword = await bcrypt.hash(password, 10);

                        const user = await writeClient.create({
                            _type: 'author',
                            email,

                            password: hashedPassword,
                        });

                        return { ...user, id: user._id };
                    }

                    // logic to verify if the user exists
                    user = await client.fetch(AUTHOR_BY_EMAIL, {
                        email
                    });

                    if (!user) {
                        throw new Error('Invalid credentials.');
                    }
                    const hashedPassword = await bcrypt.hash('password321', 10);
                    await writeClient
                    .patch(user._id)
                    .set({ password: hashedPassword })
                    .commit();

                    const isValid = await bcrypt.compare(password, user.password);

                    if (!isValid) {
                        throw new Error("Invalid password");
                    }

                    return { ...user, id: user._id };

                } catch (error) {
                    if (error instanceof z.ZodError) {
                        return null;
                    }
                }
            }
        })
    ],
    callbacks: {
        async signIn({ user: { name, email, image }, account, profile }) {
            console.log("jjjjjj")
            if (account?.provider === 'github') {
                const { id, login, bio } = profile || {};

                const existingUser = await client.fetch(AUTHOR_BY_GITHUB_ID_QUERY, {
                    id
                });

                if (!existingUser) {
                    await writeClient.withConfig({ useCdn: false }).create({
                        _type: 'author',
                        id,
                        name,
                        username: login,
                        email,
                        image,
                        bio: bio || ''
                    });
                }

                return true;
            }

            return true;
        },
        async jwt( { token, account, profile, user }) {
            console.log("gggg")
            if (account?.provider === 'github') {
                if (account && profile) {
                    const gitHubUser  = await client.withConfig({ useCdn: false }).fetch(AUTHOR_BY_GITHUB_ID_QUERY, {
                        id: profile?.id
                    });
                    token.id = gitHubUser?._id;
                }
                return token;
            } else {
                if (user) {
                    token.id = user.id;
                }
                return token;
            }

        },

        async session({ session, token }) {
            (session.user as any).id = token.id;
            return session;
        }
    }
});
Enter fullscreen mode Exit fullscreen mode

Step 3: Setting Up API Route

Create a NextAuth API route in app/api/auth/[...nextauth]/route.ts:

import { handlers } from "@/auth" // Referring to the auth.ts we just created
export const { GET, POST } = handlers
Enter fullscreen mode Exit fullscreen mode

Step 4: Configuring Sanity Schema for Users

Modify your sanity/schema.json in your Sanity project:

  [...,{
    "name": "author",
    "type": "document",
    "attributes": {
      "_id": {
        "type": "objectAttribute",
        "value": {
          "type": "string"
        }
      },
      "_type": {
        "type": "objectAttribute",
        "value": {
          "type": "string",
          "value": "author"
        }
      },
      "_createdAt": {
        "type": "objectAttribute",
        "value": {
          "type": "string"
        }
      },
      "_updatedAt": {
        "type": "objectAttribute",
        "value": {
          "type": "string"
        }
      },
      "_rev": {
        "type": "objectAttribute",
        "value": {
          "type": "string"
        }
      },
      "id": {
        "type": "objectAttribute",
        "value": {
          "type": "number"
        },
        "optional": true
      },
      "name": {
        "type": "objectAttribute",
        "value": {
          "type": "string"
        },
        "optional": true
      },
      "username": {
        "type": "objectAttribute",
        "value": {
          "type": "string"
        },
        "optional": true
      },
      "email": {
        "type": "objectAttribute",
        "value": {
          "type": "string"
        },
        "optional": true
      },
      "image": {
        "type": "objectAttribute",
        "value": {
          "type": "string"
        },
        "optional": true
      },
      "bio": {
        "type": "objectAttribute",
        "value": {
          "type": "string"
        },
        "optional": true
      },
      "password": {
        "type": "objectAttribute",
        "value": {
          "type": "string"
        },
        "optional": true
      }
    }
  }]
Enter fullscreen mode Exit fullscreen mode

Step 5: Adding Authentication to the Frontend

Modify app/login/page.tsx to show the login form:

import { Login } from './Login';

const Page = () => {
    return <Login />;
};

export default Page;
Enter fullscreen mode Exit fullscreen mode

Login.tsx

'use client';

import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { useActionState, useEffect, useState } from 'react';
import { signInAction, signInWithGithub } from './actions';
import { PasswordFields } from './PasswordFields';

const initState = {
    status: false,
    errors: []
};

export function Login() {
    const [isRegistering, setIsRegistering] = useState(false);
    const { data: session, status } = useSession();
    const [state, action] = useActionState(signInAction, initState);
    const [isPasswordValid, setIsPasswordValid] = useState(true);

    const router = useRouter();

    useEffect(() => {
        if (state.status || status === 'authenticated') {
            router.push('/');
        }
    }, [status, state.status, router]);

    return (
        <>
            <div className="min-h-screen flex items-center justify-center p-4">
                <div className="w-full max-w-md bg-white rounded-[30px] shadow-custom-top border-0 p-8 space-y-8">
                    <h2 className="text-2xl font-normal">{!isRegistering ? `Sign In` : `Register`}</h2>
                    <form action={action} className="space-y-6">
                        <input type="email" name="email" placeholder="Email" className="w-full p-2 rounded-[30px] border border-gray-300" />
                        <PasswordFields isRegistering={isRegistering} onValidityChange={setIsPasswordValid} />
                        {Boolean(state.errors.length) && (
                            <div className="text-red-500">
                                {state.errors.map((error, i) => (
                                    <ul key={i}>
                                        <li>{error}</li>
                                    </ul>
                                ))}
                            </div>
                        )}
                        <button type="submit" className={`w-full p-2 rounded-[30px] text-white ${!isPasswordValid ? 'bg-gray-300 cursor-not-allowed' : 'bg-blue-500'}`} disabled={!isPasswordValid}>
                            {isRegistering ? 'Register' : 'Sign In'}
                        </button>
                    </form>

                    <button onClick={() => setIsRegistering(!isRegistering)} className="text-blue-500 underline bg-transparent border-none p-0 cursor-pointer">
                        {!isRegistering ? `Register instead` : `Login instead`}
                    </button>
                    <form action={signInWithGithub}>
                        <button type="submit" className="py-2 px-4 max-w-md flex justify-center items-center bg-gray-600 hover:bg-gray-700 focus:ring-gray-500 focus:ring-offset-gray-200 text-white w-full transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg">
                            <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" className="mr-2" viewBox="0 0 1792 1792">
                                <path d="M896 128q209 0 385.5 103t279.5 279.5 103 385.5q0 251-146.5 451.5t-378.5 277.5q-27 5-40-7t-13-30q0-3 .5-76.5t.5-134.5q0-97-52-142 57-6 102.5-18t94-39 81-66.5 53-105 20.5-150.5q0-119-79-206 37-91-8-204-28-9-81 11t-92 44l-38 24q-93-26-192-26t-192 26q-16-11-42.5-27t-83.5-38.5-85-13.5q-45 113-8 204-79 87-79 206 0 85 20.5 150t52.5 105 80.5 67 94 39 102.5 18q-39 36-49 103-21 10-45 15t-57 5-65.5-21.5-55.5-62.5q-19-32-48.5-52t-49.5-24l-20-3q-21 0-29 4.5t-5 11.5 9 14 13 12l7 5q22 10 43.5 38t31.5 51l10 23q13 38 44 61.5t67 30 69.5 7 55.5-3.5l23-4q0 38 .5 88.5t.5 54.5q0 18-13 30t-40 7q-232-77-378.5-277.5t-146.5-451.5q0-209 103-385.5t279.5-279.5 385.5-103zm-477 1103q3-7-7-12-10-3-13 2-3 7 7 12 9 6 13-2zm31 34q7-5-2-16-10-9-16-3-7 5 2 16 10 10 16 3zm30 45q9-7 0-19-8-13-17-6-9 5 0 18t17 7zm42 42q8-8-4-19-12-12-20-3-9 8 4 19 12 12 20 3zm57 25q3-11-13-16-15-4-19 7t13 15q15 6 19-6zm63 5q0-13-17-11-16 0-16 11 0 13 17 11 16 0 16-11zm58-10q-2-11-18-9-16 3-14 15t18 8 14-14z"></path>
                            </svg>
                            Sign in with GitHub
                        </button>
                    </form>
                </div>
            </div>
        </>
    );
}

Enter fullscreen mode Exit fullscreen mode

action.ts

'use server';

import { signIn } from '@/auth';

export const signInWithGithub = async () => {
    await signIn('github');
};
export const signInAction = async (prevState: any, formData: FormData) => {
    let result = {
        status: true,
        errors: [] as string[]
    };

    const email = formData.get('email') as string;
    const password = formData.get('password') as string;
    const repeatPassword = formData.get('repeatPassword') as string;

    if (repeatPassword && password !== repeatPassword) {
        result.status = false;
        result.errors.push('Passwords do not match.');
        return result;
    }

    try {
        const res = await signIn('credentials', {
            redirect: false,
            email,
            password,
            action: Boolean(repeatPassword)? 'register' : 'login'
        });

        if (res?.error) {
            result.status = false;
            result.errors.push(res.error);
            return result;
        }
    } catch (error) {
        result.status = false;
        result.errors.push('Username and password does not match or user does not exist!');
    }

    return result;
};

Enter fullscreen mode Exit fullscreen mode

components/Logout.tsx

'use client';
import { signOut } from "next-auth/react";

export function LogoutButton() {
    const handleLogout = async () => {
        localStorage.setItem('logout', Date.now().toString());
        await signOut({ redirectTo: '/' });
    };
    return (
        <button
            onClick={handleLogout}
            className="bg-blue-500 text-white px-4 py-2 rounded-lg"
        >
            Logout
        </button>
    );
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

By following these steps, you have successfully built an authentication system in Next.js 15 using NextAuth.js, GitHub OAuth, and credentials authentication while persisting users in Sanity. This setup provides a scalable and secure foundation for your app. Find the completed code here

Top comments (0)