β€’

tech

NextJs Authentication with Effect and React 19

Implement authentication in nextjs using Effect: dependency injection, configuration variables, cookies service, middleware, everything composable and testable.


Sandro Maglione

Sandro Maglione

Software development

With React 19, Server components, Server actions, and all these new primitives coming in nextjs authentication is becoming a mess πŸ₯΄

I spent some time to organize all the resources that I found and implement a robust auth solution based on effect:

  • Full control with dependency injection and configuration variables
  • Use cookies from nextjs to store the session token
  • Use middleware to verify user access for authenticated routes
  • Everything is fully composable and testable

This is what I did πŸ‘‡


Cookies service

Authentication is based on storing a session token sent by the server inside a client cookie.

The first step therefore is implementing an effect service that allows to manage cookies.

We do this using Effect.Tag:

Effect.Tag is similar to Context.Tag to define a service in effect:

Cookies.ts
import { Effect } from "effect";

export class Cookies extends Effect.Tag("Cookies")<
  Cookies,
  {
    get: (name: string) => string | undefined;
    set: (name: string, value: string) => void;
    delete: (name: string) => void;
  }
>() {}

Cookies defines a generic cookies service with 3 methods:

  • get: verify session
  • set: initialize session on sign in
  • delete: remove session on sign out

The token will be stored as a cookie. As such it requires a key to store alongside the value.

When using effect you do not define global constants or get env.process values directly πŸ™Œ

Instead you use the Config module to inject configurations values

We define a Config.string that retrieves env.process.TOKEN_KEY from the server node environment:

Important: This configuration value will only be accessible inside the server (node), it cannot be read from the client (browser)

config.ts
export const TokenKeyConfig = Config.string("TOKEN_KEY");

We then need to define this value as an environmental variable in nextjs:

.env.local
TOKEN_KEY="auth-token"

Methods to set and delete token

We use the Config value we just defined to implement the getToken, setToken and deleteToken methods inside Cookies.

These methods read the Config value and then call get/set/delete from the cookie service:

We use Effect.orDieWith to crash the app if the config value is missing πŸ’₯

If an environmental variables is missing is definitely the developer fault, no way to recover from that πŸ’πŸΌβ€β™‚οΈ

Cookies.ts
import { Effect } from "effect";
import { TokenKeyConfig } from "./config";

export class Cookies extends Effect.Tag("Cookies")<
  Cookies,
  {
    get: (name: string) => string | undefined;
    set: (name: string, value: string) => void;
    delete: (name: string) => void;
  }
>() {
  static readonly getToken = TokenKeyConfig.pipe(
    Effect.flatMap((tokenKey) => this.get(tokenKey)),
    Effect.orDieWith(
      (error) => new Error(`Missing token key ${error._op}`)
    )
  );

  static readonly setToken = (value: string) =>
    TokenKeyConfig.pipe(
      Effect.flatMap((tokenKey) => this.set(tokenKey, value)),
      Effect.orDieWith(
        (error) => new Error(`Missing token key ${error._op}`)
      )
    );

  static readonly deleteToken = TokenKeyConfig.pipe(
    Effect.flatMap((tokenKey) => this.delete(tokenKey)),
    Effect.orDieWith(
      (error) => new Error(`Missing token key ${error._op}`)
    )
  );
}

Running effects with Runtime

In order to execute the final Effect we need to provide a valid implementation of the Cookies service.

We define a Layer that implements Cookies using cookies from nextjs:

A Layer is used to compose and provide services in effect (dependency injection)

import * as Cookies from "@/lib/services/Cookies";
import { Effect, Layer } from "effect";
import { cookies } from "next/headers";

const NextCookies = Layer.effect(
  Cookies.Cookies,
  Effect.sync(() => {
    const nextCookies = cookies();
    return Cookies.Cookies.of({
      get: (name) => nextCookies.get(name)?.value,
      set: nextCookies.set,
      delete: nextCookies.delete,
    });
  })
);

Defining a custom ManagedRuntime

We define a custom runtime from the Cookies layer we just defined.

We use the ManagedRuntime module to create the runtime. We then export the runtime (RuntimeServer):

Since we use cookies from nextjs the runtime will only work where cookies is supported (Server Component, Server Action or Route Handler)

RuntimeServer.ts
import * as Cookies from "@/lib/services/Cookies";
import { Effect, Layer, ManagedRuntime } from "effect";
import { cookies } from "next/headers";

const NextCookies = Layer.effect(
  Cookies.Cookies,
  Effect.sync(() => {
    const nextCookies = cookies();
    return Cookies.Cookies.of({
      get: (name) => nextCookies.get(name)?.value,
      set: nextCookies.set,
      delete: nextCookies.delete,
    });
  })
);


const Live = ManagedRuntime.make(NextCookies);
export const RuntimeServer = Live;

There is more 🀩

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.

Sign in: React 19 with server action

Sign in is performed using a Server Action:

  • Collect the user email and password
  • Performs some kind of validation that returns a valid token
  • Set the token using the Cookies service (setToken)

A server action is defined by adding "use server" directive at the top of the file πŸ‘€

The function will be executed on the server, which allows us to use RuntimeServer with cookies and the Config value (environmental variable)

sign-in-action.ts
"use server";

