Tutorial

Cómo construir un bloque de formulario de contacto

Crea un formulario de contacto en el Form Builder de Cmssy y luego rendérizalo en tu propio sitio Next.js headless con @cmssy/react. Validación, envíos y correo gestionados por Cmssy.

E
Equipo Cmssy
12 min read

Cómo construir un bloque de formulario de contacto

Construye un formulario de contacto headless: créalo en el Form Builder de Cmssy y luego rendérizalo en tu propio sitio Next.js con el SDK.

Qué vamos a construir

Un bloque de formulario de contacto: defines el formulario una vez en el Form Builder de Cmssy y tu bloque lo renderiza en tu propia aplicación Next.js. Cmssy se encarga de la validación, guarda el envío y lo manda por correo a los destinatarios; tu aplicación es dueña del markup y los estilos.

Esa separación importa. La definición del formulario - campos, etiquetas, validación, mensaje de éxito, todo localizado - es contenido, así que vive en el CMS y un editor puede cambiarla sin desplegar. El renderizado es código, así que vive en tu repositorio.

Requisitos previos

  • Una aplicación Next.js (App Router) conectada a tu workspace con @cmssy/react y @cmssy/next - consulta la guía de instalación
  • El codegen de GraphQL configurado, para que SubmitFormDocument se genere solo

Paso 1: crear el formulario en Cmssy

En el panel de Cmssy, ve a Formularios y crea uno:

  1. Añade los campos que necesites - name (text), email (email), message (textarea) - cada uno con etiqueta localizada y reglas de validación
  2. Pon el tipo de acción en contact y añade las direcciones de los destinatarios
  3. Escribe la etiqueta del botón y el mensaje de éxito, por idioma
  4. Cambia el estado a published para que el formulario acepte envíos

Paso 2: definir el bloque

Crea blocks/contact/block.ts. El builder fields.form le da al editor un selector de formulario; ponerlo en la pestaña advanced lo mantiene fuera del camino de la edición diaria de textos:

import { defineBlock, fields } from "@cmssy/react";
import Contact from "./Contact";

export const contactProps = {
  heading: fields.text({ label: "Heading" }),
  description: fields.textarea({ label: "Description" }),
  formId: fields.form({ label: "Form", tab: "advanced" }),
  submitLoadingText: fields.text({
    label: "Submit Loading Text",
    defaultValue: "Sending...",
  }),
  successHeading: fields.text({
    label: "Success Heading",
    defaultValue: "Message Sent!",
  }),
};

export const contactBlock = defineBlock({
  type: "contact",
  category: "Forms",
  label: "Contact",
  description:
    "Contact details and/or contact form; near the end of a page or on a dedicated contact page.",
  component: Contact,
  props: contactProps,
});

Paso 3: construir el componente

El bloque no obtiene el formulario. El SDK resuelve todos los formularios referenciados en la página y entrega las definiciones a tu componente en context.forms, indexadas por id. Como el campo está en la pestaña advanced, su valor llega en la prop advanced:

import type { BlockProps } from "@cmssy/react";
import type { contactProps } from "./block";
import { ContactForm } from "./ContactForm";

export default function Contact({
  content,
  context,
  advanced = {},
}: BlockProps<typeof contactProps>) {
  const { heading, description, successHeading, submitLoadingText } = content;
  const { formId } = advanced as { formId?: string };
  const formDef = formId ? (context?.forms?.[formId] ?? null) : null;

  return (
    <section className="py-24">
      <div className="max-w-lg mx-auto px-6">
        {heading && <h2 className="text-3xl font-bold">{heading}</h2>}
        {description && (
          <p className="mt-3 text-muted-foreground">{description}</p>
        )}

        {formDef?.fields?.length && formId ? (
          <ContactForm
            formDef={formDef}
            formId={formId}
            successHeading={successHeading}
            submitLoadingText={submitLoadingText}
          />
        ) : null}
      </div>
    </section>
  );
}

Fíjate en la guarda: sin formulario seleccionado no se renderiza formulario. Un bloque muestra lo que le da el CMS y nada más - nunca un titular de reserva escrito a fuego que colaría inglés en una página en español.

Paso 4: enviar mediante una server action

El envío es una mutación GraphQL y su sitio es el servidor, para que tus credenciales de entrega no lleguen nunca al navegador. Pon la llamada en services/forms.ts:

import { print } from "graphql";
import { createCmssyClient } from "@cmssy/react";
import { cmssy } from "@/cmssy/config";
import {
  SubmitFormDocument,
  type SubmitFormMutation,
} from "@/graphql/generated/graphql";

