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”-
Open Nebulas Studio, go to your app’s resources and click New widget.
-
Design the form: add fields (text fields, checkboxes, choice pickers) and a submit button. For example:
Field Type Rule nameTextField required emailTextField required, pattern (email) companyTextField none messageTextField (multi-line) required -
Pick what the submit button does (its handler):
Handler What it does on submit datatableAdds the submitted data as a row in a Nebulas datatable. This is the usual choice for leads. scriptRuns one of your tool scripts with the submitted data (send an email, call a CRM, …). chatTurns the submission into a message for a conversation. refreshUpdates the widget in place (for example to re-read a report). surfaceShows a follow-up widget below the form. -
Give the widget a name you’ll recognize, for example
contact_form. Your website uses this name to find it. -
Publish the app so the widget is available.
Part 2 · Add the form to your website
Section titled “Part 2 · Add the form to your website”Directorysrc
Directorylib
- nebulas.ts
Directoryapp
Directoryapi
Directorynebulas-surfaces
Directory[…path]
- route.ts forwards render / actions / theme only
Directorycontact
- page.tsx renders the form
-
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 formsnebulasApiKey: process.env.NEBULAS_API_KEY,// + your service auth credentials, if you use one}); -
Add the surface route. It’s a catch-all route that forwards exactly three calls to Nebulas and returns
404for 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.src/server.ts import { createNebulasSurfaceRouter } from "@gnomondigital/nebulas-kit-core/express";app.use("/api/nebulas-surfaces",createNebulasSurfaceRouter(express, {apiKey: process.env.NEBULAS_API_KEY,cache: { enabled: true },})); -
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><NebulasSurfacesurfaceName="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>);} -
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).
What happens on submit
Section titled “What happens on submit”- The browser checks the validation rules from the widget definition (required, pattern, min/max, …) and shows errors under the fields.
- The submission goes to
/api/nebulas-surfaces/<id>/actions/<name>and your server forwards it to Nebulas with your credentials. - Nebulas checks the rules again and then runs the handler.
- The result is shown in the form:
ok: a success note, or yoursuccessDialog. A one-shot handler (datatable,script,chat) makes the form read-only. WithsuccessDialog, 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.
Protect it from spam
Section titled “Protect it from spam”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:
"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} /> }} />Prefill and context
Section titled “Prefill and context”| 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. |
Styling
Section titled “Styling”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.
Faster renders with caching
Section titled “Faster renders with caching”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.
