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
fromnextjs
to 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.Tag
is similar toContext.Tag
to define a service in effect:Tweet not found
The embedded tweet could not be foundβ¦
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
effect
you do not define global constants or getenv.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)
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.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 ππΌββοΈ
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 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
cookies
fromnextjs
the runtime will only work wherecookies
is 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 π€©
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
andpassword
- Performs some kind of validation that returns a valid
token
- Set the
token
using theCookies
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
withcookies
and theConfig
value (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
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 requestpathname
: Extract the pathname from the requesttoken
: Use thecookie
function 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
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
usingEffect.provideService
In 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:
Config
for environmental variablesContext
to define servicesLayer
to create and compose services implementationsManagedRuntime
to execute effects
This makes adding more features, testing, and code refactoring way easier π€
Thanks for reading!