Next.js
App RouterThree patterns that work: a plain form action, a server action that keeps your endpoint private, or a client fetch with local state.
Choose a pattern
- Plain form action: simplest, works without JavaScript, and the visitor sees a Formivo thank-you page or your
_redirectURL. - Server action: the endpoint stays in server code, and you decide the redirect.
- Client fetch: inline success and error states with no navigation.
Plain form action
app/contact/contact-form.tsx
"use client";
const ENDPOINT = "https://formivo.faizyab.me/api/f/YOUR_FORM_ID";
export function ContactForm() {
return (
<form
action={ENDPOINT}
method="POST"
// Progressive enhancement: works before hydration finishes.
>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit">Send</button>
</form>
);
}Works during hydration
An HTML
action is handled by the browser, so the form still submits if JavaScript fails to load.Server action
A server action keeps the endpoint out of the client bundle and lets you map the result onto your own routes.
app/contact/actions.ts
"use server";
import { redirect } from "next/navigation";
const ENDPOINT = "https://formivo.faizyab.me/api/f/YOUR_FORM_ID";
export async function submitContact(values: Record<string, string>) {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
// A server action must not cache a mutation.
cache: "no-store",
});
if (!response.ok) {
const body = await response.json().catch(() => null);
return { error: body?.error?.message ?? "The form could not be sent." };
}
redirect("/thanks");
}Do not cache the mutation
Use cache: "no-store" (or a POST request, which is uncached by default) so a repeated render cannot replay an old response.
Client fetch
app/contact/contact-form.tsx
"use client";
import { useState } from "react";
const ENDPOINT = "https://formivo.faizyab.me/api/f/YOUR_FORM_ID";
export function ContactForm() {
const [state, setState] = useState<"idle" | "sending" | "sent" | "error">("idle");
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
setState("sending");
const response = await fetch(ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify(Object.fromEntries(new FormData(form).entries())),
});
if (response.ok) {
form.reset();
setState("sent");
} else {
setState("error");
}
}
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit" disabled={state === "sending"}>
{state === "sending" ? "Sending…" : "Send"}
</button>
{state === "sent" ? <p role="status">Thanks.</p> : null}
{state === "error" ? <p role="alert">Something went wrong. Try again.</p> : null}
</form>
);
}Keep the form id in config
The form id is not a secret, but hard-coding it across components causes drift. Put it in an environment variable and read it once:
.env.local
NEXT_PUBLIC_FORMIVO_FORM_ID=YOUR_FORM_IDEndpoint helper
export const contactEndpoint =
`https://formivo.faizyab.me/api/f/${process.env.NEXT_PUBLIC_FORMIVO_FORM_ID}`;