I use Next.js 15. Like shown below I pass a server action "onLogin" to a client component called "LoginForm". the action is called when a user clicks on the login button in the form. In the server action I instantiate a authenticator class which is responsible for sending the provided credentials to the server and saves the received access token with cookies().set("...") and returning the authentication state. If the authentication is successful the client component sends the user to the next page. In my understanding I follow the documentation regarding manipulating cookies in a server action. However, I get the error "Cookies can only be modified in a Server Action or Route Handler". What am I missing? Is it because I delegate the authentication flow to the Authenticator class? That's the only difference I can see in comparison to the example. The strange thing is that this exact implementation worked previously and stopped working without me being aware of any changes I made.
export async function LoginPage() {
async function onLogin(state: LoginFormState, formData: FormData): Promise<LoginFormState> {
"use server";
const username = formData.get("username")?.toString();
const password = formData.get("password")?.toString();
const authenticator = new Authenticator()
return authenticator.authenticate(username, password)
};
return (
<LoginForm formAction={ onLogin }/>
);
}
and here the client component
'use client';
export function LoginForm({ formAction }: Props) {
const [isRejected, setIsRejected] = useState(false);
const [state, action] = useActionState<LoginFormState, FormData>(formAction, {});
const router = useRouter()
useEffect(() => {
if (state.errorState?.unauthorized) {
setIsRejected(true)
}
if (state.successState) router.push('/clients')
}, [state]);
return (
<div className="flex h-screen">
<div className="secondary-color-background m-auto p-8 sm:rounded-md flex flex-col justify-center items-center">
<form action={action} className="">
<label className="block mb-6">
<span className="h2 white-color">Username</span>
<input name="username" type="text" className={`${state.errorState?.username ? "border-orange" : ""} w-full border-2 rounded p-2 focus:outline-none focus:bg-white focus:border-primary `}
placeholder="type here"/>
</label>
<label className="block mb-6">
<span className="h2 white-color">Password</span>
<input name="password" type="password" className={`${state.errorState?.password ? "border-orange" : ""} w-full border-2 rounded p-2 focus:outline-none focus:bg-white focus:border-primary `}
placeholder="type here" />
</label>
<div className="flex flow-root w-full content-start items-end">
<ShakeButton text="Inloggen" isShaking= { isRejected } onStop= { () => setIsRejected(false) }/>
<Link className="body2 text-primary ml-4" href={"/komt nog"}>
Ik ben mijn wachtwoord vergeten
</Link>
</div>
</form>
</div>
</div>
);
}
It looks like you're following the Next.js 15 documentation correctly, but the issue is likely due to modifying cookies inside the Authenticator class instead of the server action.
Cause: Cookies Must Be Set in the Server Action Next.js requires cookies().set(...) to run inside the server action's execution scope. If it's handled in Authenticator, Next.js won’t recognize it as a valid modification.
Possible Solution 1: Move cookies().set(...) to the Server Action Update onLogin like this:
export async function LoginPage() {
async function onLogin(state: LoginFormState, formData: FormData): Promise<LoginFormState> {
"use server";
const username = formData.get("username")?.toString();
const password = formData.get("password")?.toString();
const authenticator = new Authenticator();
const result = await authenticator.authenticate(username, password);
if (result.success && result.token) {
import("next/headers").then(({ cookies }) => {
cookies().set("access_token", result.token, { httpOnly: true, secure: true });
});
}
return result;
};
return <LoginForm formAction={onLogin} />;
}
Why This Works:
Possible Solution 2: Move onLogin to a new file with 'use server' at the top, then import it in LoginPage:
// actions/auth.ts
"use server";
import { cookies } from "next/headers";
import { Authenticator } from "@/lib/authenticator";
export async function onLogin(state: LoginFormState, formData: FormData): Promise<LoginFormState> {
const username = formData.get("username")?.toString();
const password = formData.get("password")?.toString();
const authenticator = new Authenticator();
const result = await authenticator.authenticate(username, password);
if (result.success && result.token) {
cookies().set("access_token", result.token, { httpOnly: true, secure: true });
}
return result;
}
Then, use it in LoginPage.tsx: import { onLogin } from "@/actions/auth";
export function LoginPage() {
return <LoginForm formAction={onLogin} />;
}
I recommend the second approach as it maintains a proper execution scope while keeping the code clean and organized. However, it may require some refactoring on your end.
Feel free to try the approach that works best for you, and hope this helps!