Skip to content
Innovialabs

How to Add Authentication to Next.js: Auth.js, Clerk or Custom

Practice4 min

In short

Clerk is the fastest start but keeps user data with a third party and costs more as you grow; Auth.js is free and stores users in your database; custom auth gives full control. In any case, check the session inside every server component and server action, not only in middleware.

The short answer

  • Clerk — a hosted service with ready sign-in screens, user management and organizations. Fastest to launch; you pay per active users at scale, and user data lives on the provider's servers.
  • Auth.js (formerly NextAuth) — an open-source library. Free, users are stored in your database through adapters, many OAuth providers out of the box. You build the UI yourself.
  • Custom auth — your own sessions, password hashing and OTP flows. Maximum control and no vendor, but full responsibility for security.

Comparison

AspectClerkAuth.jsCustom
CostFree tier, then paid by active usersFree, you pay only for hostingDevelopment and maintenance time
Time to launchFastestMediumLongest
ControlLimited to the provider's optionsHighFull
Where user data livesProvider's infrastructureYour databaseYour database
UIReady componentsBuild your ownBuild your own

Data location matters in Uzbekistan. The personal data law requires that personal data of citizens of Uzbekistan be stored on servers located in the country. If that applies to your project, a hosted service abroad may not fit; Auth.js or a custom solution with a local database is safer. Confirm the requirements for your case with a lawyer.

Protecting routes: three layers

Middleware is convenient for redirecting guests, but it should not be your only check. Treat it as a first filter and verify access close to the data.

  1. Middleware (or proxy in newer Next.js versions) — redirects unauthenticated users from /dashboard to /login. Keep it fast: check that a session cookie exists, nothing heavy.
  2. Server components — check the session before loading data.
  3. Server actions and route handlers — check the session and permissions inside every action.
// app/dashboard/page.tsx
import { auth } from '@/auth'
import { redirect } from 'next/navigation'

export default async function Dashboard() {
  const session = await auth()
  if (!session?.user) redirect('/login')
  const projects = await getProjects(session.user.id)
  // render projects
}

Server actions are public endpoints

A server action can be called with a direct POST request, even if the button is hidden in the UI. Always check both authentication and ownership:

'use server'
import { auth } from '@/auth'

export async function deleteProject(id: string) {
  const session = await auth()
  if (!session?.user) throw new Error('Unauthorized')
  const project = await db.project.findUnique({ where: { id } })
  if (project?.ownerId !== session.user.id) throw new Error('Forbidden')
  await db.project.delete({ where: { id } })
}

A good pattern is a data access layer: functions like getProjects that check the session themselves, so no page can forget it.

Login by phone and SMS

In Uzbekistan many users prefer a phone number to an email. The typical flow:

  1. Normalize the number to one format, for example +998XXXXXXXXX.
  2. Generate a short random code, store only its hash with an expiry time.
  3. Send it through a local SMS gateway.
  4. On verification, compare hashes, limit attempts, then create a session.

Must-haves: rate limiting per number and per IP (SMS costs money and attracts abuse), a short code lifetime, and blocking after several wrong attempts. In Auth.js this flow fits into a Credentials provider; Clerk supports phone sign-in, but check pricing and delivery for your country.

Login via Telegram

Telegram is a natural login method for local audiences. Two common options:

  • Telegram Login Widget on the website: Telegram returns user data with a hash. Verify it on the server with an HMAC-SHA256, where the key is the SHA-256 of your bot token, and check that auth_date is recent.
  • Telegram Mini App: verify initData on the server using the algorithm from Telegram's documentation, then create your own session.
import crypto from 'node:crypto'

export function verifyTelegramLogin(data: Record<string, string>, botToken: string) {
  const { hash, ...fields } = data
  const checkString = Object.keys(fields).sort().map((k) => `${k}=${fields[k]}`).join('\n')
  const secret = crypto.createHash('sha256').update(botToken).digest()
  const expected = crypto.createHmac('sha256', secret).update(checkString).digest('hex')
  const fresh = Date.now() / 1000 - Number(fields.auth_date) < 86400
  return fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(hash ?? ''))
}

Never trust Telegram data from the client without this check.

Common mistakes

  • Relying only on middleware and leaving server actions unprotected.
  • Storing sessions or tokens in localStorage instead of httpOnly cookies.
  • Storing OTP codes in plain text and without attempt limits.
  • Choosing a hosted provider without checking data residency requirements.

FAQ

Yes, but plan it. You will need to export users, map their IDs to your database and handle passwords, which usually cannot be exported in a usable form, so users may have to log in via a code or reset.

Read next

Have an idea or a task? Tell us about it

We'll reply within one business day and propose a solution. The consultation is free.