Login & Register Forms in Next.js with NextAuth.js

Step 14 of 31Next.js Tutorial Series | Source code for this step

Live Demo →


What You Will Build

↑ Index

By the end of this step, your app will have two fully working forms:

  • A login page at /login — users enter email and password to sign in
  • A register page at /register — new users create an account, then are signed in automatically

Both forms handle validation errors, display feedback, and redirect to the homepage on success.

Goal: Replace the placeholder login page with a real form, create the register page, and update the Header to show the logged-in user.


Table of Contents

  1. Build the Login Page
  2. Understanding the Login Code
  3. Build the Register Page
  4. Understanding the Register Code
  5. Update the Header
  6. Understanding the Header Code — including useSession() deep dive
  7. The Complete Auth Flow
  8. Verify the Result
  9. Common Mistakes
  10. Summary & Key Takeaways

Build the Login Page

↑ Index

Replace the entire contents of app/login/page.tsx:

'use client'

import { signIn } from 'next-auth/react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useState } from 'react'

export default function LoginPage() {
  const router = useRouter()
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')
  const [loading, setLoading] = useState(false)

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    setError('')
    setLoading(true)

    const result = await signIn('credentials', {
      email,
      password,
      redirect: false,
    })

    setLoading(false)

    if (result?.error) {
      setError('Invalid email or password')
      return
    }

    router.push('/')
    router.refresh()
  }

  return (
    <div className="flex items-center justify-center py-12">
      <div className="w-full max-w-md p-8 bg-white rounded-lg shadow-md">
        <h2 className="mb-2 text-2xl font-bold text-center text-gray-900">
          Sign In
        </h2>
        <p className="mb-6 text-center text-gray-500">
          Sign in to your Superblog account
        </p>

        <form onSubmit={handleSubmit} className="space-y-4">
          {error && (
            <div className="p-3 text-sm text-red-700 bg-red-50 rounded">
              {error}
            </div>
          )}

          <div>
            <label
              htmlFor="email"
              className="block mb-1 text-sm font-medium text-gray-700"
            >
              Email
            </label>
            <input
              id="email"
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
              className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-teal-500"
              placeholder="[email protected]"
            />
          </div>

          <div>
            <label
              htmlFor="password"
              className="block mb-1 text-sm font-medium text-gray-700"
            >
              Password
            </label>
            <input
              id="password"
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
              className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-teal-500"
              placeholder="••••••••"
            />
          </div>

          <button
            type="submit"
            disabled={loading}
            className="w-full py-2 text-white bg-teal-600 rounded hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed"
          >
            {loading ? 'Signing in...' : 'Sign In'}
          </button>
        </form>

        <p className="mt-4 text-sm text-center text-gray-500">
          Don&apos;t have an account?{' '}
          <Link href="/register" className="text-teal-600 hover:text-teal-700">
            Register
          </Link>
        </p>
      </div>
    </div>
  )
}

Understanding the Login Code

↑ Index

signIn() from next-auth/react

const result = await signIn('credentials', {
  email,
  password,
  redirect: false,
})

This is the NextAuth client-side sign-in function. It sends the credentials to the NextAuth API route, which calls the authorize function we wrote in Step 13.

  • 'credentials' — the provider ID. This tells NextAuth to use the Credentials provider.
  • email, password — the values from the form inputs. These are passed to authorize(credentials).
  • redirect: false — by default, signIn() redirects the browser on success or failure. Setting this to false prevents the redirect and returns a result object instead. This lets us handle success and errors ourselves.

The result object

if (result?.error) {
  setError('Invalid email or password')
  return
}

When redirect: false is set, signIn() returns an object:

PropertyValue on successValue on failure
result.errorundefinedError message string
result.oktruefalse
result.status200401

We check result?.error — if it exists, the login failed. We show a generic error message instead of the specific error (never tell the user whether the email or password was wrong — that leaks information).

e.preventDefault()

