Back to Insights
InženjeringJovan Ivezic

Autentikacija u Next.js App Router-u sa Auth.js (NextAuth v5)

Saznaj kako implementirati produkcijsku autentikaciju u Next.js App Router-u koristeći Auth.js (NextAuth v5) — upravljanje sesijama, zaštita ruta putem Proxy-ja, OAuth provajderi, credentials login i arhitekturalne odluke koje čuvaju aplikaciju sigurnom i održivom.

TL;DR — Ključni Uvidi

  • Auth.js (NextAuth v5) je de facto biblioteka za autentikaciju za Next.js — rukuje sesijama, OAuth provajderima i credentials loginom sa minimalnom konfiguracijom
  • Podaci sesije se čuvaju u potpisanom, enkriptovanom kolačiću — baza nije neophodna za osnovnu auth, ali se database adapter preporučuje za produkciju
  • Zaštiti rute na nivou Proxy-ja za najbrži mogući redirect — pre nego što stranica počne da se renderuje
  • Uvek verifikuj sesiju u Server Komponentama i Server Actions — Proxy je prva linija odbrane, ne jedina
  • Auth.js v5 je uveo novu unifikovanu konfiguraciju koja radi kroz Proxy, Server Komponente i API rute bez duplikacije

Zašto Auth.js a ne Nešto Custom

Autentikacija je jedna od oblasti gde je pravljenje sopstvenog rešenja gotovo uvek pogrešna odluka. Sesije, CSRF zaštita, rotacija tokena, OAuth flow-ovi, bezbedno rukovanje kolačićima — svaki od ovih ima dobro dokumentovane načine na koje može poći po zlu, a Auth.js ih rešava po defaultu.

Auth.js (biblioteka ranije poznata kao NextAuth) je u v5 verziji potpuno prerađena za App Router. Unifikovani konfiguracioni model znači da auth logiku definišeš jednom i koristiš je svuda — u Proxy-ju, Server Komponentama, Server Actions i API rutama — bez duplikacije ili nekonzistentnosti.


Instalacija i Konfiguracija

npm install next-auth@beta
npx auth secret

Komanda auth secret generiše AUTH_SECRET environment varijablu — ključ koji se koristi za potpisivanje i enkripciju kolačića sesije. Ovo mora biti postavljeno u produkciji.

// auth.ts — jedini izvor istine za auth konfiguraciju
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import { db } from "@/lib/db";
import { verifyPassword } from "@/lib/crypto";
 
export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    // OAuth provajder — setup jednim klikom
    GitHub({
      clientId: process.env.AUTH_GITHUB_ID,
      clientSecret: process.env.AUTH_GITHUB_SECRET,
    }),
 
    // Credentials provajder — email + lozinka
    Credentials({
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Lozinka", type: "password" },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) return null;
 
        const user = await db.users.findUnique({
          where: { email: credentials.email as string },
        });
 
        if (!user) return null;
 
        const isValid = await verifyPassword(
          credentials.password as string,
          user.passwordHash
        );
 
        if (!isValid) return null;
 
        return {
          id: user.id,
          name: user.name,
          email: user.email,
          role: user.role,
        };
      },
    }),
  ],
 
  callbacks: {
    // Uključi custom polja u JWT token
    jwt({ token, user }) {
      if (user) {
        token.role = user.role;
        token.id = user.id;
      }
      return token;
    },
 
    // Uključi custom polja u sesiju
    session({ session, token }) {
      session.user.role = token.role as string;
      session.user.id = token.id as string;
      return session;
    },
  },
 
  pages: {
    signIn: "/prijava",
    error: "/prijava",
  },
});
// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

Ovaj jedan auth.ts fajl se importuje svuda gde je auth potreban. Nema duplikacije.


Proširivanje Tipa Sesije

Po defaultu, session.user sadrži samo name, email i image. Za B2B aplikacije gde trebaš role, companyId ili druga custom polja, proširi tipove:

// types/next-auth.d.ts
import { DefaultSession } from "next-auth";
 
declare module "next-auth" {
  interface Session {
    user: {
      id: string;
      role: "admin" | "manager" | "viewer";
      companyId: string;
    } & DefaultSession["user"];
  }
}

Sa ovim na mestu, session.user.role je potpuno tipiziran kroz celu aplikaciju.


Zaštita Ruta u Proxy-ju

Najbrže mesto za preusmeravanje neautentifikovanih korisnika je proxy.ts — pre nego što bilo koja stranica počne da se renderuje. Auth.js v5 se direktno integriše sa Proxy-jem:

// proxy.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import createIntlMiddleware from "next-intl/middleware";
 
const intlProxy = createIntlMiddleware({
  locales: ["en", "sr"],
  defaultLocale: "en",
  localePrefix: "always",
});
 
const ZASTITENE_RUTE = ["/dashboard", "/account", "/admin", "/rfq"];
 
