Recipe: forms

Developer preview

This feature is in developer preview. Use with caution as it may change unexpectedly. For more information, contact us.

Goal: authors put a newsletter form on a page in Canvas, and set its labels. The visitor types an email address. A Next.js Server Action validates the address and sends it to your email service. After the signup, Uniform Context gets a quirk, so personalizations can change for the visitor.

You will make these files:

FileTypeJob
components/newsletter/types.tsSharedThe state of the form.
app/actions/subscribe.tsServer ActionValidates the data and sends it to your email service.
components/newsletter/NewsletterFormClient.tsxClient componentThe form, the errors and the success message.
components/newsletter/NewsletterForm.tsxServer componentThe Uniform component. It reads the parameters.
  • An app with the Uniform SDK and a resolveComponent function. Refer to Map Uniform types to React components.
  • A component type newsletterForm in Uniform, with these text parameters: title, emailLabel, buttonLabel and successMessage. Allow the type in the slots where authors can put the form.
  • An email service with an HTTP API. The example sends a POST request to the URL in the environment variable NEWSLETTER_API_URL. Change this part for your service.

The Server Action and the client component share this type:

components/newsletter/types.ts

export type SubscribeState = { status: "idle" | "success" | "error"; message: string; email?: string; }; export const initialSubscribeState: SubscribeState = { status: "idle", message: "" };

app/actions/subscribe.ts