async function handleSubmit(e: React.FormEvent) {
  e.preventDefault()

Without this, the browser would submit the form as a traditional HTML form submission (full page reload, data in URL). preventDefault() stops that default behavior so we can handle the submission with JavaScript using signIn().

router.push() and router.refresh()

router.push('/')
router.refresh()

After a successful login:

  1. router.push('/') — navigate to the homepage
  2. router.refresh() — refresh the server-rendered parts of the page. Without this, Server Components would still show the old (unauthenticated) data because they were rendered before the login happened.

useRouter() is imported from next/navigation — the App Router version. Do not import from next/router (that is the old Pages Router).

The type="email" attribute

<input id="email" type="email" ... />

type="email" is an HTML attribute that provides built-in browser validation. The browser will check that the input looks like an email address and show an error if it doesn't. Combined with required, it prevents the user from submitting empty or invalid values without any JavaScript.

The loading state

const [loading, setLoading] = useState(false)

We track whether the form is currently submitting. While loading:

  • The button shows "Signing in..." instead of "Sign In"
  • The button is disabled to prevent double-submissions
  • The disabled:opacity-50 class dims the button visually

Build the Register Page

↑ Index

Create the folder and file:

mkdir -p app/register

Create app/register/page.tsx:

'use client'

import { signIn } from 'next-auth/react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useState } from 'react'

export default function RegisterPage() {
  const router = useRouter()
  const [name, setName] = useState('')
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')
  const [loading, setLoading] = useState(false)

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    setError('')
    setLoading(true)

    // Step 1: Create the account
    const res = await fetch('/api/auth/register', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name, email, password }),
    })

    if (!res.ok) {
      const data = await res.json()
      setError(data.error || 'Registration failed')
      setLoading(false)
      return
    }

    // Step 2: Sign in automatically
    const result = await signIn('credentials', {
      email,
      password,
      redirect: false,
    })

    setLoading(false)

    if (result?.error) {
      setError('Account created, but sign-in failed. Try logging in.')
      return
    }

    router.push('/')
    router.refresh()
  }

  return (
    <div className="flex items-center justify-center py-12">
      <div className="w-full max-w-md p-8 bg-white rounded-lg shadow-md">
        <h2 className="mb-2 text-2xl font-bold text-center text-gray-900">
          Create Account
        </h2>
        <p className="mb-6 text-center text-gray-500">
          Join Superblog and start writing
        </p>

        <form onSubmit={handleSubmit} className="space-y-4">
          {error && (
            <div className="p-3 text-sm text-red-700 bg-red-50 rounded">
              {error}
            </div>
          )}

          <div>
            <label
              htmlFor="name"
              className="block mb-1 text-sm font-medium text-gray-700"
            >
              Name
            </label>
            <input
              id="name"
              type="text"
              value={name}
              onChange={(e) => setName(e.target.value)}
              className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-teal-500"
              placeholder="Your name"
            />
          </div>

          <div>
            <label
              htmlFor="email"
              className="block mb-1 text-sm font-medium text-gray-700"
            >
              Email
            </label>
            <input
              id="email"
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
              className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-teal-500"
              placeholder="[email protected]"
            />
          </div>

          <div>
            <label
              htmlFor="password"
              className="block mb-1 text-sm font-medium text-gray-700"
            >
              Password
            </label>
            <input
              id="password"
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
              minLength={6}
              className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-teal-500"
              placeholder="••••••••"
            />
          </div>

          <button
            type="submit"
            disabled={loading}
            className="w-full py-2 text-white bg-teal-600 rounded hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed"
          >
            {loading ? 'Creating account...' : 'Create Account'}
          </button>
        </form>

        <p className="mt-4 text-sm text-center text-gray-500">
          Already have an account?{' '}
          <Link href="/login" className="text-teal-600 hover:text-teal-700">
            Sign In
          </Link>
        </p>
      </div>
    </div>
  )
}

Understanding the Register Code

↑ Index

The two-step submission

// Step 1: Create the account
const res = await fetch('/api/auth/register', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name, email, password }),
})

// Step 2: Sign in automatically
const result = await signIn('credentials', {
  email,
  password,
  redirect: false,
})

Registration is a two-step process:

  1. Create the account — call our /api/auth/register API route (from Step 13). This hashes the password and inserts the user into the database.
  2. Sign in automatically — once the account exists, immediately sign the user in using signIn(). The user does not need to go to the login page separately.

Handling the registration response

if (!res.ok) {
  const data = await res.json()
  setError(data.error || 'Registration failed')
  setLoading(false)
  return
}

res.ok is true when the HTTP status is 200-299. If the registration endpoint returns 400 (missing fields) or 409 (email taken), res.ok is false and we display the error message from the API response.

The name field is optional

<input id="name" type="text" value={name} ... />

Notice the name input does not have the required attribute. This matches our Prisma schema where name is String? (optional). Users can register without providing a name.

The minLength attribute

<input id="password" type="password" minLength={6} ... />

minLength={6} is a built-in HTML validation attribute. The browser will prevent form submission if the password is shorter than 6 characters. This is client-side validation — a quick first line of defense. You should also validate on the server.


