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
cookiesfromnextjsto store the sessiontoken - 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.Tagis similar toContext.Tagto define a service in effect:Services in @EffectTS_ with `Tag` π·οΈ π Use Context (requires flatMap) π Use Effect (direct access to service function) Tag from `Effect` makes your services as easy as it gets π€
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 sessionset: initialize session on sign indelete: remove session on sign out
Cookie token key using Config
The token will be stored as a cookie. As such it requires a key to store alongside the value.
When using
effectyou do not define global constants or getenv.processvalues directly πInstead you use the
Configmodule 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)
export const TokenKeyConfig = Config.string("TOKEN_KEY");We then need to define this value as an environmental variable in nextjs:
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.orDieWithto 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 ππΌββοΈ
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
Layeris used to compose and provide services ineffect(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
cookiesfromnextjsthe runtime will only work wherecookiesis supported (Server Component, Server Action or Route Handler)
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 π€©
Every week I dive headfirst into a topic, uncovering every hidden nook and shadow, to deliver you the most interesting insights
Not convinced? Well, let me tell you more about it
Sign in: React 19 with server action
Sign in is performed using a Server Action:
- Collect the user
emailandpassword - Performs some kind of validation that returns a valid
token - Set the
tokenusing theCookiesservice (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
RuntimeServerwithcookiesand theConfigvalue (environmental variable)
"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 π
XState + React 19 takes client/server to the next level π₯ π Actors can invoke server actions This allows to interact between client (XState actor @statelyai) and server (Server actions) with a single function call π
"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
effectwe 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 requestpathname: Extract the pathname from the requesttoken: Use thecookiefunction to extract thetoken(fromConfig)
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
pathnamematches a route that requires authentication we check if the token is available, otherwise we redirect to/sign-in - If
pathnamematches 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
NextRequestusingEffect.provideServiceIn case of any error (
catchAllCause) we just returnNextResponse.next()
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:
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:
Configfor environmental variablesContextto define servicesLayerto create and compose services implementationsManagedRuntimeto execute effects
This makes adding more features, testing, and code refactoring way easier π€
Thanks for reading!
