Newsletter

SaaS Newsletter Form Component for shadcn/ui

The following forms are built using shadcn/ui components and TanStack Form for form logic.

The details of every shadcn/ui component used and the installation process are described below each component.

1. Minimal Newsletter

import NewsletterForm from "@/components/forms/newsletter-form";

export default function Page() {
    return (
        <div className="mx-auto max-w-xs mt-8">
            <NewsletterForm />
        </div>
    );
}

Installation

Install the following shadcn/ui components:

This component uses Sonner for toast messages. So install that as well form here.

Install Zod and Tanstack Form.

Optionally, you can read this guide on using Tanstack Form with shadcn/ui.

Finally, copy paste the following code into your project.

components/forms/newsletter-form.tsx
"use client";

// Icon Library
import { IconChevronRight, IconLoader2 } from "@tabler/icons-react";

// Tanstack Form: https://ui.shadcn.com/docs/forms/tanstack-form
import { useForm } from "@tanstack/react-form";

// Toaster for alerts: https://ui.shadcn.com/docs/components/radix/sonner
import { toast } from "sonner";

import * as z from "zod";

// Components by shadcn/ui
import { Button } from "@/components/ui/button";
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";

const formSchema = z.object({
    email: z.email({
        error: "Please enter a valid email."
    }),
});

export default function NewsletterForm() {
    const form = useForm({
        defaultValues: {
            email: "",
        },
        validators: {
            onSubmit: formSchema,
        },
        onSubmit: async ({ value }) => {
            try {
                // Simulate a network request; replace with your actual API call
                // e.g. await fetch("/api/newsletter", { method: "POST", body: JSON.stringify(value) })
                await new Promise((resolve) => setTimeout(resolve, 2000))

                // On success, you can trigger additional side effects here
                // e.g. router.push("/thank-you") or analytics.track("newsletter_subscribed")

                toast.success("Subscribed!", {
                    description: `With the email '${value.email}'`
                })

            } catch {
                // Handle specific server errors here if needed
                // e.g. show a different message for 409 (already subscribed) vs. network failures
                toast.error(
                    "Subscription failed. Check your email address and try again.",
                );
            }
        },
    });

    return (
        <div className="w-full">
            <h3 className="text-sm text-muted-foreground mb-4">Subscribe to our <span className="text-foreground">Newsletter</span></h3>
            <form
                onSubmit={(e) => {
                    e.preventDefault();
                    form.handleSubmit();
                }}
            >
                <FieldGroup className="shrink-0 flex-1 relative">
                    <form.Field name="email">
                        {(field) => {
                            const isInvalid =
                                field.state.meta.isTouched && !field.state.meta.isValid;
                            return (
                                <Field data-invalid={isInvalid}>
                                    <FieldLabel htmlFor={field.name} className="sr-only">Email</FieldLabel>
                                    <Input
                                        id={field.name}
                                        name={field.name}
                                        value={field.state.value}
                                        onBlur={field.handleBlur}
                                        onChange={(e) => field.handleChange(e.target.value)}
                                        aria-invalid={isInvalid}
                                        autoComplete="off"
                                        placeholder={"yourname@email.com"}
                                        className="h-12 bg-background"
                                    />
                                    {isInvalid && (
                                        <FieldError className="text-xs" errors={field.state.meta.errors} />
                                    )}
                                </Field>
                            );
                        }}
                    </form.Field>
                    <form.Subscribe
                        selector={(state) => [state.canSubmit, state.isSubmitting]}
                    >
                        {([canSubmit, isSubmitting]) => (
                            <Button
                                variant={"inverted"}
                                size={"icon"}
                                className="shrink-0 absolute right-1.5 top-1.5"
                                type="submit"
                                disabled={!canSubmit}
                            >
                                {isSubmitting ? <IconLoader2 className="animate-spin" /> : <IconChevronRight />}
                            </Button>
                        )}
                    </form.Subscribe>
                </FieldGroup>
            </form>
        </div >
    );
}

Update the import paths to match your project setup.

Last Update: 23 May 2026 at 12:47 PM