Update the Header

↑ Index

Now that authentication works, the Header should show different content based on whether the user is signed in. Replace the entire contents of app/Header.tsx:

'use client'

import { useSession, signOut } from 'next-auth/react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'

export default function Header() {
  const pathname = usePathname()
  const { data: session } = useSession()

  const links = [
    { href: '/', label: 'Home' },
    { href: '/posts', label: 'Posts' },
  ]

  return (
    <header className="bg-white shadow-sm">
      <nav className="flex items-center justify-between w-full max-w-4xl px-6 py-4 mx-auto">
        <Link href="/" className="text-xl font-bold text-gray-900">
          Superblog
        </Link>
        <div className="flex items-center space-x-4">
          {links.map((link) => {
            const isActive = pathname === link.href
            return (
              <Link
                key={link.href}
                href={link.href}
                className={`rounded px-3 py-2 text-sm font-medium transition ${
                  isActive
                    ? 'bg-teal-600 text-white'
                    : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
                }`}
              >
                {link.label}
              </Link>
            )
          })}

          {session ? (
            <>
              <Link
                href="/posts/new"
                className={`rounded px-3 py-2 text-sm font-medium transition ${
                  pathname === '/posts/new'
                    ? 'bg-teal-600 text-white'
                    : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
                }`}
              >
                New Post
              </Link>
              <span className="text-sm text-gray-500">
                {session.user?.name || session.user?.email}
              </span>
              <button
                onClick={() => signOut()}
                className="rounded px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition"
              >
                Sign Out
              </button>
            </>
          ) : (
            <Link
              href="/login"
              className={`rounded px-3 py-2 text-sm font-medium transition ${
                pathname === '/login'
                  ? 'bg-teal-600 text-white'
                  : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
              }`}
            >
              Sign In
            </Link>
          )}
        </div>
      </nav>
    </header>
  )
}

Understanding the Header Code

↑ Index

useSession() — Where It Comes From

const { data: session } = useSession()

useSession() is not a React built-in hook and not a Next.js hook. It is provided by NextAuth.js (Auth.js):

HookProvided by
useState, useEffectReact
useRouter, usePathnameNext.js
useSessionNextAuth / Auth.js

It works because we wrapped the app in <SessionProvider> in Step 13. The SessionProvider uses React Context internally to make the session available to all components in the tree.

What useSession() Returns

useSession() returns an object with two properties — data and status:

const { data: session, status } = useSession()

status has three possible values:

statusMeaning
"loading"Session is being fetched (initial page load)
"authenticated"User is signed in
"unauthenticated"No active session

data (renamed to session above) is the session object:

{
  "user": {
    "name": "Alice",
    "email": "[email protected]"
  },
  "expires": "2026-04-14T..."
}

When no one is signed in, data is null.

In our Header, we only use data and skip status because we do not need a loading state — the Header simply shows "Sign In" until the session arrives. But in other components, you can use status for loading indicators:

const { data: session, status } = useSession()

if (status === 'loading') return <p>Loading...</p>
if (!session) return <p>Not authenticated</p>
return <p>Welcome {session.user?.name}</p>

useSession() vs auth() — When to Use Which

Since useSession() is a React hook, it only works in Client Components ('use client'). For Server Components, NextAuth provides the auth() function instead:

// Server Component — use auth()
import { auth } from '@/auth'
const session = await auth()

// Client Component — use useSession()
import { useSession } from 'next-auth/react'
const { data: session } = useSession()

Prefer auth() on the server when possible — it requires no extra client-side request and renders faster. Use useSession() only when you need session data inside interactive Client Components (like our Header, which uses signOut() and needs to re-render when the session changes).

We will use auth() in Server Components in Step 15.

Conditional rendering

{session ? (
  // Logged in: show New Post, user name, Sign Out
  <>
    <Link href="/posts/new">New Post</Link>
    <span>{session.user?.name}</span>
    <button onClick={() => signOut()}>Sign Out</button>
  </>
) : (
  // Not logged in: show Sign In link
  <Link href="/login">Sign In</Link>
)}

The ternary operator renders different content based on the session:

  • Signed in — shows the "New Post" link, the user's name or email, and a "Sign Out" button
  • Not signed in — shows only the "Sign In" link

signOut()

<button onClick={() => signOut()}>Sign Out</button>

signOut() is imported from next-auth/react. It clears the session cookie and redirects to the homepage. No additional code needed — NextAuth handles the cleanup.

Hiding "New Post" from guests

