were as a new way to process data on the server in response to a client interaction. A server action is like an asynchronous API route, but more tightly coupled to the UI so the data mutation code is closer to where it is triggered.
Server actions are an elegant way to handle user actions, such as button clicks or form submissions, because much of the boilerplate is handled for you - they are invoked through automatically generated POST requests. Next.js hides most of the request details so your code can focus just on processing the data.
unique, non-deterministic ID references for server actions to make it more difficult to locate and reference the APIs. However, this is just security by obscurity and the endpoint can still be found within the client code or when triggering a request to raw server action.
Unused server actions will not have their IDs exposed to the client-side JavaScript bundle. However, if a user were to get the handle to the ID of an in-use action, they could still be invoked with any arguments.
The action ID can be derived from the Next-Action request header.
.
This also has implications for self-hosting Next.js because the generated encryption keys will be different on each server. You will need to handle syncing the encryption keys to ensure requests that round-robin to different servers work correctly.
POST APIs have built-in protection from in your Next.js config.
These are the architectural security implications. Once you've considered these, you then need to implement usual API security protections, which we'll explore below.
Securing a from submission server action
Once you have considered the security implications described above, you can then proceed to add additional protection by setting up input validation and installing Arcjet.
, and block traffic from automated clients and bots. We will also validate data using the , which creates request objects that allow access to the headers for analyzing the request.
'use server'
import arcjet, { shield, detectBot, fixedWindow, request } from '@arcjet/next';
import { registrationSchema, type RegistrationData } from './lib/schema'
Define what the response will be - either an error message or a success message.
type RegisterResponse = {
error?: string;
success?: string;
};
Now, configure the rules for the Arcjet protection measures. prevents all automated clients from submitting the form, and
Registration form showing the rate limit error.
Next, create an object named data that will collect the form fields from the submission using the .get() method on the formData object that is sent to this function from the form.
const data = {
email: formData.get('email') || '',
password: formData.get('password') || '',
confirmPassword: formData.get('confirmPassword') || ''
};
Parse the data object against the validation schema using the .safeParse() Zod method and store this evaluation in the result variable. If the validation check fails, the fields responsible will display their error messages. If the check passes, the registration to a user database is simulated with a message printed to the terminal and a success message is displayed.
Finally, to handle any unexpected errors, we include the catch block at the end.
try {
const result = registrationSchema.safeParse(data);
if (!result.success) {
return {
error: result.error.errors[0].message
};
}
const validatedData: RegistrationData = result.data;
// This is where you would normally save the user to a database.
console.log("Database would register:", { email: validatedData.email });
return {
success: "Registration successful!"
};
} catch (error) {
console.error('Registration error:', error);
return {
error: "An error occurred during registration."
};
}
}
/src/app/components/form.tsx
Now, let's create the form component of the webpage. Begin with the 'use client' annotation to specify that this component runs in the browser.
Next, import the necessary hooks, the server action function, and validation schema.
'use client'
// Next.js hook that manages form state and server action responses.
import { useActionState } from 'react'
// React's built-in hook for managing local component state.
import { useState } from 'react'
// Import our server action function that handles form submission.
import { registerUser } from '../actions'
// Import our Zod schema that defines validation rules.
import { registrationSchema } from '../lib/schema'
Define the initial state of the form using undefined values since there are no error or success messages before the first form submission.
const initialState = {
error: '',
success: undefined
} as const
At the beginning of the form creation function:
The useActionState hook takes two arguments: the server action function registerUser and the initialState that we just defined. In the tuple, state stores the current error or success messages and formAction is the client-side function that triggers the registerUser server action.
The useState hook creates an object of key-value pairs consisting of the form field names and their respective error messages. In the tuple, validationErrors stores these messages and setValidationErrors is responsible for updating them.
export default function RegisterForm() {
const [state, formAction] = useActionState(registerUser, initialState)
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({})
The handleSubmit client-side function runs whenever the form is submitted. Upon a form submission, setValidationErrors({}) clears any previous messages. The form data is collected and stored in the data object variable and compared against the validation schema.
const handleSubmit = async (formData: FormData) => {
setValidationErrors({})
const data = {
email: formData.get('email')?.toString() || '',
password: formData.get('password')?.toString() || '',
confirmPassword: formData.get('confirmPassword')?.toString() || '',
}
const result = registrationSchema.safeParse(data)
If validation fails, it displays the errors locally without making a server call. If validation passes, formAction(formData) passes the form data to the server and calls the registerUser server action function.
if (!result.success) {
const errors: Record<string, string> = {}
result.error.errors.forEach((error) => {
const field = error.path[0].toString()
errors[field] = error.message
})
setValidationErrors(errors)
return // Stop here - don't submit invalid data.
}
await formAction(formData)
}
The form that will be rendered will call handleSubmit which subsequently calls the server action if validation passes. If the check does not pass, the appropriate error messages will be sourced from validationErrors and displayed to the user.
return (
<form action={handleSubmit}>
<div>
<label htmlFor="email">Email:</label>
<input
type="email"
id="email"
name="email"
required
/>
{validationErrors.email && (
<p>{validationErrors.email}</p>
)}
</div>
<div>
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
name="password"
required
/>
{validationErrors.password && (
<p>{validationErrors.password}</p>
)}
</div>
<div>
<label htmlFor="confirmPassword">Confirm Password:</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
required
/>
{validationErrors.confirmPassword && (
<p>{validationErrors.confirmPassword}</p>
)}
</div>
{state?.error && (
<p>{state.error}</p>
)}
{state?.success && (
<p>{state.success}</p>
)}
<button type="submit">Register</button>
</form>
)
}
/src/app/page.tsx
Finally, import the form on the app's landing page.
import RegisterForm from './components/form'
export default function Home() {
return (
<main>
<h1>Register Account</h1>
<RegisterForm />
</main>
)
}
Test the Protections
To test your web application run npm run dev and visit:
Console output showing the form submissions.
Using an HTTP proxy tool, we can test the validation performed server-side:
for more tips. Next.js also has server actions security documentation worth reviewing.
SOCIAL SHARE CARD GENERATOR