Building a Newsletter Form in Next.js 15 with React 19, React Hook Form, and Shadcn UI
Learn how to create a newsletter subscription form with server actions, and the latest React 19 hook.

Next.js 15 has just launched with React 19, and with it comes some big changes. useFormState has been renamed to useActionState, and features like startTransition, server actions and server components are all the rage. Everyone is also jumping on the ShadCN bandwagon. React Hook Form was once the king of forms, but how do you use it alongside all these new features? It’s normal to feel overwhelmed by all of this.
This article will guide you through implementing a newsletter subscription form using useActionState, React Hook Form, and ShadCN UI components. We’ll cover integrating form validation, server actions, and loading states seamlessly.
Full code example at the bottom of the article.
Step 1: Set up the Form Schema
Client-side validation is handled by React Hook Form with a Zod schema.
Define your form’s data structure and validation rules using zod:
1import * as z from"zod";23const formSchema = z.object({4name: z.string().min(2, { message: "Name must be at least 2 characters" }),5email: z.string().email({ message: "Please enter a valid email address" }),6});
Step 2: Initialize Form Hooks and Refs
Set up the necessary hooks and refs for form handling:
1import { useForm } from "react-hook-form";2import { zodResolver } from "@hookform/resolvers/zod";3import { useRef, useEffect } from "react";4import { useActionState } from "react";5import { subscribeToNewsletter } from "@/app/contact/actions";67export function NewsletterHomeSection() {8 const formRef = useRef<HTMLFormElement>(null);9 const [state, formAction, isPending] = useActionState(subscribeToNewsletter, {});10 const form = useForm<z.infer<typeof formSchema>>({11 resolver: zodResolver(formSchema),12 defaultValues: { name: "", email: "" },13 });14}
We will be defining the server action later on.
Step 3: Set up Form Response Handling
Add an effect to handle form submission responses:
1useEffect(() => {2 if (state?.success) {3 toast.success("Successfully subscribed to the newsletter!");4 form.reset();5 }6 if (state?.error) {7 toast.error(state.error);8 }9}, [state, form]);
Next, we can optionally use useEffect to display toasts for errors or successes. The error and success fields are defined in the server action (step 7) and can be set to any value.
While optional, it’s common practice to utilize these fields for feedback.
Step 4: Create the Form Structure
Combine Shadcn’s Form components with React Hook Form. Use startTransition to ensure smooth UI updates during form submission:
1<Form {...form}>2 <form3 ref={formRef}4 className="space-y-4"5 action={formAction}6 onSubmit={(evt) => {7 evt.preventDefault();8 form.handleSubmit(() => {9 const formData = new FormData(formRef.current!);10 startTransition(() => {11 formAction(formData);12 });13 })(evt);14 }}15 >16 {/* Form fields go here */}17 </form>18</Form>
Using startTransition ensures that state updates related to form submission do not block other UI updates, maintaining a smooth user experience.
It marks state updates as non-urgent, ensuring smoother UI responsiveness during async server actions.
It’s a new addition to React 19. You can read more about it here.
This is a critical step otherwise we will get the following error:
An async function was passed to useActionState, but it was dispatched outside of an action context