const client = createCmssyClient(cmssy);

export async function submitForm(
  formId: string,
  data: Record<string, string>,
) {
  const res = await client.queryScoped<SubmitFormMutation>(
    print(SubmitFormDocument),
    { formId, input: { data } },
  );
  const result = res.public.form.submit;
  return { success: result.success, message: result.message };
}

Luego envúelvela en una server action en blocks/contact/actions.ts. El campo website es un señuelo: está oculto a las personas, así que cualquier cosa que lo rellene es un bot y recibe un éxito fingido:

"use server";

import { submitForm } from "@/services/forms";
import type { ContactState } from "./types";

export async function submitContact(
  formId: string,
  _prevState: ContactState,
  formData: FormData,
): Promise<ContactState> {
  if (formData.get("website")) {
    return { status: "success", message: null };
  }

  const data: Record<string, string> = {};
  for (const [key, value] of formData.entries()) {
    if (key === "website") continue;
    if (typeof value === "string" && value) data[key] = value;
  }

  try {
    const result = await submitForm(formId, data);
    return {
      status: result.success ? "success" : "error",
      message: result.message,
    };
  } catch {
    return { status: "error", message: null };
  }
}

Paso 5: renderizar los campos

La mitad cliente renderiza exactamente los campos que declara la definición del formulario y llama a la acción con useActionState. Nada de la lista de campos está escrito a fuego: añade un campo en el Form Builder y aparece aquí sin desplegar:

"use client";

import { useActionState } from "react";
import type { CmssyFormDefinition } from "@cmssy/react";
import { submitContact } from "./actions";
import type { ContactState } from "./types";

const INITIAL_STATE: ContactState = { status: "idle", message: null };

export function ContactForm({
  formDef,
  formId,
  successHeading,
  submitLoadingText,
}: {
  formDef: CmssyFormDefinition;
  formId: string;
  successHeading: string;
  submitLoadingText: string;
}) {
  const [state, formAction, isPending] = useActionState(
    submitContact.bind(null, formId),
    INITIAL_STATE,
  );

  if (state.status === "success") {
    return <p>{successHeading}</p>;
  }

  return (
    <form action={formAction} className="mt-10 space-y-5">
      <input type="text" name="website" tabIndex={-1} className="hidden" />

      {formDef.fields.map((field) => (
        <div key={field.id}>
          <label className="block text-sm font-medium mb-1.5">
            {field.label}
          </label>
          {field.fieldType === "textarea" ? (
            <textarea
              name={field.name}
              rows={5}
              required={field.validation?.required}
              className="w-full px-4 py-2.5 border rounded-lg"
            />
          ) : (
            <input
              type={field.fieldType}
              name={field.name}
              required={field.validation?.required}
              className="w-full px-4 py-2.5 border rounded-lg"
            />
          )}
        </div>
      ))}

      <button type="submit" disabled={isPending}>
        {isPending ? submitLoadingText : "Send"}
      </button>

      {state.status === "error" && (
        <p className="text-sm text-red-500">{state.message}</p>
      )}
    </form>
  );
}

La etiqueta está localizada en el CMS, así que cada idioma recibe la suya sin un solo condicional en tu código.

Paso 6: registrar el bloque

Añádelo al array de cmssy/blocks.ts, el mismo que pasas a createCmssyPage:

import { contactBlock } from "@/blocks/contact/block";
// ...tus otros bloques

export const blocks = [contactBlock];

Paso 7: desplegar y usarlo

No hay un paso de despliegue aparte para el bloque: viaja con tu aplicación Next.js:

git push   # luego despliega vía Vercel, o tu CI

Después abre una página en el editor de Cmssy, suelta el bloque Contact, elige tu formulario en la pestaña Advanced y publica. Los envíos aparecen en Formularios dentro del panel y salen hacia los destinatarios que configuraste.

Patrones clave

  • La definición del formulario es contenido - campos, etiquetas y validación viven en el CMS, y los editores los cambian sin desplegar
  • El envío es del lado del servidor - una server action mantiene tus credenciales de entrega fuera del navegador
  • Señuelo mejor que captcha - un campo oculto no cuesta nada y frena a la mayoría de los bots
  • Nada de envíos reales en el editor - comprueba context.isPreview antes de conectar algo destructivo
  • Mejor no renderizar que poner un valor por defecto - sin formulario elegido no hay formulario, ni placeholder

Siguientes pasos