Skip to content

Forms

Backend Frontend

Implementing forms with Brush ; with server side validation.

The frontend needs:

  • A form in a Shopify Liquid file…
  • …having each fields to be validated identified by a validator-field="FIELD_NAME_FOR_SERVER_VALIDATION" attribute.
  • This form is powered by an AlpineJS component…
  • … which instanciates a Brush.Form.Validator and handles form submission logic.

The backend needs:

  • A POST route…
  • … which declares a Zod schema where each entry matches a field from the frontend that needs validation.
  • Then the request is validated against this schema using Brush’s Form.validate utility.
some-liquid-file.liquid
<div x-data="ContactForm">
<form>
<div>
<input type="text" id="lastname" x-model="formData.lastname" validator-field="lastname" placeholder="Last name">
<input type="text" id="firstname" x-model="formData.firstname" validator-field="firstname" placeholder="First name">
</div>
<div>
<select name="topic" id="topic" x-model="formData.topic" validator-field="topic">
<option value="">Choose a topic</option>
<template x-for="(label, value) in topics">
<option :value="value" x-text="label"></option>
</template>
</select>
</div>
<div>
<label>
<input type="checkbox" id="newsletter" x-model="formData.newsletter">
Subscribe to newsletter
</label>
</div>
<div>
<button x-on:click.prevent="submit" :disabled="submitting">Submit</button>
</div>
</form>
</div>

ā„¹ļø Please see AlpineJS components to learn how to build and declare those with Brush.

brush/src/components/contact-form.ts
const initialFormData = {
lastname: "",
firstname: "",
topic: "",
newsletter: false,
};
export default function () {
Alpine.data("ContactForm", () => ({
submitting: false,
validator: undefined as InstanceType<typeof Brush.Form.Validator> | undefined,
topics: {
// See "Translations" reference for more infomation on how to use $t()
order: $t("form.topics.order"),
product: $t("form.topics.product"),
other: $t("form.topics.other"),
},
formData: { ...initialFormData },
async submit() {
this.submitting = true;
this.validator = this.validator || new Brush.Form.Validator(this.$root.querySelector("form")!);
this.validator.clearErrors();
const res = await ggtFetch("contact-form", {
method: "POST",
body: JSON.stringify(this.formData),
});
if (res.valid) {
this.formData = { ...initialFormData };
} else {
this.validator.showErrors(res.errors);
}
this.submitting = false;
},
}));
}
api/routes/contact-form/POST.ts
import { RouteHandler } from "gadget-server";
import { z } from "zod";
import { Form } from "../../../../_brush";
import { BrushContext } from "../../../../_brush/types";
const handler: RouteHandler = async (context: BrushContext) => {
// Define Zod schema
// Note: Messages will be translated on the frontend automatically by Brush using $t() under the hood.
const schema = z.object({
firstname: z.string().min(1, { message: "form_errors.required" }),
lastname: z.string().min(1, { message: "form_errors.required" }),
topic: z.enum(["order", "product", "other"], { message: "form_errors.required" }),
});
// Validate request against Zod schema
const result = Form.validate(context, schema);
// Business logic
if (result.valid) {
context.logger.info({ body: context.body }, "[brush] Form is valid");
} else {
context.logger.error(
{ error: new Error("[brush] Form is invalid"), body: context.body, errors: result.errors },
"[brush] Form is invalid"
);
}
// Send response
return context.reply.send(result);
};
export default handler;