import { RuntimeServer } from "@/lib/RuntimeServer";
import * as Cookies from "@/lib/services/Cookies";
import { Effect } from "effect";

export async function signInAction(
  { email, password }: { email: string, password: string }
): Promise<boolean> {
  return RuntimeServer.runPromise(
    Effect.gen(function* () {
      const { token } = yield* /// Sign in request that returns `token`
      yield* Cookies.Cookies.setToken(token);
      return true;
    })
  );
}

You can then execute this function from a form on the client:

Note: I use XState actors to implement the sign in flow on my app πŸ‘‡

"use client";

import { signInAction } from "@/lib/machines/sign-in/sign-in-action";
import { useActionState } from "react";

export default function SignInForm() {
  /// Collect email and password here, or use `FormData` πŸ‘¨β€πŸ’»
  const [, action] = useActionState(
    () => signInAction({ email, password }),
    null
  );
  
  return (
    <form action={action}>
      <input
        id="email"
        name="email"
        type="email"
        inputMode="email"
        placeholder="[email protected]"
        autoComplete="email"
      />
      <input
        id="password"
        name="password"
        type="password"
        placeholder="Enter password"
        autoComplete="current-password"
      />

      <Button type="submit">Sign in</Button>
    </form>
  );
}

Authentication session using middleware

The final step is checking for a valid session for authenticated routes.

We do this by using nextjs middleware.

We start by creating a service that wraps NextRequest from nextjs.

With effect we can create a reusable service for any value πŸ’‘

This allows to easily compose services using effect's services and layers πŸͺ„

We then define 3 methods derived from NextRequest:

  • cookie: Extract cookie value from the request
  • pathname: Extract the pathname from the request
  • token: Use the cookie function to extract the token (from Config)
NextRequest.ts
import { Config, Context, Effect } from "effect";
import type { NextRequest as _NextRequest } from "next/server";
import { TokenKeyConfig } from "./config";

export class NextRequest extends Context.Tag("NextRequest")<
  NextRequest,
  _NextRequest
>() {}

const cookie = (name: string) =>
  NextRequest.pipe(Effect.map((req) => req.cookies.get(name)?.value));

export const pathname = NextRequest.pipe(
  Effect.map((req) => req.nextUrl.pathname)
);

export const token = TokenKeyConfig.pipe(
  Effect.flatMap(cookie),
  Effect.orDieWith((error) => new Error(`Missing token key ${error._op}`))
);

Session check in middleware

The final step is putting all together inside middleware.ts:

  • If pathname matches a route that requires authentication we check if the token is available, otherwise we redirect to /sign-in
  • If pathname matches the sign in route and the token is defined we redirect to /dashboard
  • In all other cases we just return NextResponse.next()

We provide a valid implementation of NextRequest using Effect.provideService

In case of any error (catchAllCause) we just return NextResponse.next()

middleware.ts
import * as NextRequest from "@/lib/services/NextRequest";
import { Effect } from "effect";
import { NextResponse, type NextRequest as _NextRequest } from "next/server";

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

export default async function middleware(
  req: _NextRequest
): Promise<NextResponse> {
  return Effect.runPromise(
    Effect.gen(function* () {
      const pathname = yield* NextRequest.pathname;
      const token = yield* NextRequest.token;

      if (pathname !== "/sign-in") {
        /// πŸ‘‡ Auth route and token already defined (i.e. user signed in)
        if (token === undefined) {
          return NextResponse.redirect(new URL("/sign-in", req.url));
        }
      } else {
        /// πŸ‘‡ Sign in route and token not defined (i.e. user signed out)
        if (token !== undefined) {
          return NextResponse.redirect(new URL("/dashboard", req.url));
        }
      }

      return NextResponse.next();
    }).pipe(
      Effect.provideService(NextRequest.NextRequest, req),
      Effect.catchAllCause(() => Effect.succeed(NextResponse.next()))
    )
  );
}

With these we make sure that all routes that require authentication are protected. After the user signs in token will be defined and the user will have access to the app.

The middleware is used to perform an optimistic check πŸ‘€

Verifying that the token exists is not enough. We then need to verify that the token is valid πŸ™Œ

We perform this check on the page level using Server components πŸ‘‡

Verify valid token in Server components

We can use server components to make requests to the database. We extract the token and send it to authorize the request.

In this step we can verify that the token is valid, and otherwise sign out the user and redirect to the /sign-in page:

page.tsx
const main = Effect.gen(function* () {
  const token = yield* Cookies.Cookies.getToken;
  if (token === undefined) {
    return null;
  }

  /// Get and return data from the database πŸ‘€
  ///
  /// If `token` is invalid, then sign out by calling `deleteToken` 🀝
});

export default async function Page() {
  const data = await RuntimeServer.runPromise(main);
  
  /// If `data` missing the redirect to `/sign-in` 
  if (data === null) {
    return redirect("/sign-in");
  }

  return (
    /// Component here πŸ‘‰
  );
}

By using effect we organized all the authentication logic in their own services. We can now compose different implementations (for example a different cookies implementation or testing implementation).

We achieved this by using multiple effect modules:

  • Config for environmental variables
  • Context to define services
  • Layer to create and compose services implementations
  • ManagedRuntime to execute effects

This makes adding more features, testing, and code refactoring way easier 🀝

Thanks for reading!

πŸ‘‹γƒ»Interested in learning more, every week?

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.