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 or document).
  • 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 like useEffect.

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 the sendEmail action and passes it down to ClientComponent.
  • ClientComponent doesn’t need to know about the server logic; it just receives the sendEmail 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 to true, and it’s reset to false 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 the register call in a try-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 named authToken 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 using redirect.
  • 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:

  1. HTTP-only Cookies: Always use httpOnly cookies for authentication to prevent them from being accessed by JavaScript.
  2. Input Validation: Validate all user inputs on the server, as client-side validation can be bypassed.
  3. Use HTTPS: Always use HTTPS to encrypt data in transit, especially when sending sensitive information like passwords or tokens.
  4. 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 an authToken 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.

Next.js
Clap here if you liked the blog