Simplifying Authentication in Next.js with Server and Client Components

Introduction

Next.js has made significant strides in its architecture, particularly with the introduction of server components. While these changes bring about powerful features, they also introduce complexities, especially when implementing authentication. In this blog, I'll discuss a streamlined approach to handling JWT authentication in Next.js, addressing challenges like local storage access and refresh token management.

The Challenge

In Next.js, server components are rendered by default. This poses a challenge for JWT authentication since server components cannot directly access browser storage (localStorage or sessionStorage). Additionally, storing and managing refresh tokens securely becomes a critical concern.

My Solution

To tackle these issues, I devised a method that leverages Next.js server actions and a session provider for client components. This approach ensures secure token management and seamless integration between server and client components.

Step-by-Step Implementation

1. Authentication Action

First, create an authentication action using Next.js server actions. This action acts as a proxy server, handling credential validation, token issuance, and session management.

// auth.action.ts
"use server";
import { SignJWT, jwtVerify } from "jose";
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import { unstable_noStore as noStore, revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { r } from "@/config/request";
import { handleUnauthorized } from "@/lib/middleware";

const secretKey = process.env.SESSION_SECRET;
const key = new TextEncoder().encode(secretKey);

export async function encrypt(payload: any) {
  return await new SignJWT(payload)
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("30h")
    .sign(key);
}

export async function decrypt(input: string): Promise<any> {
  const { payload } = await jwtVerify(input, key, {
    algorithms: ["HS256"],
  });
  return payload;
}

export async function logout() {
  cookies().set("session", "", { expires: new Date(0) });
  redirect("/");
}

export async function getSession() {
  noStore();
  const session = cookies().get("session")?.value;
  if (!session) return;
  try {
    return await decrypt(session);
  } catch (err) {
    return;
  }
}

export async function updateSession({
  session,
  request,
}: {
  session: any;
  request: NextRequest;
}) {
  try {
    const { accessToken: token, user: { refreshToken } } = session;

    const newSession = await r.post({
      endpoint: "/auth/refreshtoken",
      token,
      payload: { refreshToken },
    });

    const expires = new Date(Date.now() + 30 * 60 * 60 * 1000);
    const res = NextResponse.next();

    res.cookies.set({
      name: "session",
      value: await encrypt(newSession),
      httpOnly: true,
      expires: expires,
    });

    return res;
  } catch (err) {
    console.log("Error while refreshing token:", err);
    return handleUnauthorized(request);
  }
}

type Response = {
  message?: string;
  error?: string;
  code: number;
};

export const login = async (payload: any): Promise<Response> => {
  try {
    const res = await fetch(
      `${process.env.NEXT_PUBLIC_BACKEND_URL}/auth/signin`,
      {
        method: "POST",
        body: JSON.stringify(payload),
        headers: { "Content-Type": "application/json" },
      }
    );

    if (!res.ok) {
      const data = await res.json()
      const session = await encrypt(data);
      cookies().set("session", session, {
        httpOnly: true,
        expires: new Date(Date.now() + 24 * 60 * 1000),
      });
      revalidatePath("/", "layout");
      return { code: res.status, message: "You have been logged in successfully !!" };
    }
  } catch (err: any) {
    console.log(err);
    return { code: err.code || 500, error: err.message || "couldn't connect to server" };
  }
};

2. Middleware for Session Management

Next, set up middleware to handle session validation and token refresh.

// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { handleUnauthorized, isPrivateRoute } from "./lib/middleware";
import { getSession, updateSession } from "./actions/auth.action";

export async function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;
  if (/\.(png|svg|jpg|webp|mp3|geojson)$/.test(pathname)) return;

  if (isPrivateRoute(pathname)) {
    const session = await getSession();
    if (!session) {
      return handleUnauthorized(request);
    }
    return updateSession({ request, session });
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!_next|api|favicon.ico).*)"],
};

3. Session Provider for Client Components

Create a session provider to manage session state in client components.

// SessionProvider.tsx
"use client";
import React, { createContext, useContext, ReactNode } from "react";

interface SessionCtxProps {
  session: any;
}

const SessionCtx = createContext<SessionCtxProps | undefined>(undefined);

const SessionProvider = ({
  children,
  session,
}: {
  children: ReactNode;
  session: any;
}) => {
  return (
    <SessionCtx.Provider value={{ session }}>{children}</SessionCtx.Provider>
  );
};

const useSession = (): SessionCtxProps => {
  const context = useContext(SessionCtx);
  if (!context) {
    throw new Error("useAuth must be used within an AuthProvider");
  }
  return context;
};

export { SessionProvider, useSession };

4. Integrate Session Provider in Layout

Finally, integrate the session provider in your layout to pass the session to client components.

// layout.tsx
import { getSession } from "./actions/auth.action";
import { SessionProvider } from "./components/SessionProvider";

const Layout = async ({ children }: { children: ReactNode }) => {
  const session = await getSession();
  return (
    <html lang="en">
      <body>
        <SessionProvider session={session}>
          {children}
        </SessionProvider>
      </body>
    </html>
  );
};

export default Layout;

How to Use Session in Client Components

"use client";
import { useSession } from "@/providers/SessionProvider";
import React from "react";

const UserPill = () => {
 const {
  session: { user },
 } = useSession();

 return (
  <div className="bg-muted w-fit flex items-center gap-2 py-1 p-1 rounded-full">
   <img
    className="h-[35px] w-[35px] rounded-full border-primary object-cover object-center"
    src={user?.imgurl ? user.imgurl : "/avatar.jpg"}
    alt=""
    height={35}
    width={35}
   />
   <div>
    <p className="font-semibold text-sm">{user?.name}</p>
    <p className="text-xs text-muted-foreground">@{user?.name}</p>
   </div>
  </div>
 );
};

export default UserPill;

Benefits of This Approach

  • Secure Token Management: Tokens are stored in HTTP-only cookies, enhancing security.
  • Seamless SSR Integration: Access tokens are accessible in both server and client components, leveraging the benefits of server-side rendering.
  • Centralized Session Management: The session provider ensures consistent session state across client components.

By following this approach, you can implement robust JWT authentication in your Next.js applications, overcoming the challenges posed by server and client component integration.