Notice that the "New Post" link only appears when session exists. This means unauthenticated users cannot see the link. Later (in Step 15), we will also protect the route on the server side — but hiding the link is a good first step for UX.


The Complete Auth Flow

↑ Index

Registration

1. User visits /register
2. User fills in name, email, password → clicks "Create Account"
3. Frontend calls POST /api/auth/register
4. Server hashes password with bcrypt
5. Server creates user in database
6. Server returns { id, email, name } with status 201
7. Frontend immediately calls signIn('credentials', { email, password })
8. NextAuth's authorize() validates the credentials
9. NextAuth creates a JWT session cookie
10. Frontend redirects to / with router.push('/')
11. Header re-renders showing the user's name and "Sign Out"

Login

1. User visits /login
2. User enters email + password → clicks "Sign In"
3. Frontend calls signIn('credentials', { email, password, redirect: false })
4. NextAuth calls authorize() in auth.ts
5. authorize() queries the database and compares the bcrypt hash
6. If valid → NextAuth creates a JWT session cookie → returns { ok: true }
7. If invalid → returns { error: "..." }
8. Frontend redirects to / on success, or shows error on failure

Sign Out

1. User clicks "Sign Out" in the Header
2. signOut() clears the session cookie
3. Page refreshes → useSession() returns null
4. Header re-renders showing "Sign In" link

Verify the Result

↑ Index

Make sure your dev server is running:

pnpm dev

Test registration

  1. Navigate to http://localhost:3000/register.
  2. Enter a name, email, and password (at least 6 characters).
  3. Click "Create Account".
  4. You should be redirected to the homepage.
  5. The Header should show your name and a "Sign Out" button.

Test sign out

  1. Click "Sign Out" in the Header.
  2. The Header should switch back to showing "Sign In".

Test login

  1. Navigate to http://localhost:3000/login.
  2. Enter the email and password you just registered with.
  3. Click "Sign In".
  4. You should be redirected to the homepage with your session restored.

Test error handling

  1. Go to /login and enter a wrong password. You should see "Invalid email or password".
  2. Go to /register and try registering with the same email. You should see "A user with this email already exists".

Test with seed users

The seed data uses password123 for all users. Try logging in with any seed user email.


Common Mistakes

↑ Index

Forgetting the SessionProvider

Error: [next-auth]: `useSession` must be wrapped in a <SessionProvider />

This means you did not wrap the app in <Providers> in layout.tsx (Step 13). The useSession() hook requires SessionProvider to be an ancestor in the component tree.

Importing from next/router instead of next/navigation

// ❌ This is the Pages Router version — does not work in App Router
import { useRouter } from 'next/router'

// ✅ This is the App Router version
import { useRouter } from 'next/navigation'

Missing router.refresh() after sign-in

// ❌ Server Components still show stale data
router.push('/')

// ✅ Refresh forces Server Components to re-render with new session
router.push('/')
router.refresh()

Without router.refresh(), Server Components like the homepage (which show database counts) will display cached data from before the login. The refresh tells Next.js to re-run all Server Components on the current route.

Not handling the redirect: false option

// ❌ signIn() redirects the page — you lose control
await signIn('credentials', { email, password })

// ✅ redirect: false lets you handle success/error yourself
const result = await signIn('credentials', { email, password, redirect: false })
if (result?.error) { ... }

Without redirect: false, signIn() will redirect to an error page on failure — you cannot show a custom error message.


File Structure After This Step

↑ Index

app/
├── api/
│   ├── auth/
│   │   ├── [...nextauth]/
│   │   │   └── route.ts
│   │   └── register/
│   │       └── route.ts
│   └── posts/
│       └── route.ts
├── login/
│   └── page.tsx              ← Updated — real login form
├── register/
│   └── page.tsx              ← NEW — registration form
├── Header.tsx                ← Updated — shows session state
├── providers.tsx
├── layout.tsx
└── ...
auth.ts

Summary & Key Takeaways

↑ Index

ConceptWhat it means
signIn('credentials', { ... })Client-side function that calls the NextAuth authorize flow
redirect: falsePrevents automatic redirect so you can handle errors in the UI
useSession()Returns the current session in Client Components
signOut()Clears the session cookie and logs the user out
router.refresh()Forces Server Components to re-render with the latest session
Two-step registrationCreate the account via API, then sign in with signIn()
Conditional renderingShow different Header content based on session being null or not

What is Next

In Step 15, we will build a session-aware UI — protecting routes so only authenticated users can access certain pages, and reading the session in Server Components to show personalized content.