Next.js

How I Built Authentication in Next.js Without NextAuth

NextAuth kept fighting me, so I built auth with Supabase and @supabase/ssr instead. Cookies, middleware, protected routes — the full setup.

ZZ

Zeeshan Zakir

July 16, 20266 min readNext.js
How I Built Authentication in Next.js Without NextAuth

It was 1 a.m. and I had eleven tabs open, all of them NextAuth documentation. Half described version 4, half described version 5 (which had a different name now), and the GitHub issue I was reading ended with someone saying "this works differently in the App Router." My login page had been "almost done" for two days.

I'm not here to say NextAuth is bad — it powers a huge number of production apps and handles OAuth providers well. But for my situation, a Supabase project where the database already knew about my users, adding a second auth layer on top was creating problems instead of solving them. So I ripped it out and built auth directly with Supabase. It ended up being less code, and more importantly, I understand every line of it.

Here's the complete setup.

The mental model first

Three pieces, that's all:

  1. A browser client that handles login/signup forms and stores the session in cookies.
  2. A server client that reads those cookies so Server Components and server actions know who's asking.
  3. A middleware that refreshes the session token before it expires.

Everything else is details. The reason cookies matter (instead of localStorage, which older tutorials use) is that Server Components can't read localStorage. Cookies travel with every request, so the server always knows who the user is.

Step 1: The two clients

Install @supabase/supabase-js and @supabase/ssr. Then create two small files.

The browser client — lib/supabase/client.ts:

import { createBrowserClient } from '@supabase/ssr';

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );
}

The server client — lib/supabase/server.ts:

import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export async function createClient() {
  const cookieStore = await cookies();
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => cookieStore.getAll(),
        setAll: (list) => {
          try {
            list.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // called from a Server Component — middleware handles refresh
          }
        },
      },
    }
  );
}

Step 2: The middleware (the part everyone skips)

Access tokens expire after about an hour. Without something refreshing them, users get mysteriously logged out. The middleware runs on every request and keeps the session fresh:

// middleware.ts
import { type NextRequest } from 'next/server';
import { updateSession } from '@/lib/supabase/middleware';

export async function middleware(request: NextRequest) {
  return await updateSession(request);
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg)).*)'],
};

The updateSession helper comes straight from the Supabase docs — it creates a server client wired to the request/response cookies and calls getUser(), which refreshes the token if needed. I copied it verbatim; there's no prize for rewriting it.

The first time I built this, I skipped the middleware entirely because the app "worked without it." It did — for exactly one hour per user. That was a fun bug report to receive.

Step 3: Login with a server action

No API route needed. The form posts to a server action:

// features/auth/actions.ts
'use server';

import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';

export async function login(formData: FormData) {
  const supabase = await createClient();

  const { error } = await supabase.auth.signInWithPassword({
    email: formData.get('email') as string,
    password: formData.get('password') as string,
  });

  if (error) redirect('/login?error=' + encodeURIComponent(error.message));
  redirect('/dashboard');
}

Signup is the same shape with signUp(). The session lands in cookies automatically because our server client is wired to them.

Step 4: Protecting routes

I protect the whole logged-in area in one place — the layout of my (app) route group:

// app/(app)/layout.tsx
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';

export default async function AppLayout({ children }) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) redirect('/login');
  return <>{children}</>;
}

Every page inside the group is now protected without repeating the check.

The security detail most tutorials get wrong

This one matters, so I'll be direct: on the server, use getUser(), not getSession().

getSession() reads the session from the cookie and trusts it as-is. Cookies come from the browser, and browsers are enemy territory — a tampered cookie could claim to be anyone. getUser() sends the token to the Supabase Auth server and verifies it's genuine before answering.

The difference never shows up in local testing, which is exactly why it's dangerous. getSession() is fine in client components for reading UI state. For any server-side decision about who the user is — layouts, server actions, route handlers — it's getUser(), every time.

Mistakes I made along the way

Starting with localStorage. My first attempt followed an older tutorial storing tokens in localStorage. Login "worked," but every Server Component thought I was logged out. If a tutorial mentions localStorage for Next.js auth, check its date and move on.

Redirecting from the wrong place. I initially did the auth check in middleware and the layout and some pages. Triple redirects caused a lovely infinite loop on the login page. Pick one layer of defense per area — the layout check covers my app group, and that's enough.

Forgetting email confirmation exists. New Supabase projects require email confirmation by default. I spent forty minutes debugging "invalid credentials" for a user who simply hadn't clicked the link in their inbox.

What I gave up by skipping NextAuth

Honesty section: if you need "Sign in with Google, GitHub, and five other providers" today, NextAuth's provider catalog is genuinely convenient — though Supabase handles OAuth providers too, with a dashboard toggle and the same client code. What I gave up in practice was very little. What I gained is auth that lives in the same system as my database and my row level security, one mental model instead of two, and no dependency on documentation that changes shape between major versions.

Six projects later, this exact setup is my default starting point. The 1 a.m. version of me with eleven tabs open would have paid good money for it — so consider this article me sending it back in time.



Need help building this?

I offer full-stack development services for startups and product teams.

If you want a faster path from idea to shipped product, I can help with architecture, frontend systems, backend APIs, and launch-ready builds.

View Services

Share this post

Related posts

More practical reading from the blog to keep your momentum going.