Step 5: Add Form Fields
Let’s now add name and email fields to our form. Use Shadcn’s FormField component to build fields:
1<div className="flex flex-col md:flex-row gap-4">2 <FormField3 control={form.control}4 name="name"5 render={({ field }) => (6 <FormItem className="flex-1">7 <FormControl>8 <Input placeholder="Your name" disabled={isPending} {...field} />9 </FormControl>10 <FormMessage />11 </FormItem>12 )}13 />14 <FormField15 control={form.control}16 name="email"17 render={({ field }) => (18 <FormItem className="flex-1">19 <FormControl>20 <Input type="email" placeholder="Your email" disabled={isPending} {...field} />21 </FormControl>22 <FormMessage />23 </FormItem>24 )}25 />26</div>
For more information about the different types of fields, you can refer to the ShadCN documentation here.
Step 6: Add Submit Button
The final piece of the form is the submit button. Using the isPending state from useActionState, we can dynamically display text based on the submission status.
Additionally, we can disable the button while the submission is in progress to prevent duplicate submissions.
Here’s how you can create a submit button with a loading state:
1<div className="flex justify-center">2 <input type="hidden" name="source" value="homepage" />3 <Button type="submit" disabled={isPending} size="lg" className="w-full md:w-auto">4 {isPending ? (5 <>6 <Mail className="mr-2 h-4 w-4 animate-spin" />7 Subscribing...8 </>9 ) : (10 <>11 <Mail className="mr-2 h-4 w-4" />12 Subscribe Now13 </>14 )}15 </Button>16</div>
Step 7: Create the Server Action
As we have finished the client-side rendering let’s move to the server. Let’s define the mentioned server action that will be responsible for creating the newsletter entry in our database.
Define your server-side action:
1'use server';23export async function subscribeToNewsletter(previousState: NewsletterState | null,4 formData: FormData) {5 try {6 const name = formData.get('name');7 const email = formData.get('email');8 const subscriber = await prisma.newsletterSubscriber.create({9 data: {10 email,11 name,12 source: source?.toString() || 'homepage',13 },14 })15 return { success: true };16 } catch (error) {17 return { error: "Failed to subscribe" };18 }19}
Notice how we are returning an object with success and error keys. This object is sent back to the client component and determines what will be displayed. Earlier, we set up useEffect to show toasts based on whether success or error is defined in this object.
You can freely define the structure of this object, but it’s important to keep it consistent between the server action and the client logic. For a more robust approach, you can define a type that matches the structure used in useActionState. However, for simplicity, I chose not to do so in this example.
I’m using Prisma to manage my newsletter subscribers. In an upcoming post, I’ll show you how to set up a database using Docker and host it on an affordable or home-based VPS to serve as your newsletter database.

