← Back to Blog

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.

·Matija Žiberna·
NextJs
Building a Newsletter Form in Next.js 15 with React 19, React Hook Form, and Shadcn UI

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";
2
3const 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";
6
7export 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 <form
3 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

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 <FormField
3 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 <FormField
15 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 Now
13 </>
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';
2
3export 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.

Newsletter subscription database using Prisma & PostgreSQL

Here is the full code example.

Form component

1'use client'
2
3import { 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'
21
22const 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})
26
27export function NewsletterHomeSection() {
28 const formRef = useRef<HTMLFormElement>(null)
29 const [state, formAction, isPending] = useActionState(subscribeToNewsletter, null)
30
31 const form = useForm<z.infer<typeof formSchema>>({
32 resolver: zodResolver(formSchema),
33 defaultValues: {
34 name: "",
35 email: "",
36 },
37 })
38
39 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.js
69 </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 applications
76 </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 guaranteed
83 </p>
84 </div>
85 </div>
86
87 <Form {...form}>
88 <form
89 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 <FormField
104 control={form.control}
105 name="name"
106 render={({ field }) => (
107 <FormItem className="flex-1">
108 <FormControl>
109 <Input
110 placeholder="Your name"
111 disabled={isPending}
112 {...field}
113 />
114 </FormControl>
115 <FormMessage />
116 </FormItem>
117 )}
118 />
119 <FormField
120 control={form.control}
121 name="email"
122 render={({ field }) => (
123 <FormItem className="flex-1">
124 <FormControl>
125 <Input
126 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 Now
149 </>
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'
2
3export async function subscribeToNewsletter(
4 previousState: NewsletterState | null,
5 formData: FormData
6): Promise<NewsletterState> {
7 try {
8 const email = formData.get('email')
9 const name = formData.get('name')
10 const source = formData.get('source')
11
12 if (!email || !name) {
13 return { error: 'Name and email are required' }
14 }
15
16 if (typeof email !== 'string' || typeof name !== 'string') {
17 return { error: 'Invalid input format' }
18 }
19
20 console.log('Attempting to create subscriber:', { email, name, source })
21
22 // Create new subscriber in database using the singleton client
23 const subscriber = await prisma.newsletterSubscriber.create({
24 data: {
25 email,
26 name,
27 source: source?.toString() || 'homepage',
28 },
29 })
30
31 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.code
38 })
39
40 // Handle unique constraint violation
41 if (error instanceof PrismaClientKnownRequestError && error.code === 'P2002') {
42 return { error: 'This email is already subscribed to the newsletter' }
43 }
44
45 // Handle connection errors
46 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 }
50
51 return { error: 'Failed to subscribe to newsletter. Please try again later.' }
52 }
53}

Thanks,
Matija

0
Enjoyed this article?
Subscribe to my newsletter for more insights and tutorials.