Tutorial

So baust du einen Kontaktformular-Block

Erstelle ein Kontaktformular im Form Builder von Cmssy und rendere es dann in deiner eigenen headless Next.js-Site mit @cmssy/react. Validierung, Einsendungen und E-Mail übernimmt Cmssy.

C
Cmssy Team
12 min read

So baust du einen Kontaktformular-Block

Baue ein headless Kontaktformular: Erstelle es im Form Builder von Cmssy und rendere es dann mit dem SDK in deiner eigenen Next.js-Site.

Was wir bauen

Ein Kontaktformular-Block: Du definierst das Formular einmal im Cmssy Form Builder, und dein Block rendert es in deiner eigenen Next.js-App. Cmssy übernimmt die Validierung, speichert die Einsendung und mailt sie an die Empfänger; deiner App gehören Markup und Styling.

Diese Trennung ist entscheidend. Die Definition des Formulars - Felder, Labels, Validierung, Erfolgsmeldung, alles lokalisiert - ist Inhalt, lebt also im CMS, und Redakteure ändern sie ohne Deploy. Das Rendering ist Code und lebt in deinem Repo.

Voraussetzungen

  • Eine Next.js-App (App Router), über @cmssy/react und @cmssy/next mit deinem Workspace verdrahtet - siehe den Installations-Guide
  • Eingerichtetes GraphQL-Codegen, damit SubmitFormDocument für dich generiert wird

Schritt 1: Das Formular in Cmssy anlegen

Geh im Cmssy-Admin zu Formulare und leg eines an:

  1. Füg die Felder hinzu, die du brauchst - name (text), email (email), message (textarea) - jeweils mit lokalisiertem Label und Validierungsregeln
  2. Setz den Action-Typ auf contact und trag die Empfängeradressen ein
  3. Formuliere Button-Label und Erfolgsmeldung, pro Sprache
  4. Setz den Status auf published, damit das Formular Einsendungen annimmt

Schritt 2: Den Block definieren

Leg blocks/contact/block.ts an. Der fields.form-Builder gibt dem Editor eine Formularauswahl; auf dem advanced-Tab liegt sie den alltäglichen Textbearbeitungen nicht im Weg:

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,
});

Schritt 3: Die Komponente bauen

Der Block holt das Formular nicht selbst. Das SDK löst jedes auf der Seite referenzierte Formular auf und reicht die Definitionen über context.forms an deine Komponente, nach Id verschlüsselt. Da das Feld auf dem advanced-Tab sitzt, kommt sein Wert in der advanced-Prop an:

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>
  );
}

Beachte den Guard: Kein ausgewähltes Formular heißt kein gerendertes Formular. Ein Block zeigt, was das CMS ihm gibt, und sonst nichts - nie eine hartkodierte Fallback-Überschrift, die Englisch auf eine deutsche Seite trägt.

Schritt 4: Über eine Server Action absenden

Das Absenden ist eine GraphQL-Mutation und gehört auf den Server, damit deine Delivery-Credentials nie den Browser erreichen. Leg den Aufruf in 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 };
}

Wickle das dann in eine Server Action in blocks/contact/actions.ts. Das Feld website ist ein Honeypot: Es ist für Menschen unsichtbar, also ist alles, was es ausfüllt, ein Bot und bekommt einen vorgetäuschten Erfolg:

"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 };
  }
}

Schritt 5: Die Felder rendern

Die Client-Hälfte rendert genau die Felder, die die Formulardefinition deklariert, und ruft die Action mit useActionState auf. An der Feldliste ist nichts hartkodiert - füg im Form Builder ein Feld hinzu und es erscheint hier ohne Deploy:

"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>
  );
}

Das Label ist im CMS lokalisiert, jede Sprache bekommt also ihres - ganz ohne Verzweigung in deinem Code.

Schritt 6: Den Block registrieren

Füg ihn dem Array in cmssy/blocks.ts hinzu - demselben, das du an createCmssyPage übergibst:

import { contactBlock } from "@/blocks/contact/block";
// ...deine anderen Blöcke

export const blocks = [contactBlock];

Schritt 7: Deployen und benutzen

Es gibt keinen separaten Block-Deploy-Schritt - der Block geht live, wenn deine Next.js-App live geht:

git push   # dann Deploy über Vercel oder dein CI

Öffne danach eine Seite im Cmssy-Editor, zieh den Contact-Block hinein, wähl auf dem Advanced-Tab dein Formular und veröffentliche. Einsendungen landen im Admin unter Formulare und gehen an die konfigurierten Empfänger.

Zentrale Muster

  • Die Formulardefinition ist Inhalt - Felder, Labels und Validierung leben im CMS, Redakteure ändern sie ohne Deploy
  • Absenden läuft serverseitig - eine Server Action hält deine Delivery-Credentials aus dem Browser heraus
  • Honeypot statt Captcha - ein verstecktes Feld kostet nichts und stoppt das Gros der Bots
  • Im Editor keine echten Submits - prüf context.isPreview, bevor du etwas Destruktives verdrahtest
  • Lieber nichts rendern als einen Default - kein gewähltes Formular heißt kein Formular, kein Platzhalter

Nächste Schritte