HeyBinyang

Part 7: Adding Simple Authentication to blog-demo (Next.js + Prisma + Auth.js)

·Tech·visibility4 reads ·Development

In the previous article, we already got blog-demo running in Next.js: we can list articles, create articles, view details, and add comments.

But there is an obvious problem: all write operations use the hardcoded authorId = 1 by default, with no login concept at all.

In this article, we will do three things:

  1. Add a minimal usable login system (Auth.js / NextAuth style) to the project;

  2. Change blog-demo’s “New Article / Post a Comment” to “Must be logged in to operate”;

  3. In the API Route, retrieve the current user ID from the session instead of the hardcoded authorId.

We won’t use third-party OAuth, nor a complex registration process. We’ll only use the simplest version of Email/Password or Credentials login to build the overall skeleton of “Prisma + Next.js + Auth”.

Note: This article focuses on “engineering connection”, not on the full functionality of Auth.js, so the configuration will be as concise and clear as possible.


1. Choosing an Auth Solution: Why Auth.js / NextAuth

Next.js’s recommended auth solution is Auth.js (formerly NextAuth). Community examples almost always pair it with Prisma.

The benefits are:

  • It has a dedicated Prisma Adapter, allowing user tables and session tables to be persisted directly in the database;

  • It works well with the App Router, with examples in the official docs;

  • We don’t need to design all security details ourselves.

In this article, we’ll use Auth.js as an example, focusing on:

  • How to integrate the Prisma User table with Auth.js;

  • How to get the current user in API Routes / Server Components.


2. Extending the Schema: Adding User Table Structure for Auth.js

Auth.js generally requires a set of models (User / Account / Session / VerificationToken). The official command can add these directly to the Prisma schema.

But we already have our own blog-demo User model and don’t want to be completely overwritten. There are two approaches:

  • Let Auth.js fully take over the user model: let it add its own User, then reference this User in blog-demo’s relations;

  • Let Auth.js use our existing User, but add the necessary fields.

For simplicity, we will choose: unify the User model and let Auth.js also use it.

You can extend the existing User model slightly, e.g., add fields for login (like username or hashed password) and possibly a role field (role control will be used later).

model User {
  id        Int       @id @default(autoincrement())
  email     String    @unique
  name      String
  // For simplicity, use username + hashedPassword
  username  String?   @unique
  password  String?   // Store encrypted password (must be hashed in production)

  role      String    @default("USER") // Simple role

  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt

  profile   Profile?
  posts     Post[]
  comments  Comment[]

  // If you use Auth.js PrismaAdapter later, you also need to add Account/Session etc. models per its docs
}

If you plan to use the full Auth.js PrismaAdapter, it's best to follow the official guide to have it create all models at once, then adjust blog-demo’s relations to use its User.

To keep focus, we assume:

  • You already have a User that can log in (whether created by Auth.js or pre‑inserted);

  • We will focus on the part “get current user + restrict write operations”.


3. Configuring Auth.js in Next.js: auth.ts + /api/auth

Following the recommended usage for the App Router, we typically configure Auth.js in lib/auth.ts and export the handler in app/api/auth/[...nextauth]/route.ts.

3.1 Install Dependencies

In the project root:

pnpm add next-auth @auth/prisma-adapter

3.2 Configure Auth.js: lib/auth.ts

Example lib/auth.ts (simplified, using Credentials Provider):

// lib/auth.ts
import NextAuth, { type NextAuthConfig } from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { blogPrisma } from './prisma-blog'
import type { User as PrismaUser } from '../generated/blog'

export const authConfig: NextAuthConfig = {
  adapter: PrismaAdapter(blogPrisma as any),
  session: {
    strategy: 'jwt',
  },
  providers: [
    Credentials({
      name: 'Credentials',
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type: 'password' },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials.password) return null

        const user = await blogPrisma.user.findUnique({
          where: { email: credentials.email },
        })
        if (!user) return null

        // Password verification should be done here (BCrypt etc.)
        // For simplicity, allow any password for now
        // if (!compareHash(credentials.password, user.password)) return null

        return {
          id: String(user.id),
          name: user.name,
          email: user.email,
          role: user.role,
        }
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id
        token.role = (user as any).role
      }
      return token
    },
    async session({ session, token }) {
      if (token && session.user) {
        session.user.id = token.id as string
        ;(session.user as any).role = token.role
      }
      return session
    },
  },
}

