Skip to content

Add a contact form

Goal: a contact form on your website. When a visitor submits it, the entry lands in Nebulas (for example as a new row in a datatable) and they see a thank-you message. You don’t write any form-handling code.

The form is a widget built in Nebulas Studio. The code and API call it a surface. Its fields, validation rules and submit handler all live in Nebulas. Your website only renders it and forwards the submission.

Part 1 · Build the form in Nebulas Studio

Section titled “Part 1 · Build the form in Nebulas Studio”
  1. Open Nebulas Studio, go to your app’s resources and click New widget.

  2. Design the form: add fields (text fields, checkboxes, choice pickers) and a submit button. For example:

    Field Type Rule
    name TextField required
    email TextField required, pattern (email)
    company TextField none
    message TextField (multi-line) required
  3. Pick what the submit button does (its handler):

    Handler What it does on submit
    datatable Adds the submitted data as a row in a Nebulas datatable. This is the usual choice for leads.
    script Runs one of your tool scripts with the submitted data (send an email, call a CRM, …).
    chat Turns the submission into a message for a conversation.
    refresh Updates the widget in place (for example to re-read a report).
    surface Shows a follow-up widget below the form.
  4. Give the widget a name you’ll recognize, for example contact_form. Your website uses this name to find it.

  5. Publish the app so the widget is available.

  • Directorysrc
    • Directorylib
      • nebulas.ts
    • Directoryapp
      • Directoryapi
        • Directorynebulas-surfaces
          • Directory[…path]
            • route.ts forwards render / actions / theme only
      • Directorycontact
        • page.tsx renders the form
  1. Set the Nebulas API URL and key in your config module (see Configure the server):

    src/lib/nebulas.ts
    import { configureNebulasServer } from "@gnomondigital/nebulas-kit-core";
    configureNebulasServer({
    nebulasApiUrl: process.env.NEBULAS_API_URL, // required for forms
    nebulasApiKey: process.env.NEBULAS_API_KEY,
    // + your service auth credentials, if you use one
    });
  2. Add the surface route. It’s a catch-all route that forwards exactly three calls to Nebulas and returns 404 for everything else:

    src/app/api/nebulas-surfaces/[...path]/route.ts
    import "@/lib/nebulas";
    import { createSurfaceHandler } from "@gnomondigital/nebulas-kit-core/nextjs";
    const handler = createSurfaceHandler({
    apiKey: process.env.NEBULAS_API_KEY,
    cache: { enabled: true }, // faster renders, see below
    });
    export const GET = handler;
    export const POST = handler;

    If you’ve set up a service strategy (service account, ROP, …) for the chat, use createSurfaceHandlerWithServiceAuth({ apiKey, cache }) instead. Form submissions then use the same identity as the chat.

  3. Render the form.

    src/app/contact/page.tsx
    "use client";
    import { NebulasSurface } from "@gnomondigital/nebulas-kit-react";
    export default function ContactPage() {
    return (
    <main className="container mx-auto max-w-2xl py-12 px-4">
    <h1 className="text-3xl font-semibold mb-6">Contact us</h1>
    <NebulasSurface
    surfaceName="contact_form"
    successDialog={{
    title: "Thank you!",
    message: "We've received your message and will get back to you shortly.",
    }}
    onResult={(result) => {
    if (result.status === "ok") {
    // e.g. send a conversion event to your analytics
    }
    }}
    />
    </main>
    );
    }
  4. Try it. Open /contact, fill in the form and submit. The entry shows up in your datatable in Nebulas (or runs the handler you picked).

  1. The browser checks the validation rules from the widget definition (required, pattern, min/max, …) and shows errors under the fields.
  2. The submission goes to /api/nebulas-surfaces/<id>/actions/<name> and your server forwards it to Nebulas with your credentials.
  3. Nebulas checks the rules again and then runs the handler.
  4. The result is shown in the form:
    • ok: a success note, or your successDialog. A one-shot handler (datatable, script, chat) makes the form read-only. With successDialog, the form resets to blank once the dialog is closed.
    • invalid: the error messages appear under the matching fields.
    • error: an error note is shown inline.

The component doesn’t include a captcha library. Pass captcha.render to add your own widget (Cloudflare Turnstile, reCAPTCHA, hCaptcha, …). Submitting is blocked until the widget returns a token:

import { Turnstile } from "@marsidev/react-turnstile";
<NebulasSurface
surfaceName="contact_form"
captcha={{
render: (onVerify) => (
<Turnstile
siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!}
onSuccess={onVerify}
onExpire={() => onVerify(null)}
/>
),
// dataPath: "/captcha_token" ← where the token goes in the submitted data (default)
}}
/>

The token is sent with the submission at /captcha_token (set dataPath to use another path), so your Nebulas handler can check it. It’s single-use: it clears after a successful submit, but stays if the submission was rejected, so fixing a field doesn’t mean solving the challenge again.

No third-party service? Use a honeypot

A honeypot is a hidden field that people never see but most bots fill in. The form counts as verified as long as the field is empty:

HoneypotCaptcha.tsx
"use client";
import { useEffect, useState } from "react";
const HONEYPOT_TOKEN = "honeypot-empty";
export function HoneypotCaptcha({ onVerify }: { onVerify: (t: string | null) => void }) {
const [value, setValue] = useState("");
useEffect(() => onVerify(HONEYPOT_TOKEN), []); // verified on mount
return (
<input
type="text"
name="company_website"
value={value}
onChange={(e) => {
setValue(e.target.value);
onVerify(e.target.value === "" ? HONEYPOT_TOKEN : null);
}}
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
style={{ position: "absolute", left: "-9999px", width: 1, height: 1, opacity: 0 }}
/>
);
}
// <NebulasSurface captcha={{ render: (onVerify) => <HoneypotCaptcha onVerify={onVerify} /> }} />
Prop Use it to…
data={{ email: user.email }} Prefill fields the visitor can still edit.
inputs={{ plan: "pro" }} Send values for the widget’s declared inputs, including hidden ones such as the page the form sits on.
trackingParams Send UTM parameters from the URL as hidden inputs. See Track where leads come from.
sessionId Link the submission to a chat conversation.
  • theme="brand" (default): the form uses the brand tokens set on the widget in Nebulas.
  • theme="product": your page’s own Tailwind tokens (--primary, …) apply.
  • className: extra classes on the wrapper.
  • labels: change the built-in texts: loading, error, retry, pending, captchaRequired.
  • locale="fr": pick which language of the widget’s own labels to show.

Each render rebuilds the widget on the Nebulas side, which is the slow part. With cache: { enabled: true } on the handler, renders and name lookups are cached in memory. For the first 3 seconds (freshMs) a cached render is returned as is. After that, the handler makes one cheap check that the widget hasn’t changed before reusing it. Changes you make in Studio show up within seconds. Submissions are never cached.