How to use Server Actions in NextJS?
Server Actions are a powerful new feature in Next.js 14, allowing developers to run functions directly on the server, but call them from components on the client side. This removes the need for traditional API routes for many use cases, like form submissions or data fetching, and improves performance by keeping heavy logic server-side. In this tutorial, weâll be using the App Router, which is a newer way to structure Next.js apps. The key difference is that Server Actions must be used with server components, which weâll explore shortly.
What is Server Actions in Next.js?
In Next.js 14, Server Actions need to be explicitly defined using the 'use server'
directive. This tells Next.js that the function is meant to run on the server and not the client. These actions are often defined in files within the app
directory (or wherever your server components live).
For example:
// app/actions/sendEmail.ts
'use server';
export async function sendEmail(email: string, message: string) {
// Imagine this function interacts with an external API
// to send an email.
console.log(`Sending email to: ${email}, message: ${message}`);
}
Explanation:
- The
'use server'
directive ensures this function is executed on the server. - The
sendEmail
function simulates sending an email by logging the data. In a real-world scenario, this could involve making an HTTP request to an email service. - This action will later be triggered from a client component.
Server Components vs Client Components
Understanding the difference between Server Components and Client Components is critical when working with Server Actions.
- Server Components: These components render on the server and are great for tasks like fetching data from a database or making API requests because they donât have access to the browserâs environment (like
window
ordocument
). - Client Components: These components are rendered in the browser and are necessary for interactivity, such as handling form inputs, managing state with
useState
, or using lifecycle hooks likeuseEffect
.
Example:
// app/components/ServerComponent.tsx
import { sendEmail } from '../actions/sendEmail';
export default async function ServerComponent() {
return (
<div>
<h1>This is a Server Component</h1>
<p>It renders on the server, so it can call server actions like sending an email.</p>
</div>
);
}
Explanation:
- This is a Server Component. It imports the
sendEmail
action, but since the component runs on the server, you can execute server actions directly in it if needed. - The component renders static content since server-side rendering (SSR) is done before sending HTML to the client.
Example:
// app/components/ClientComponent.tsx
'use client';
import { useState } from 'react';
export default function ClientComponent() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
Explanation:
- This is a Client Component. It uses
useState
to manage a count, and updates the count when the button is clicked. - The
'use client'
directive ensures that this component will run in the browser, making it suitable for handling interactions.
UseCases
Client Components
A common use case in Next.js 14 is passing a server action as a prop from a Server Component to a Client Component. This allows the client to trigger server-side functionality (like sending data to the server) while keeping the logic server-side.
Hereâs how to pass the sendEmail
action to a client component:
// app/page.tsx
import { sendEmail } from './actions/sendEmail';
import ClientComponent from './components/ClientComponent';
export default function Page() {
return (
<div>
<h1>Send an Email</h1>
<ClientComponent sendEmail={sendEmail} />
</div>
);
}
Explanation:
- Here, the server component (which is the
Page
component in this case) imports thesendEmail
action and passes it down toClientComponent
. ClientComponent
doesnât need to know about the server logic; it just receives thesendEmail
function as a prop.
In ClientComponent
, you can call this action like so:
// app/components/ClientComponent.tsx
'use client';
export default function ClientComponent({ sendEmail }: { sendEmail: (email: string, message: string) => Promise<void> }) {
const handleSend = () => {
sendEmail('user@example.com', 'Hello!'); // Calling the server action
};
return (
<button onClick={handleSend}>
Send Email
</button>
);
}
Explanation:
- The
sendEmail
function is passed as a prop from the parent (server) component. This function is executed on the server, but triggered by the client when the button is clicked. - This decouples the server logic from the UI, making your app more secure and performant.
Forms
How to use Server Actions in Forms? Server actions are extremely useful for handling form submissions because they allow you to process data on the server directly, without needing to create separate API routes.
Hereâs an example of how to handle a login form:
// app/actions/login.ts
'use server';
export async function login(username: string, password: string) {
// Simulate authentication process
if (username === 'admin' && password === 'password123') {
return { success: true };
}
return { success: false, message: 'Invalid credentials' };
}
Explanation:
- This is the server-side action for handling login. It takes a username and password and returns a success or failure message depending on the credentials.
- Notice how the server handles sensitive data like passwords, ensuring this logic never runs on the client where it could be exploited.
Now, letâs see how this is wired up in a form on the client:
// app/components/LoginForm.tsx
'use client';
export default function LoginForm({ login }: { login: (username: string, password: string) => Promise<{ success: boolean; message?: string }> }) {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const result = await login(form.username.value, form.password.value);
if (result.success) {
alert('Login successful!');
} else {
alert(result.message || 'Login failed');
}
};
return (
<form onSubmit={handleSubmit}>
<input name="username" type="text" placeholder="Username" />
<input name="password" type="password" placeholder="Password" />
<button type="submit">Login</button>
</form>
);
}
Explanation:
- The client component accepts the
login
action as a prop. - When the form is submitted, the action is called with the username and password values from the form fields.
- The action is executed on the server, and the result (success or failure) is passed back to the client, where itâs handled in the form of an alert.
Pending States
How to handle Pending States and Optimistic Updates with Server Actions Often, you want to show some indication to the user while a server action is being processed. This is known as a pending state. You might also want to update the UI immediately, assuming the action will succeed, and revert the change if it fails. This is called an optimistic update.
Example:
'use client';
import { useState } from 'react';
export default function FormWithPending({ sendMessage }: { sendMessage: (message: string) => Promise<void> }) {
const [isPending, setPending] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setPending(true); // Start pending state
const form = e.target as HTMLFormElement;
await sendMessage(form.message.value); // Server action
setPending(false); // End pending state
};
return (
<form onSubmit={handleSubmit}>
<input name="message" type="text" placeholder="Your message" />
<button type="submit" disabled={isPending}>
{isPending ? 'Sending...' : 'Send'}
</button>
</form>
);
}
Explanation:
- The
isPending
state tracks whether the server action is currently being processed. - When the form is submitted, the
isPending
state is set totrue
, and itâs reset tofalse
when the action completes. - The button is disabled while the action is pending, and the button label changes to reflect the loading state.
Great! Let's continue with the next sections, expanding on Error Handling, Data Revalidation, Cookies, and more. Weâll maintain the same approach of detailed explanations along with relevant TypeScript code.
Error Handler
How to handle errors in Server Actions? Error handling is crucial when working with server actions, especially when dealing with user inputs or external services that might fail. You can handle errors inside the server action and pass the error messages back to the client for display.
Example:
// app/actions/register.ts
'use server';
export async function register(username: string, password: string) {
if (!username || !password) {
throw new Error('All fields are required');
}
// Simulate database or external API call
if (username === 'admin') {
throw new Error('Username already taken');
}
return { success: true };
}
Explanation:
- The
register
function throws errors if certain conditions arenât met. For example, if the username or password is missing, it throws an error. - If a username is already taken (in this example, if itâs
admin
), an error is also thrown.
Now, on the client side, you can catch these errors and display them to the user:
// app/components/RegisterForm.tsx
'use client';
import { useState } from 'react';
export default function RegisterForm({ register }: { register: (username: string, password: string) => Promise<{ success: boolean }> }) {
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
try {
await register(form.username.value, form.password.value);
alert('Registration successful!');
} catch (err: any) {
setError(err.message); // Display error message
}
};
return (
<form onSubmit={handleSubmit}>
<input name="username" type="text" placeholder="Username" />
<input name="password" type="password" placeholder="Password" />
<button type="submit">Register</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
</form>
);
}
Explanation:
- The
handleSubmit
function wraps theregister
call in atry-catch
block to handle errors. - If the server action throws an error, the error message is caught and displayed to the user in red below the form.
- This pattern allows you to gracefully handle validation and other issues on the server, making the UI more responsive to failures.
Revalidate
How to revalidate/updateCache Data after execution of Server Actions?
After performing a server action like updating or deleting data, you often need to revalidate the data on the page. This ensures that the client-side UI is up to date with the server-side state.
Next.js provides built-in support for revalidation using functions like revalidatePath
or revalidateTag
.
Example:
// app/actions/deletePost.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function deletePost(postId: string) {
// Simulate post deletion
console.log(`Post ${postId} deleted`);
// Revalidate the page to fetch the updated list of posts
revalidatePath('/posts');
}
Explanation:
- After the post is deleted on the server,
revalidatePath
is called to refresh the/posts
page. - This will refetch the data for the
/posts
route, ensuring that the client-side UI reflects the latest state (i.e., the deleted post no longer shows).
In the client component:
// app/components/PostList.tsx
'use client';
export default function PostList({ deletePost }: { deletePost: (postId: string) => Promise<void> }) {
const handleDelete = async (postId: string) => {
await deletePost(postId);
alert('Post deleted and page revalidated!');
};
return (
<div>
<button onClick={() => handleDelete('123')}>Delete Post</button>
</div>
);
}
Explanation:
- The
deletePost
function is triggered when the button is clicked. - Once the post is deleted on the server, the
revalidatePath
ensures that the client-side UI will refetch the latest data from the server, keeping the UI in sync with the backend.
Cookies
How to use Cookies in Server Actions? Cookies are often used to store session information, authentication tokens, or user preferences. In Next.js 14, you can easily manage cookies within server actions, making it simple to handle user sessions or tokens in a secure, server-side environment.
Example:
// app/actions/login.ts
'use server';
import { cookies } from 'next/headers';
export async function login(username: string, password: string) {
if (username === 'admin' && password === 'password123') {
// Set a cookie for authentication
cookies().set('authToken', 'secureToken123', {
httpOnly: true, // Ensures the cookie is not accessible via JavaScript
secure: true, // Only send cookie over HTTPS
maxAge: 3600, // Expiry time of 1 hour
});
return { success: true };
}
throw new Error('Invalid credentials');
}
Explanation:
- In this example, the
login
function checks the credentials. If they are correct, it sets a cookie namedauthToken
with a secure value. - The
httpOnly
flag ensures that the cookie cannot be accessed via JavaScript, improving security. - The
secure
flag ensures the cookie is only sent over HTTPS. maxAge
specifies how long the cookie should live (in this case, 1 hour).
On the client side, you donât need to worry about cookies directly, as they are automatically handled by the browser:
// app/components/LoginForm.tsx
'use client';
export default function LoginForm({ login }: { login: (username: string, password: string) => Promise<void> }) {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
try {
await login(form.username.value, form.password.value);
alert('Login successful!');
} catch (err: any) {
alert(err.message);
}
};
return (
<form onSubmit={handleSubmit}>
<input name="username" type="text" placeholder="Username" />
<input name="password" type="password" placeholder="Password" />
<button type="submit">Login</button>
</form>
);
}
Explanation:
- The
login
function sets the cookie on the server. This cookie will be automatically sent with subsequent requests, such as when fetching protected resources or validating sessions.
Redirect
How to redirect in Server Actions?
After performing certain actions (like a login or form submission), you might want to redirect the user to another page. This can be achieved in server actions by returning a redirect
response.
Example:
// app/actions/loginWithRedirect.ts
'use server';
import { redirect } from 'next/navigation';
export async function loginWithRedirect(username: string, password: string) {
if (username === 'admin' && password === 'password123') {
// Redirect the user to the dashboard
redirect('/dashboard');
}
throw new Error('Invalid credentials');
}
Explanation:
- In this example, the
loginWithRedirect
function checks the credentials. If they are correct, it redirects the user to the/dashboard
page usingredirect
. - This is done entirely on the server, keeping the client-side logic simple and secure.
In the client component:
// app/components/LoginWithRedirect.tsx
'use client';
export default function LoginWithRedirectForm({ loginWithRedirect }: { loginWithRedirect: (username: string, password: string) => Promise<void> }) {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
try {
await loginWithRedirect(form.username.value, form.password.value);
} catch (err: any) {
alert(err.message);
}
};
return (
<form onSubmit={handleSubmit}>
<input name="username" type="text" placeholder="Username" />
<input name="password" type="password" placeholder="Password" />
<button type="submit">Login</button>
</form>
);
}
Explanation:
- The server-side
loginWithRedirect
handles both the authentication and the redirect logic, allowing the client component to focus solely on the form input and submission.
Advanced
Security Considerations
Security is paramount when dealing with server actions, especially when handling user data, authentication, or sensitive operations.
Example:
- HTTP-only Cookies: Always use
httpOnly
cookies for authentication to prevent them from being accessed by JavaScript. - Input Validation: Validate all user inputs on the server, as client-side validation can be bypassed.
- Use HTTPS: Always use HTTPS to encrypt data in transit, especially when sending sensitive information like passwords or tokens.
- CSRF Protection: Ensure server actions are protected from Cross-Site Request Forgery (CS
RF) attacks by implementing CSRF tokens or verifying origins.
Authentication and Authorization
Server actions can be used to handle authentication and protect routes based on user permissions. By storing authentication tokens in secure cookies and validating them in server actions, you can restrict access to certain resources or pages.
Example:
// app/actions/protectedAction.ts
'use server';
import { cookies } from 'next/headers';
export async function protectedAction() {
const token = cookies().get('authToken');
if (!token || token !== 'secureToken123') {
throw new Error('Unauthorized');
}
// Proceed with protected action
console.log('User is authorized');
}
Explanation:
- The
protectedAction
checks for anauthToken
cookie. If the token is missing or invalid, it throws an error. Otherwise, it proceeds with the protected logic. - This ensures that only authenticated users can access this action.
Closures and Encryption in Server Actions
In some cases, you might want to pass sensitive data securely between functions or protect data from being exposed. Closures and encryption can help achieve this by keeping certain values within the scope of the server action and ensuring that sensitive data is encrypted before itâs transmitted or stored.
Example:
// app/actions/encryptMessage.ts
'use server';
import { createCipheriv, randomBytes, createDecipheriv } from 'crypto';
const algorithm = 'aes-256-ctr'; // Symmetric encryption algorithm
const secretKey = randomBytes(32); // Random encryption key
const iv = randomBytes(16); // Initialization vector
export function encryptMessage(message: string) {
const cipher = createCipheriv(algorithm, secretKey, iv);
const encrypted = Buffer.concat([cipher.update(message), cipher.final()]);
return {
iv: iv.toString('hex'),
content: encrypted.toString('hex'),
};
}
export function decryptMessage(encryptedData: { iv: string; content: string }) {
const decipher = createDecipheriv(algorithm, secretKey, Buffer.from(encryptedData.iv, 'hex'));
const decrypted = Buffer.concat([
decipher.update(Buffer.from(encryptedData.content, 'hex')),
decipher.final(),
]);
return decrypted.toString();
}
Explanation:
- This example uses Node.jsâs built-in
crypto
module to encrypt and decrypt messages using the AES-256-CTR algorithm. encryptMessage
creates an encrypted version of the input message using a secret key and an initialization vector (IV).decryptMessage
reverses the process to convert the encrypted message back into plaintext.- Since the encryption key is stored within the closure (the scope of the action), itâs not exposed to the client or other parts of the application, keeping it secure.
On the client side:
// app/components/EncryptForm.tsx
'use client';
import { useState } from 'react';
export default function EncryptForm({ encryptMessage }: { encryptMessage: (message: string) => { iv: string, content: string } }) {
const [encryptedData, setEncryptedData] = useState<{ iv: string, content: string } | null>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const result = encryptMessage(form.message.value); // Encrypt the message
setEncryptedData(result);
};
return (
<form onSubmit={handleSubmit}>
<input name="message" type="text" placeholder="Message to encrypt" />
<button type="submit">Encrypt</button>
{encryptedData && (
<div>
<p>Encrypted Message: {encryptedData.content}</p>
<p>IV: {encryptedData.iv}</p>
</div>
)}
</form>
);
}
Explanation:
- The form allows the user to enter a message, which is encrypted when submitted.
- The encrypted message and IV are displayed, but they are completely unreadable to the client.
- This can be useful when dealing with sensitive data, such as passwords or private messages.
Overwriting Encryption Keys (Advanced)
In certain high-security applications, you may want to overwrite encryption keys regularly to further secure sensitive data. This practice is often used to limit the time during which any given key can be used to decrypt sensitive data, reducing the risk of key compromise.
Example:
// app/actions/overwriteKey.ts
'use server';
let currentSecretKey = randomBytes(32); // Generate an initial key
export function overwriteEncryptionKey() {
currentSecretKey = randomBytes(32); // Overwrite the encryption key periodically
}
export function encryptWithNewKey(message: string) {
const iv = randomBytes(16);
const cipher = createCipheriv(algorithm, currentSecretKey, iv);
const encrypted = Buffer.concat([cipher.update(message), cipher.final()]);
return {
iv: iv.toString('hex'),
content: encrypted.toString('hex'),
};
}
Explanation:
overwriteEncryptionKey
generates a new encryption key, effectively "rotating" the key used for encryption.- The
encryptWithNewKey
function uses the current secret key to encrypt the message, which changes every time the key is rotated. - Rotating encryption keys can reduce the risk of prolonged data exposure in case an encryption key is compromised.
This approach is typically combined with a key management service (KMS) to automate and securely manage key rotations.
Allowed Origins (Advanced)
When working with server actions, itâs important to ensure that your server is only accepting requests from trusted sources. One way to do this is by specifying allowed origins, ensuring that server actions cannot be triggered from unauthorized websites or external sources.
This can help prevent Cross-Site Request Forgery (CSRF) attacks, where a malicious site attempts to perform actions on behalf of a logged-in user.
Example:
// app/actions/checkOrigin.ts
'use server';
import { headers } from 'next/headers';
const allowedOrigins = ['https://trustedwebsite.com', 'https://anothertrusted.com'];
export async function checkOriginAndProcess() {
const origin = headers().get('origin'); // Get the origin of the request
if (!origin || !allowedOrigins.includes(origin)) {
throw new Error('Origin not allowed');
}
// Proceed with processing the action if the origin is allowed
console.log('Request from allowed origin:', origin);
}
Explanation:
- The
headers()
function is used to get the Origin header from the request. This contains the domain from which the request originated. - The
allowedOrigins
array defines a list of trusted websites that are permitted to trigger this server action. - If the request's origin is not in the allowed list, an error is thrown, preventing the action from being executed.
By implementing allowed origins, you can ensure that only requests from specific trusted domains can interact with your server actions, reducing the risk of unauthorized access.
Programmatic Form Submission with Allowed Origins
Combining the concept of allowed origins with programmatic form submissions can be a powerful technique to ensure that user actions (such as submitting a form) only happen when initiated from your trusted domain.
// app/components/OriginProtectedForm.tsx
'use client';
export default function OriginProtectedForm({ checkOriginAndProcess }: { checkOriginAndProcess: () => Promise<void> }) {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await checkOriginAndProcess(); // Ensure the request is from an allowed origin
alert('Request processed successfully!');
} catch (err: any) {
alert(err.message); // Show error if origin is not allowed
}
};
return (
<form onSubmit={handleSubmit}>
<button type="submit">Submit</button>
</form>
);
}
Explanation:
- The form submits programmatically, but only if the origin check succeeds.
- This ensures that even if the form submission is intercepted or modified, the server-side origin check will prevent unauthorized domains from triggering the action.
- The client component remains simple and secure, as the sensitive logic happens on the server side.
Conclusion
This tutorial has covered the essential and advanced features of Server Actions in Next.js 14, using the App Router. Youâve learned how to:
- Differentiate between server and client components.
- Pass server actions as props to client components.
- Handle forms, including validation, error handling, and pending states.
- Secure your server actions using techniques like cookies, encryption, and allowed origins.
These examples showcase how Next.js 14 simplifies the process of working with server-side logic while maintaining security and performance. For more complex applications, techniques like key rotation, origin validation, and encryption provide additional layers of protection, making server actions a powerful tool in building modern web applications.