"use server"; import type { SubscribeState } from "@/components/newsletter/types"; const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export async function subscribe(_previous: SubscribeState, formData: FormData): Promise<SubscribeState> { const email = String(formData.get("email") ?? "").trim(); const consent = formData.get("consent") === "on"; // Validate on the server. Do not trust the browser checks. if (!EMAIL_PATTERN.test(email)) { return { status: "error", message: "Enter a valid email address.", email }; } if (!consent) { return { status: "error", message: "Accept the terms to subscribe.", email }; } try { // Send the address to your email service. const response = await fetch(process.env.NEWSLETTER_API_URL!, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email }), cache: "no-store", }); if (!response.ok) throw new Error(`Status ${response.status}`); } catch (error) { console.error("Newsletter signup failed", error); return { status: "error", message: "We could not subscribe you. Try again later.", email }; } return { status: "success", message: "" }; }
  • "use server" at the top of the file makes each exported async function a Server Action.
  • With useActionState, the action gets the previous state as the first argument, and the form data as the second argument.
  • The action returns the new state. It returns the email address on an error, so the form keeps the value.

warning

A Server Action is a public endpoint. Each person who can send a POST request to your page can call it. Validate all data on the server. Add rate limits or a bot check before you use the form in production.

components/newsletter/NewsletterFormClient.tsx

"use client"; import { useActionState, useEffect } from "react"; import { useUniformContext } from "@uniformdev/next-app-router/component"; import { subscribe } from "@/app/actions/subscribe"; import { initialSubscribeState } from "./types"; type Props = { emailLabel: string; buttonLabel: string; successMessage: string; }; export function NewsletterFormClient({ emailLabel, buttonLabel, successMessage }: Props) { const [state, formAction, pending] = useActionState(subscribe, initialSubscribeState); const { context } = useUniformContext(); // Tell Uniform Context that the visitor subscribed. useEffect(() => { if (state.status === "success") { void context?.update({ quirks: { newsletter: "subscribed" } }); } }, [state.status, context]); if (state.status === "success") { return <p role="status">{successMessage}</p>; } return ( <form action={formAction}> <label htmlFor="newsletter-email">{emailLabel}</label> <input id="newsletter-email" name="email" type="email" required autoComplete="email" defaultValue={state.email} /> <label> <input name="consent" type="checkbox" required /> I accept the terms. </label> <button type="submit" disabled={pending}> {pending ? "Sending…" : buttonLabel} </button> <p aria-live="polite">{state.status === "error" ? state.message : null}</p> </form> ); }
  • useActionState returns the state, the action for the <form>, and pending. pending is true while the action runs.
  • type="email" and required give a fast check in the browser. The server check in Step 2 is the real check.
  • useUniformContext() returns { context }. context is undefined until the browser Context is ready. Thus the code uses context?..
  • context.update({ quirks }) gives the quirk newsletter the value subscribed.

The Uniform component is a server component. It reads the parameters, and gives plain strings to the client form:

components/newsletter/NewsletterForm.tsx

import { type ComponentParameter, type ComponentProps, UniformText, } from "@uniformdev/next-app-router/component"; import { NewsletterFormClient } from "./NewsletterFormClient"; type NewsletterFormParameters = { title?: ComponentParameter<string>; emailLabel?: ComponentParameter<string>; buttonLabel?: ComponentParameter<string>; successMessage?: ComponentParameter<string>; }; export const NewsletterForm = ({ parameters, component }: ComponentProps<NewsletterFormParameters>) => ( <section> {parameters.title ? ( <UniformText component={component} parameter={parameters.title} as="h2" placeholder="Enter a title" /> ) : null} <NewsletterFormClient emailLabel={parameters.emailLabel?.value || "Email"} buttonLabel={parameters.buttonLabel?.value || "Subscribe"} successMessage={parameters.successMessage?.value || "Thank you. You are now subscribed."} /> </section> );
  • UniformText lets authors edit the title in the page in Canvas.
  • The other labels have default values, because a parameter is undefined when the author did not fill it in.
  • Only serializable props can go from a server component to a client component. Thus the code gives strings, not the parameter objects.

Add the component to your resolveComponent map:

components/resolveComponent.tsx

import type { ResolveComponentFunction, ResolveComponentResult } from "@uniformdev/next-app-router"; import type { ComponentProps } from "@uniformdev/next-app-router/component"; import { NewsletterForm } from "./newsletter/NewsletterForm"; const componentMap: Record<string, ResolveComponentResult["component"]> = { // ...your other components newsletterForm: NewsletterForm, }; const NotFound = ({ type }: ComponentProps) => <div>Component not found: {type}</div>; export const resolveComponent: ResolveComponentFunction = ({ component }) => ({ component: componentMap[component.type] ?? NotFound, });

Then put a newsletterForm component on a composition in Canvas, and publish it.

  1. The page renders the NewsletterForm server component with the parameters from Uniform.
  2. The browser hydrates NewsletterFormClient.
  3. The visitor submits the form. React sends a POST request to the current URL with the form data.
  4. Next.js runs subscribe on the server, and sends the new state back to the browser.
  5. The client form shows the error, or the success message.
  6. On success, the browser Context gets the quirk newsletter: subscribed.

The page stays static. The Server Action does not render the page again, because it does not call revalidatePath or refresh.

You can use the quirk in Uniform:

  • Add the quirk newsletter to your Uniform project, and publish the Context manifest.
  • Use the quirk in personalization criteria or in visibility rules. For example, show a "Thank you" banner and not the signup form to visitors with newsletter = subscribed.

When the quirk changes, the page updates in the browser:

  • Lite mode: the browser chooses all variants, so the page changes at once.
  • Edge mode: personalizations that the edge rendered also choose again. A/B tests keep their variant.
  • Visibility rules: the browser applies them with the current quirks.

On the next page load, the quirk comes from a cookie. The browser Context writes the quirks to the cookie ufvdqk only when the visitor gave consent, and when quirkSerialization is on (the default). The edge middleware reads this cookie. Refer to Client context and Personalization.

  • Consent: without consent, the browser Context does not write the quirk to a cookie. The edge and the next full page load do not see it.
  • No data in Uniform: the form sends the data to your service. Uniform does not store form data.
  • Canvas preview: a submit in the Canvas preview calls the real Server Action. Use a test address, or skip the request to your service in draft mode.
  • Middleware: the middleware rewrites the POST request of the action, as it does for other requests. Make sure that your middleware matcher includes the page.