export const { handlers, auth, signIn, signOut } = NextAuth(authConfig)

A few key points:

  • Using the Credentials Provider: only shows an “Email + Password” form;

  • In authorize, use Prisma to read the User table;

  • In callbacks, write user.id and role into the JWT, then sync them onto session.user;

  • The auth function can be called in Server Components/APIs to get the session for the current request.

In production, proper password hashing verification should be used. Here we omit the specific password logic to focus on the overall structure of Prisma combined with auth.

3.3 /api/auth/[...nextauth] Route

app/api/auth/[...nextauth]/route.ts:

import { handlers } from '@/lib/auth'

// Auth.js will use GET/POST handlers
export const { GET, POST } = handlers

At this point, the Auth.js foundations are set. You can use the hooks from next-auth/react on the client to login/logout; on the server, use auth() to get current user info.


4. Using auth() in API Routes: Restrict Write Operations to Logged‑in Users

Now, let’s revisit the API Routes we wrote in Part 6, such as /api/posts and /api/posts/[id]/comments.

The goals are:

  • Remove the authorId sent in the body;

  • Instead, read userId from the session;

  • If not logged in, return 401 immediately.

4.1 Modify POST /api/posts: Use the Currently Logged‑in User as Author

Modify POST in app/api/posts/route.ts:

import { NextRequest, NextResponse } from 'next/server'
import { blogPrisma } from '@/lib/prisma-blog'
import { auth } from '@/lib/auth'

export async function POST(req: NextRequest) {
  try {
    const session = await auth()
    if (!session?.user?.id) {
      return NextResponse.json(
        { error: 'Not logged in, cannot create a post' },
        { status: 401 },
      )
    }

    const userId = Number(session.user.id)
    const body = await req.json()
    const { title, content, categorySlugs } = body

    if (!title) {
      return NextResponse.json(
        { error: 'title is required' },
        { status: 400 },
      )
    }

    const post = await blogPrisma.$transaction(async (tx) => {
      const created = await tx.post.create({
        data: {
          title,
          content,
          published: true,
          authorId: userId,
        },
      })

      if (Array.isArray(categorySlugs) && categorySlugs.length > 0) {
        const categories = await tx.category.findMany({
          where: { slug: { in: categorySlugs } },
        })
        if (categories.length > 0) {
          await tx.postCategory.createMany({
            data: categories.map((c) => ({
              postId: created.id,
              categoryId: c.id,
            })),
            skipDuplicates: true,
          })
        }
      }

      return await tx.post.findUnique({
        where: { id: created.id },
        include: {
          author: { select: { id: true, name: true } },
          categories: { include: { category: true } },
        },
      })
    })

    if (!post) {
      return NextResponse.json({ error: 'Failed to create post' }, { status: 500 })
    }

    const result = {
      id: post.id,
      title: post.title,
      content: post.content,
      createdAt: post.createdAt,
      author: post.author,
      categories: post.categories.map((pc) => pc.category),
    }

    return NextResponse.json(result, { status: 201 })
  } catch (e: any) {
    console.error(e)
    return NextResponse.json(
      { error: e.message || 'Failed to create post' },
      { status: 500 },
    )
  }
}

Key changes:

  • Use auth() to get the session, return 401 if absent;

  • No longer allow the client to send authorId to prevent spoofing;

  • All write operations are tied to the currently logged‑in user.

4.2 Modify POST /api/posts/[id]/comments: Use the Currently Logged‑in User as Commenter

Similarly, add login protection to the comment endpoint:

app/api/posts/[id]/comments/route.ts:

import { NextRequest, NextResponse } from 'next/server'
import { blogPrisma } from '@/lib/prisma-blog'
import { auth } from '@/lib/auth'

type RouteParams = {
  params: { id: string }
}

