├── .eslintrc.json ├── styles ├── globals.css ├── auth.css └── Home.module.css ├── .env.example ├── public ├── favicon.ico └── vercel.svg ├── prisma ├── migrations │ ├── migration_lock.toml │ └── 20230505105922_init │ │ └── migration.sql └── schema.prisma ├── next.config.js ├── next-auth.d.ts ├── pages ├── api │ ├── hello.ts │ ├── register.ts │ └── auth │ │ └── [...nextauth].ts ├── _app.tsx ├── index.tsx ├── login.tsx └── register.tsx ├── README.md ├── .gitignore ├── tsconfig.json └── package.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 80%; 3 | margin: 100px auto; 4 | } 5 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="file:./db.sqlite3" 2 | JWT_SECRET="" 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/max-programming/auth-creds-tutorial/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | swcMinify: true, 5 | } 6 | 7 | module.exports = nextConfig 8 | -------------------------------------------------------------------------------- /next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from 'next-auth'; 2 | 3 | declare module 'next-auth' { 4 | interface Session { 5 | user: { 6 | id: string; 7 | username: string; 8 | } & Session['user']; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | 4 | type Data = { 5 | name: string 6 | } 7 | 8 | export default function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | res.status(200).json({ name: 'John Doe' }) 13 | } 14 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css'; 2 | import '../styles/auth.css'; 3 | 4 | import type { AppProps } from 'next/app'; 5 | import { SessionProvider } from 'next-auth/react'; 6 | 7 | export default function App({ Component, pageProps }: AppProps) { 8 | return ( 9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## NextAuth Credentials Tutorial 2 | 3 | This is the code for the tutorial video below. If you are interested in adding credentials (username, password) authentication to your Next.js app with NextAuth. This tutorial below will help you out 👇 4 | 5 | [![How to use Credentials Authentication in Next.js with NextAuth?](https://img.youtube.com/vi/fqXC2V-MSV4/0.jpg)](https://www.youtube.com/watch?v=fqXC2V-MSV4) 6 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSession } from 'next-auth/react'; 2 | 3 | export default function Home() { 4 | const { data, status } = useSession(); 5 | 6 | return ( 7 |
8 | {status === 'authenticated' && data !== null && ( 9 | <> 10 |

Welcome {data.user.username}

11 |

User ID: {data.user.id}

12 | {JSON.stringify(data.user)} 13 | 14 | )} 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | .env 39 | prisma/test.db 40 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "auth-creds", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@next-auth/prisma-adapter": "^1.0.6", 13 | "@prisma/client": "^4.13.0", 14 | "@types/node": "20.0.0", 15 | "@types/react": "18.2.5", 16 | "@types/react-dom": "18.2.3", 17 | "bcryptjs": "^2.4.3", 18 | "eslint": "8.39.0", 19 | "eslint-config-next": "13.4.0", 20 | "next": "13.4.0", 21 | "next-auth": "^4.22.1", 22 | "react": "18.2.0", 23 | "react-dom": "18.2.0", 24 | "typescript": "5.0.4", 25 | "zod": "^3.21.4" 26 | }, 27 | "devDependencies": { 28 | "@types/bcryptjs": "^2.4.2", 29 | "prisma": "^4.13.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /pages/login.tsx: -------------------------------------------------------------------------------- 1 | import { signIn } from 'next-auth/react'; 2 | import Link from 'next/link'; 3 | import { FormEvent } from 'react'; 4 | 5 | export default function Login() { 6 | async function handleSubmit(e: FormEvent) { 7 | e.preventDefault(); 8 | const form = new FormData(e.target as HTMLFormElement); 9 | 10 | await signIn('credentials', { 11 | username: form.get('username'), 12 | password: form.get('password'), 13 | callbackUrl: '/', 14 | }); 15 | } 16 | return ( 17 |
18 |
19 |

Login

20 | 21 | 22 | 23 | 24 | 25 |
26 |

27 | Not registered yet? Register here 28 |

29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /pages/api/register.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | import { NextApiRequest, NextApiResponse } from 'next'; 3 | import { z } from 'zod'; 4 | import bcrypt from 'bcryptjs'; 5 | 6 | const registerUserSchema = z.object({ 7 | username: z.string().regex(/^[a-z0-9_-]{3,15}$/g, 'Invalid username'), 8 | password: z.string().min(5, 'Password should be minimum 5 characters'), 9 | }); 10 | const prisma = new PrismaClient(); 11 | 12 | export default async function registerUser( 13 | req: NextApiRequest, 14 | res: NextApiResponse 15 | ) { 16 | const { username, password } = registerUserSchema.parse(req.body); 17 | const user = await prisma.user.findUnique({ 18 | where: { username }, 19 | }); 20 | 21 | if (user !== null) { 22 | return res.send({ user: null, message: 'User already exists' }); 23 | } 24 | 25 | const hashedPassword = await bcrypt.hash(password, 10); 26 | 27 | const newUser = await prisma.user.create({ 28 | data: { 29 | username, 30 | password: hashedPassword, 31 | }, 32 | }); 33 | 34 | return res.send({ user: newUser, message: 'User created successfully' }); 35 | } 36 | -------------------------------------------------------------------------------- /styles/auth.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Quicksand:wght@500&display=swap'); 2 | 3 | * { 4 | font-family: 'Quicksand', sans-serif; 5 | box-sizing: border-box; 6 | } 7 | 8 | form { 9 | background-color: #f9f9f9; 10 | width: 300px; 11 | margin: 0 auto; 12 | padding: 20px; 13 | border-radius: 10px; 14 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.3); 15 | } 16 | 17 | h2 { 18 | text-align: center; 19 | color: #9b59b6; 20 | margin-bottom: 20px; 21 | } 22 | 23 | label { 24 | display: block; 25 | margin-bottom: 5px; 26 | color: #666; 27 | } 28 | 29 | input { 30 | display: block; 31 | width: 100%; 32 | padding: 10px; 33 | margin-bottom: 10px; 34 | border-radius: 5px; 35 | border: none; 36 | box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.3); 37 | } 38 | 39 | button[type='submit'] { 40 | background-color: #9b59b6; 41 | color: #fff; 42 | padding: 10px; 43 | border: none; 44 | border-radius: 5px; 45 | cursor: pointer; 46 | width: 100%; 47 | } 48 | 49 | button[type='submit']:hover { 50 | background-color: #8e44ad; 51 | } 52 | 53 | p { 54 | margin-top: 15px; 55 | text-align: center; 56 | } 57 | 58 | a { 59 | color: #9b59b6; 60 | text-decoration: none; 61 | } 62 | 63 | a:hover { 64 | color: #8e44ad; 65 | } 66 | -------------------------------------------------------------------------------- /pages/register.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { FormEvent } from 'react'; 3 | import { signIn } from 'next-auth/react'; 4 | 5 | export default function Register() { 6 | async function handleSubmit(e: FormEvent) { 7 | e.preventDefault(); 8 | const form = new FormData(e.target as HTMLFormElement); 9 | 10 | const res = await fetch('/api/register', { 11 | method: 'POST', 12 | headers: { 13 | 'Content-Type': 'application/json', 14 | }, 15 | body: JSON.stringify({ 16 | username: form.get('username'), 17 | password: form.get('password'), 18 | }), 19 | }); 20 | const data = await res.json(); 21 | if (!data.user) return null; 22 | await signIn('credentials', { 23 | username: data.user.username, 24 | password: form.get('password'), 25 | callbackUrl: '/', 26 | }); 27 | } 28 | 29 | return ( 30 |
31 |
32 |

Register

33 | 34 | 35 | 36 | 37 | 38 |
39 |

40 | Already registered? Login here 41 |

42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "sqlite" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | model Account { 11 | id String @id @default(cuid()) 12 | userId String 13 | type String 14 | provider String 15 | providerAccountId String 16 | refresh_token String? 17 | access_token String? 18 | expires_at Int? 19 | token_type String? 20 | scope String? 21 | id_token String? 22 | session_state String? 23 | 24 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 25 | 26 | @@unique([provider, providerAccountId]) 27 | } 28 | 29 | model Session { 30 | id String @id @default(cuid()) 31 | sessionToken String @unique 32 | userId String 33 | expires DateTime 34 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 35 | } 36 | 37 | model User { 38 | id String @id @default(cuid()) 39 | name String? 40 | username String @unique 41 | password String 42 | emailVerified DateTime? 43 | image String? 44 | accounts Account[] 45 | sessions Session[] 46 | } 47 | 48 | model VerificationToken { 49 | identifier String 50 | token String @unique 51 | expires DateTime 52 | 53 | @@unique([identifier, token]) 54 | } 55 | -------------------------------------------------------------------------------- /prisma/migrations/20230505105922_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Account" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "userId" TEXT NOT NULL, 5 | "type" TEXT NOT NULL, 6 | "provider" TEXT NOT NULL, 7 | "providerAccountId" TEXT NOT NULL, 8 | "refresh_token" TEXT, 9 | "access_token" TEXT, 10 | "expires_at" INTEGER, 11 | "token_type" TEXT, 12 | "scope" TEXT, 13 | "id_token" TEXT, 14 | "session_state" TEXT, 15 | CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE 16 | ); 17 | 18 | -- CreateTable 19 | CREATE TABLE "Session" ( 20 | "id" TEXT NOT NULL PRIMARY KEY, 21 | "sessionToken" TEXT NOT NULL, 22 | "userId" TEXT NOT NULL, 23 | "expires" DATETIME NOT NULL, 24 | CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE 25 | ); 26 | 27 | -- CreateTable 28 | CREATE TABLE "User" ( 29 | "id" TEXT NOT NULL PRIMARY KEY, 30 | "name" TEXT, 31 | "username" TEXT NOT NULL, 32 | "password" TEXT NOT NULL, 33 | "emailVerified" DATETIME, 34 | "image" TEXT 35 | ); 36 | 37 | -- CreateTable 38 | CREATE TABLE "VerificationToken" ( 39 | "identifier" TEXT NOT NULL, 40 | "token" TEXT NOT NULL, 41 | "expires" DATETIME NOT NULL 42 | ); 43 | 44 | -- CreateIndex 45 | CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); 46 | 47 | -- CreateIndex 48 | CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); 49 | 50 | -- CreateIndex 51 | CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); 52 | 53 | -- CreateIndex 54 | CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token"); 55 | 56 | -- CreateIndex 57 | CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token"); 58 | -------------------------------------------------------------------------------- /pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import { PrismaAdapter } from '@next-auth/prisma-adapter'; 2 | import { PrismaClient, User } from '@prisma/client'; 3 | import { AuthOptions } from 'next-auth'; 4 | import NextAuth from 'next-auth/next'; 5 | import CredentialsProvider from 'next-auth/providers/credentials'; 6 | import { z } from 'zod'; 7 | import bcrypt from 'bcryptjs'; 8 | 9 | const loginUserSchema = z.object({ 10 | username: z.string().regex(/^[a-z0-9_-]{3,15}$/g, 'Invalid username'), 11 | password: z.string().min(5, 'Password should be minimum 5 characters'), 12 | }); 13 | const prisma = new PrismaClient(); 14 | 15 | const authOptions: AuthOptions = { 16 | adapter: PrismaAdapter(prisma), 17 | providers: [ 18 | CredentialsProvider({ 19 | credentials: { 20 | username: { type: 'text', placeholder: 'test@test.com' }, 21 | password: { type: 'password', placeholder: 'Pa$$w0rd' }, 22 | }, 23 | async authorize(credentials, req) { 24 | const { username, password } = loginUserSchema.parse(credentials); 25 | const user = await prisma.user.findUnique({ 26 | where: { username }, 27 | }); 28 | if (!user) return null; 29 | 30 | const isPasswordValid = await bcrypt.compare(password, user.password); 31 | 32 | if (!isPasswordValid) return null; 33 | 34 | return user; 35 | }, 36 | }), 37 | ], 38 | callbacks: { 39 | session({ session, token }) { 40 | session.user.id = token.id; 41 | session.user.username = token.username; 42 | return session; 43 | }, 44 | jwt({ token, account, user }) { 45 | if (account) { 46 | token.accessToken = account.access_token; 47 | token.id = user.id; 48 | token.username = (user as User).username; 49 | console.log({ user }); 50 | } 51 | return token; 52 | }, 53 | }, 54 | pages: { 55 | signIn: '/login', 56 | }, 57 | session: { 58 | strategy: 'jwt', 59 | }, 60 | secret: process.env.JWT_SECRET, 61 | }; 62 | 63 | export default NextAuth(authOptions); 64 | -------------------------------------------------------------------------------- /styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 0 2rem; 3 | } 4 | 5 | .main { 6 | min-height: 100vh; 7 | padding: 4rem 0; 8 | flex: 1; 9 | display: flex; 10 | flex-direction: column; 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | 15 | .footer { 16 | display: flex; 17 | flex: 1; 18 | padding: 2rem 0; 19 | border-top: 1px solid #eaeaea; 20 | justify-content: center; 21 | align-items: center; 22 | } 23 | 24 | .footer a { 25 | display: flex; 26 | justify-content: center; 27 | align-items: center; 28 | flex-grow: 1; 29 | } 30 | 31 | .title a { 32 | color: #0070f3; 33 | text-decoration: none; 34 | } 35 | 36 | .title a:hover, 37 | .title a:focus, 38 | .title a:active { 39 | text-decoration: underline; 40 | } 41 | 42 | .title { 43 | margin: 0; 44 | line-height: 1.15; 45 | font-size: 4rem; 46 | } 47 | 48 | .title, 49 | .description { 50 | text-align: center; 51 | } 52 | 53 | .description { 54 | margin: 4rem 0; 55 | line-height: 1.5; 56 | font-size: 1.5rem; 57 | } 58 | 59 | .code { 60 | background: #fafafa; 61 | border-radius: 5px; 62 | padding: 0.75rem; 63 | font-size: 1.1rem; 64 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, 65 | Bitstream Vera Sans Mono, Courier New, monospace; 66 | } 67 | 68 | .grid { 69 | display: flex; 70 | align-items: center; 71 | justify-content: center; 72 | flex-wrap: wrap; 73 | max-width: 800px; 74 | } 75 | 76 | .card { 77 | margin: 1rem; 78 | padding: 1.5rem; 79 | text-align: left; 80 | color: inherit; 81 | text-decoration: none; 82 | border: 1px solid #eaeaea; 83 | border-radius: 10px; 84 | transition: color 0.15s ease, border-color 0.15s ease; 85 | max-width: 300px; 86 | } 87 | 88 | .card:hover, 89 | .card:focus, 90 | .card:active { 91 | color: #0070f3; 92 | border-color: #0070f3; 93 | } 94 | 95 | .card h2 { 96 | margin: 0 0 1rem 0; 97 | font-size: 1.5rem; 98 | } 99 | 100 | .card p { 101 | margin: 0; 102 | font-size: 1.25rem; 103 | line-height: 1.5; 104 | } 105 | 106 | .logo { 107 | height: 1em; 108 | margin-left: 0.5rem; 109 | } 110 | 111 | @media (max-width: 600px) { 112 | .grid { 113 | width: 100%; 114 | flex-direction: column; 115 | } 116 | } 117 | 118 | @media (prefers-color-scheme: dark) { 119 | .card, 120 | .footer { 121 | border-color: #222; 122 | } 123 | .code { 124 | background: #111; 125 | } 126 | .logo img { 127 | filter: invert(1); 128 | } 129 | } 130 | --------------------------------------------------------------------------------