Here is the full code example.
Form component
1'use client'23import { useEffect, useRef } from 'react'4import { useActionState } from 'react'5import { zodResolver } from "@hookform/resolvers/zod"6import { useForm } from "react-hook-form"7import * as z from "zod"8import { Button } from '@/components/ui/button'9import { Input } from '@/components/ui/input'10import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'11import {12 Form,13 FormControl,14 FormField,15 FormItem,16 FormMessage,17} from "@/components/ui/form"18import { Mail, Sparkles, BookOpen, XCircle } from 'lucide-react'19import { subscribeToNewsletter } from '@/app/contact/actions'20import { startTransition } from 'react'2122const formSchema = z.object({23 name: z.string().min(2, { message: "Name must be at least 2 characters" }),24 email: z.string().email({ message: "Please enter a valid email address" }),25})2627export function NewsletterHomeSection() {28 const formRef = useRef<HTMLFormElement>(null)29 const [state, formAction, isPending] = useActionState(subscribeToNewsletter, null)3031 const form = useForm<z.infer<typeof formSchema>>({32 resolver: zodResolver(formSchema),33 defaultValues: {34 name: "",35 email: "",36 },37 })3839 return (40 <section className="py-16 bg-gradient-to-b from-gray-50 to-white dark:from-gray-900/50 dark:to-gray-900">41 <div className="container mx-auto px-4">42 <div className="max-w-3xl mx-auto">43 <Card className="border-0 shadow-lg">44 <CardHeader className="text-center pb-4">45 <CardTitle className="text-3xl font-bold">Stay Ahead in Tech</CardTitle>46 <CardDescription className="text-lg mt-2">47 Join my newsletter for exclusive insights into software development, MVP building, and tech entrepreneurship.48 </CardDescription>49 </CardHeader>50 <CardContent className="pt-6">51 {state?.success && (52 <div className="flex flex-row justify-center gap-2 items-center text-center p-4 rounded-lg bg-blue-50 dark:bg-blue-950/30">53 <Sparkles className="h-4 w-4 text-blue-600 dark:text-blue-400 mb-3" />54 <h3 className="font-semibold mb-2">Successfully subscribed to the newsletter!</h3>55 </div>56 )}57 {state?.error && (58 <div className="flex flex-row justify-center gap-2 items-center text-center p-4 rounded-lg bg-red-50 dark:bg-red-950/30">59 <XCircle className="h-4 w-4 text-red-600 dark:text-red-400 mb-3" />60 <h3 className="font-semibold mb-2">Failed to subscribe to the newsletter. Please try again later.</h3>61 </div>62 )}63 <div className="grid md:grid-cols-3 gap-6 mb-8">64 <div className="flex flex-col items-center text-center p-4 rounded-lg bg-blue-50 dark:bg-blue-950/30">65 <Sparkles className="h-8 w-8 text-blue-600 dark:text-blue-400 mb-3" />66 <h3 className="font-semibold mb-2">Weekly Articles</h3>67 <p className="text-sm text-gray-600 dark:text-gray-300">68 Deep dives into modern web development, React, and Next.js69 </p>70 </div>71 <div className="flex flex-col items-center text-center p-4 rounded-lg bg-blue-50 dark:bg-blue-950/30">72 <BookOpen className="h-8 w-8 text-blue-600 dark:text-blue-400 mb-3" />73 <h3 className="font-semibold mb-2">Practical Tutorials</h3>74 <p className="text-sm text-gray-600 dark:text-gray-300">75 Step-by-step guides on building real-world applications76 </p>77 </div>78 <div className="flex flex-col items-center text-center p-4 rounded-lg bg-blue-50 dark:bg-blue-950/30">79 <XCircle className="h-8 w-8 text-blue-600 dark:text-blue-400 mb-3" />80 <h3 className="font-semibold mb-2">No Spam Promise</h3>81 <p className="text-sm text-gray-600 dark:text-gray-300">82 Unsubscribe anytime, your inbox privacy is guaranteed83 </p>84 </div>85 </div>8687 <Form {...form}>88 <form89 ref={formRef}90 className="space-y-4"91 onSubmit={(evt) => {92 evt.preventDefault()93 form.handleSubmit(() => {94 const formData = new FormData(formRef.current!)95 startTransition(() => {96 formAction(formData)97 })98 })(evt)99 }}100 >101 <div className="flex flex-col gap-4">102 <div className="flex flex-col md:flex-row gap-4">103 <FormField104 control={form.control}105 name="name"106 render={({ field }) => (107 <FormItem className="flex-1">108 <FormControl>109 <Input110 placeholder="Your name"111 disabled={isPending}112 {...field}113 />114 </FormControl>115 <FormMessage />116 </FormItem>117 )}118 />119 <FormField120 control={form.control}121 name="email"122 render={({ field }) => (123 <FormItem className="flex-1">124 <FormControl>125 <Input126 type="email"127 placeholder="Your email"128 disabled={isPending}129 {...field}130 />131 </FormControl>132 <FormMessage />133 </FormItem>134 )}135 />136 </div>137 <div className="flex justify-center">138 <input type="hidden" name="source" value="homepage" />139 <Button type="submit" disabled={isPending} size="lg" className="w-full md:w-auto">140 {isPending ? (141 <>142 <Mail className="mr-2 h-4 w-4 animate-spin" />143 Subscribing...144 </>145 ) : (146 <>147 <Mail className="mr-2 h-4 w-4" />148 Subscribe Now149 </>150 )}151 </Button>152 </div>153 </div>154 <p className="text-center text-sm text-gray-500 dark:text-gray-400">155 Join 1,000+ developers receiving weekly insights. No spam, ever.156 </p>157 </form>158 </Form>159 </CardContent>160 </Card>161 </div>162 </div>163 </section>164 )165}
And the server action
1'use server'23export async function subscribeToNewsletter(4 previousState: NewsletterState | null,5 formData: FormData6): Promise<NewsletterState> {7 try {8 const email = formData.get('email')9 const name = formData.get('name')10 const source = formData.get('source')1112 if (!email || !name) {13 return { error: 'Name and email are required' }14 }1516 if (typeof email !== 'string' || typeof name !== 'string') {17 return { error: 'Invalid input format' }18 }1920 console.log('Attempting to create subscriber:', { email, name, source })2122 // Create new subscriber in database using the singleton client23 const subscriber = await prisma.newsletterSubscriber.create({24 data: {25 email,26 name,27 source: source?.toString() || 'homepage',28 },29 })3031 console.log('Successfully created subscriber:', subscriber)32 return { success: true }33 } catch (error: any) {34 console.error('Failed to subscribe to newsletter:', {35 name: error.name,36 message: error.message,37 code: error.code38 })3940 // Handle unique constraint violation41 if (error instanceof PrismaClientKnownRequestError && error.code === 'P2002') {42 return { error: 'This email is already subscribed to the newsletter' }43 }4445 // Handle connection errors46 if (error instanceof PrismaClientKnownRequestError &&47 (error.code === 'P1001' || error.code === 'P1002')) {48 return { error: 'Unable to connect to the database. Please try again later.' }49 }5051 return { error: 'Failed to subscribe to newsletter. Please try again later.' }52 }53}
Thanks,
Matija