export async function POST(req: NextRequest, { params }: RouteParams) {
  try {
    const session = await auth()
    if (!session?.user?.id) {
      return NextResponse.json(
        { error: 'Not logged in, cannot comment' },
        { status: 401 },
      )
    }
    const authorId = Number(session.user.id)
    const postId = Number(params.id)
    const body = await req.json()
    const { content } = body

    if (!content || !Number.isFinite(postId)) {
      return NextResponse.json(
        { error: 'content and a valid postId are required' },
        { status: 400 },
      )
    }

    const comment = await blogPrisma.$transaction(async (tx) => {
      const post = await tx.post.findUnique({
        where: { id: postId },
        select: { id: true },
      })
      if (!post) throw new Error('Post does not exist')

      return await tx.comment.create({
        data: {
          content,
          postId,
          authorId,
        },
        include: {
          author: { select: { id: true, name: true } },
        },
      })
    })

    return NextResponse.json(comment, { status: 201 })
  } catch (e: any) {
    console.error(e)
    const message =
      e.message === 'Post does not exist' ? e.message : 'Failed to create comment'
    const status = message === 'Post does not exist' ? 400 : 500
    return NextResponse.json({ error: message }, { status })
  }
}

At this point, blog-demo’s “write” endpoints are tied to login status:

  • Unauthenticated requests receive a 401;

  • The logged‑in user’s id comes from the session, not from a client parameter.


5. Using Login Status in Pages: Show Only Relevant Buttons

Now adjust the frontend pages from the previous article: replace the hardcoded authorId = 1 with login‑dependent logic, and add UI decisions:

  • If not logged in, the new‑article page should prompt to log in;

  • On the article detail page, hide the comment form or prompt to log in when not authenticated.

5.1 Using useSession in Client Components

If you use next-auth/react, you can use the useSession hook on the client to get the login status.

Install:

pnpm add next-auth

Inject SessionProvider in app/layout.tsx (abbreviated), then use it in comment-form.tsx and new/page.tsx.

Example with CommentForm:

'use client'

import { FormEvent, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'

export default function CommentForm({ postId }: { postId: number }) {
  const router = useRouter()
  const { data: session, status } = useSession()
  const [content, setContent] = useState('')
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  if (status === 'loading') {
    return <p className="text-xs text-gray-500">Checking login status...</p>
  }

  if (!session?.user) {
    return (
      <p className="text-xs text-gray-500">
        You must be logged in to post a comment.
      </p>
    )
  }

  async function handleSubmit(e: FormEvent) {
    e.preventDefault()
    setError(null)
    setLoading(true)
    try {
      const res = await fetch(`/api/posts/${postId}/comments`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ content }),
      })
      const data = await res.json()
      if (!res.ok) {
        throw new Error(data.error || 'Comment failed')
      }
      setContent('')
      router.refresh()
    } catch (err: any) {
      setError(err.message)
    } finally {
      setLoading(false)
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-2">
      <textarea
        className="w-full rounded border px-3 py-2 text-sm min-h-[80px]"
        placeholder="Write your comment..."
        value={content}
        onChange={(e) => setContent(e.target.value)}
        required
      />
      {error && <p className="text-xs text-red-500">{error}</p>}
      <button
        type="submit"
        disabled={loading}
        className="rounded bg-blue-600 px-3 py-1 text-xs text-white disabled:opacity-60"
      >
        {loading ? 'Submitting...' : 'Post Comment'}
      </button>
    </form>
  )
}

Similarly, the new‑article page can disable the form when not logged in and show a “Please log in” prompt.


6. Summary: Basic Integration of Prisma + Next.js + Auth

This article essentially did two things:

  • Based on the existing Prisma User model, integrated Auth.js/NextAuth to give Next.js login status;

  • In API Routes, used auth() to get the current user, changing blog-demo’s “write operations” from a hardcoded authorId to “the real logged‑in user”.

From Prisma’s perspective, almost nothing changed:

  • The Schema gained a few fields/models related to auth;

  • Prisma Client usage remains the same, except you no longer trust the userId sent by the client; instead, you obtain it from the auth system’s session.

Share