How to Add Authentication to Next.js: Auth.js, Clerk or Custom
Practice4 minInnovialabs editorial team
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
| Aspect | Clerk | Auth.js | Custom |
|---|---|---|---|
| Cost | Free tier, then paid by active users | Free, you pay only for hosting | Development and maintenance time |
| Time to launch | Fastest | Medium | Longest |
| Control | Limited to the provider's options | High | Full |
| Where user data lives | Provider's infrastructure | Your database | Your database |
| UI | Ready components | Build your own | Build 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.
- Middleware (or proxy in newer Next.js versions) — redirects unauthenticated users from
/dashboardto/login. Keep it fast: check that a session cookie exists, nothing heavy. - Server components — check the session before loading data.
- 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:
- Normalize the number to one format, for example
+998XXXXXXXXX. - Generate a short random code, store only its hash with an expiry time.
- Send it through a local SMS gateway.
- 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 thatauth_dateis recent. - Telegram Mini App: verify
initDataon 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
localStorageinstead ofhttpOnlycookies. - Storing OTP codes in plain text and without attempt limits.
- Choosing a hosted provider without checking data residency requirements.