Implementing stateless session for Next.js using Server Actions
Wang Sijie
Posted on August 23, 2023
Introduction
Following the much-celebrated release of the App Router released, Next.js introduced another feature, Server Actions. This new innovation facilitates server-side data manipulations, reduces reliance on client-side JavaScript, and progressively enhances forms–all while using JavaScript and React to create robust web applications without the need for traditional REST APIs.
In this article, we tap into the wealth of advantages offered by this innovation and see it in action as we implement a cookie-based stateless session for Next.js. This piece serves as a step-by-step guide that will walk you through every phase of crafting a demo project using the App Router.
Our practical demonstration can be readily deployed to Vercel using Edge Runtime. And you can download the full source code from GitHub.
Steering away from REST APIs
Traditionally, if we want to create a Next.js web app that queries database in the backend, we may create some REST APIs to validate auth state and query database, then the front-end React app will call these APIs. But if there is no need to open API to the public and this react app is the only client, it seems redundant to use REST APIs for they will only be called by ourselves.
With Server Actions, the React component can now run server side code. Rather than needing to manually create an API endpoint, Server Actions automatically create an endpoint for Next.js to use behind the scenes. When calling a Server Action, Next.js sends a POST
request to the page you're on with metadata for which action to run.
The need for stateless session
As a preferred framework for creating stateless applications, Next.js often means serverless, in which we can not use the memory to store session data. Traditional sessions necessitate the use of an external storage service, such as Redis or a database.
However, when the session data remains small enough, we have an alternative path: engineer a stateless session using secure encryption methods and cookies stored on the client-side. This method bypasses the need for external storage and keeps the session data decentralized, offering non-trivial benefits regarding server load and overall application performance.
So our target is a lightweight, efficient stateless session that capitalizes on the client-side storage capability while ensuring security through well-executed encryption.
Basic session implementation
First up, we need to initiate a new project:
npx create-next-app@latest
For more details on the installation, refer to the official guide.
Crafting a session library
To make understanding Server Actions easier, we'll create a simplified version of the session first. In this version, we'll use JSON to store session data in cookies.
Create a file called session/index.ts
and include the following code:
'use server';
import { cookies } from 'next/headers';
export type Session = {
username: string;
};
export const getSession = async (): Promise<Session | null> => {
const cookieStore = cookies();
const session = cookieStore.get('session');
if (session?.value) {
return JSON.parse(session.value) as Session;
}
return null;
};
export const setSession = async (session: Session) => {
const cookieStore = cookies();
cookieStore.set('session', JSON.stringify(session));
};
export const removeSession = async () => {
const cookieStore = cookies();
cookieStore.delete('session');
};
The first line "use server"
marks this file’s functions as Server Actions.
Since we cannot access request
and response
directly, we use next/headers
to read and write cookies. This is only available in Server Actions.
Incoming: two more Server Actions
With the session library in place, it's time to implement a sign in and sign out feature to showcase the session's usability.
Incorporate the following code into user/index.ts
:
'use server';
import { removeSession, setSession } from '@/session';
export const signIn = async (username: string) => {
await setSession({ username });
};
export const signOut = async () => {
await removeSession();
};
Here, we are using a 'pretend' sign in process that merely requires a username.
Building the page
In App Router, the page is usually an asynchronous component, and Server Actions cannot be directly invoked from such a component. We need to create components using "use client"
:
components/sign-in.tsx
'use client';
import { signIn } from '@/user';
import { useState } from 'react';
const SignIn = () => {
const [username, setUsername] = useState('');
return (
<div>
<input
type="text"
value={username}
placeholder="username"
onChange={(event) => {
setUsername(event.target.value);
}}
/>
<button
disabled={!username}
onClick={() => {
signIn(username);
}}
>
Sign In
</button>
</div>
);
};
export default SignIn;
components/sign-out.tsx
'use client';
import { signOut } from '@/user';
const SignOut = () => {
return (
<button
onClick={() => {
signOut();
}}
>
Sign Out
</button>
);
};
export default SignOut;
Finally, let's construct our app/page.tsx
import { getSession } from '@/session';
import styles from './page.module.css';
import SignIn from '../components/sign-in';
import SignOut from '@/components/sign-out';
export default async function Home() {
const session = await getSession();
return (
<main className={styles.main}>
{session ? (
<div>
<div>You have signed in as {session.username}</div>
<div>
<SignOut />
</div>
</div>
) : (
<SignIn />
)}
</main>
);
}
Implementing encryption
The job of Server Actions is done. Now, the final part involves the encryption implementation that can be achieved through crypto
.
// session/encrypt.ts
import { createCipheriv, createDecipheriv } from 'crypto';
// Replace with your own key and iv
// You can generate them with crypto.randomBytes(32) and crypto.randomBytes(16)
const key = Buffer.from('17204a84b538359abe8ba74807efa12a068c20a7c7f224b35198acf832cea57b', 'hex');
const iv = Buffer.from('da1cdcd9fe4199c835bd5f1d56446aff', 'hex');
const algorithm = 'aes-256-cbc';
export const encrypt = (text: string) => {
const cipher = createCipheriv(algorithm, key, iv);
const encrypted = cipher.update(text, 'utf8', 'base64');
return `${encrypted}${cipher.final('base64')}`;
};
export const decrypt = (encrypted: string) => {
const decipher = createDecipheriv(algorithm, key, iv);
const decrypted = decipher.update(encrypted, 'base64', 'utf8');
return `${decrypted}${decipher.final('utf8')}`;
};
Next, modify the session library to implement the following:
'use server';
import { cookies } from 'next/headers';
import { decrypt, encrypt } from './encrypt';
export type Session = {
username: string;
};
export const getSession = async (): Promise<Session | null> => {
const cookieStore = cookies();
const session = cookieStore.get('session');
if (session?.value) {
try {
const decrypted = decrypt(session.value);
return JSON.parse(decrypted) as Session;
} catch {
// Ignore invalid session
}
}
return null;
};
export const setSession = async (session: Session) => {
const cookieStore = cookies();
const encrypted = encrypt(JSON.stringify(session));
cookieStore.set('session', encrypted);
};
export const removeSession = async () => {
const cookieStore = cookies();
cookieStore.delete('session');
};
Conclusion
Congratulations! You've successfully implemented a stateless session for Next.js. Here is an online preview on Vercel, and you can download the full source code here. We hope this guide aids your understanding of the brand new Server Actions.
Next, we'll be exploring how to use Server Actions to integrate Logto for Next.js. Stay tuned!
Posted on August 23, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024