export async function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
 
  // Proveri auth za zaštićene rute
  const jeZasticena = ZASTITENE_RUTE.some((ruta) =>
    pathname.includes(ruta)
  );
 
  if (jeZasticena) {
    const session = await auth();
 
    if (!session) {
      const loginUrl = new URL("/en/prijava", request.url);
      loginUrl.searchParams.set("callbackUrl", pathname);
      return NextResponse.redirect(loginUrl, { status: 302 });
    }
 
    // Kontrola pristupa zasnovana na roli
    if (pathname.includes("/admin") && session.user.role !== "admin") {
      return NextResponse.redirect(new URL("/en/dashboard", request.url));
    }
  }
 
  // i18n rutiranje
  const response = intlProxy(request);
  return response;
}
 
export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico|api/auth).*)"],
};

Primeti da matcher isključuje api/auth — Auth.js callback rute moraju biti javno dostupne ili OAuth flow prestaje da radi.


Pristupanje Sesiji u Server Komponentama

Za stranice koje trebaju personalizovati sadržaj ili praviti auth-zavisne upite baze, pristupaj sesiji direktno u Server Komponenti:

// app/[locale]/dashboard/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
 
export default async function DashboardPage() {
  const session = await auth();
 
  // Dvostruka provera auth-a čak i ako je Proxy već proverio
  // Proxy je prva linija odbrane, ne jedina
  if (!session) {
    redirect("/prijava");
  }
 
  // Koristi podatke sesije da skopiš upit baze
  const orders = await db.orders.findMany({
    where: { companyId: session.user.companyId },
    orderBy: { createdAt: "desc" },
    take: 10,
  });
 
  return (
    <div className="mx-auto max-w-5xl px-4 py-12">
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-2xl font-bold">Dashboard</h1>
          <p className="text-sm text-gray-500">
            Dobrodošli nazad, {session.user.name}
          </p>
        </div>
        <span className="rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-700 capitalize">
          {session.user.role}
        </span>
      </div>
 
      <div className="mt-8">
        <h2 className="text-lg font-semibold">Nedavne porudžbine</h2>
        <ul className="mt-4 divide-y divide-gray-100 rounded-xl border border-gray-200">
          {orders.map((order) => (
            <li key={order.id} className="flex items-center justify-between px-4 py-3">
              <span className="text-sm font-medium">{order.reference}</span>
              <span className="text-sm text-gray-500">{order.status}</span>
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

Uvek verifikuj sesiju u Server Komponentama čak i kada Proxy štiti rutu. Odbrana u dubini — ako neko zaobiđe Proxy, stranica i dalje neće prikazati osetljive podatke.


Login i Sign Out UI

// components/LoginForm.tsx
"use client";
 
import { useActionState } from "react";
import { signIn } from "@/auth";
 
async function loginAction(
  prevState: { error?: string },
  formData: FormData
) {
  "use server";
 
  try {
    await signIn("credentials", {
      email: formData.get("email"),
      password: formData.get("password"),
      redirectTo: "/dashboard",
    });
  } catch (error) {
    return { error: "Neispravan email ili lozinka." };
  }
 
  return {};
}
 
export function LoginForm() {
  const [state, action, isPending] = useActionState(loginAction, {});
 
  return (
    <form action={action} className="space-y-5">
      {state.error && (
        <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
          {state.error}
        </div>
      )}
 
      <div className="space-y-1.5">
        <label className="block text-sm font-medium text-gray-700">Email</label>
        <input
          name="email"
          type="email"
          required
          autoComplete="email"
          className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
      </div>
 
      <div className="space-y-1.5">
        <label className="block text-sm font-medium text-gray-700">Lozinka</label>
        <input
          name="password"
          type="password"
          required
          autoComplete="current-password"
          className="w-full rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
        />
      </div>
 
      <button
        type="submit"
        disabled={isPending}
        className="w-full rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-60"
      >
        {isPending ? "Prijavljivanje..." : "Prijavi se"}
      </button>
    </form>
  );
}
// components/SignOutButton.tsx
"use client";
 
import { signOut } from "@/auth";
 
export function SignOutButton() {
  return (
    <form
      action={async () => {
        "use server";
        await signOut({ redirectTo: "/" });
      }}
    >
      <button
        type="submit"
        className="text-sm font-medium text-gray-500 hover:text-gray-700"
      >
        Odjavi se
      </button>
    </form>
  );
}

Database Adapter za Produkciju

Podrazumevana JWT strategija sesije čuva sve u kolačiću. Ovo funkcioniše za jednostavnu auth ali ima ograničenja — ne možeš invalidirati sesije na strani servera, a veličina kolačića ograničava šta možeš da čuvaš.

Za produkcijske B2B aplikacije, koristi database adapter:

npm install @auth/drizzle-adapter
# ili
npm install @auth/prisma-adapter
// auth.ts — sa database adapterom
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { db } from "@/lib/db";
 
export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(db),
  session: { strategy: "database" }, // Čuvaj sesije u DB umesto u kolačiću
  providers: [
    // ... tvoji provajderi
  ],
});

Sa database adapterom, sesije se čuvaju u tvojoj bazi. Možeš ih programatski opozvati, videti ko je prijavljen i čuvati proizvoljne količine korisničkih podataka bez brige o veličini kolačića.


Zaštita Server Actions

Server Actions koji menjaju osetljive podatke moraju takođe verifikovati sesiju:

// app/[locale]/dashboard/actions.ts
"use server";
 
import { auth } from "@/auth";
 
export async function updateOrderStatus(orderId: string, status: string) {
  const session = await auth();
 
  // Uvek verifikuj auth u Server Actions
  if (!session) {
    throw new Error("Neovlašćen pristup");
  }
 
  // Autorizacija zasnovana na roli
  if (session.user.role !== "admin" && session.user.role !== "manager") {
    throw new Error("Nedovoljne dozvole");
  }
 
  // Skopira ažuriranje na kompaniju korisnika
  await db.orders.update({
    where: {
      id: orderId,
      companyId: session.user.companyId, // Sprečava pristup podacima druge kompanije
    },
    data: { status },
  });
}

companyId scope je kritičan u multi-tenant B2B aplikacijama. Čak i ako autentifikovani korisnik nekako pozove akciju sa orderId druge kompanije, companyId provera osigurava da može menjati samo sopstvene podatke.


SEO Razmatranja

Autentikacija i SEO interaguju na važne načine za B2B aplikacije:

Javne vs. privatne stranice: Marketing stranice, listinzi proizvoda i blog sadržaj nikad ne bi trebalo da zahtevaju autentikaciju. Samo dashboard, account i transakcione stranice treba da budu iza auth-a. Prekomerna zaštita stranica ih potpuno uklanja iz Google-ovog indeksa.

Indeksiranje login stranice: Tvoja /prijava stranica treba da ima noindex — nema SEO vrednosti i troši crawl budget.

// app/[locale]/prijava/page.tsx
export const metadata = {
  robots: { index: false, follow: false },
};

Soft redirecti za botove: Kada Googlebot pogodi zaštićenu rutu, dobija redirect na /prijava. Ovo je očekivano — zaštićene stranice nisu indeksabilne po dizajnu. Osiguraj da je redirect konzistentan.


Česte Greške i Zamke

1. Zaštita ruta samo u Proxy-ju

Proxy se pokreće pre svakog zahteva, ali može biti zaobiđen ili pogrešno konfigurisan. Uvek verifikuj sesiju u Server Komponentama i Server Actions kao drugi sloj. Nikada ne pretpostavljaj da je korisnik autentifikovan samo zato što je stigao do stranice.

2. Izlaganje osetljivih podataka u kolačiću sesije

JWT sesije su enkodirane (nije enkripcija po defaultu u nekim konfiguracijama). Ne čuvaj osetljive podatke — lozinke, podatke o plaćanju, interne ID-jeve koji bi mogli omogućiti enumeraciju — u sesiji. Čuvaj samo ono što je potrebno za UI: ID korisnika, ime, email, rolu.

3. Nescopiranje upita baze na autentifikovanog korisnika

U multi-tenant aplikacijama, svaki upit baze na zaštićenoj stranici mora uključivati companyId ili userId filter. Bez njega, autentifikovani korisnik iz Kompanije A potencijalno može pristupiti podacima Kompanije B manipulacijom parametara zahteva.

4. Zaboravljanje isključivanja Auth.js ruta iz Proxy matcher-a

api/auth rute rukuju OAuth callback-ovima i moraju biti javno dostupne. Uključivanje u Proxy auth proveru će slomiti OAuth flow — korisnici će biti preusmereni na login usred OAuth callback-a, kreirajući beskonačnu petlju.


Često Postavljana Pitanja (FAQ)

Koja je razlika između Auth.js v4 (NextAuth) i v5?

Auth.js v5 je potpuno prerađen specifično za React Server Komponente i App Router. Glavne promene: unifikovana auth() funkcija koja radi u Proxy-ju, Server Komponentama i Server Actions bez posebnih importa; nova konfiguraciona struktura u root auth.ts fajlu; poboljšani TypeScript tipovi. Ako pokrećeš novi projekat, koristi v5.

Treba li koristiti JWT ili database sesije?

Za jednostavne aplikacije ili one bez baze podataka, JWT sesije (čuvane u kolačićima) su fine. Za produkcijske B2B aplikacije, database sesije su snažno preferirane — možeš ih invalidirati na zahtev (prisilna odjava svih uređaja), skaliraju na veće payloade sesije i imaš kompletan revizijski trag aktivnih sesija.

Mogu li koristiti Auth.js sa custom bazom (poput MySQL sa mysql2)?

Da. Auth.js ima zvanične adaptere za Prisma, Drizzle i nekoliko drugih. Za mysql2 bez ORM-a, trebalo bi implementirati custom adapter — što je moguće ali uključuje pisanje implementacija za createUser, getUser, createSession i druge metode. Za većinu projekata, korišćenje Drizzle-a sa MySQL-om je pragmatičan izbor.

Kako da rukovam auth u API rutama koje moraju biti javne?

API rute koje koriste webhook-ovi ili integracije trećih strana treba da koriste auth zasnovan na tajni (deljeni tajni ključ u Authorization headeru) umesto auth-a zasnovanog na sesiji. Kolačići sesije su samo za tokove bazirane na browseru. Za webhook prijemnike, validuj webhook potpis od provajdera umesto toga.