Creating an Email Contact Form in Next.js: Using Resend and Zod
15 min read
Table of contents
Introduction
Next.js is a powerful full-stack framework that allows us to build applications with front and backend features. It's very flexible and can be used for everything from simple static websites to complex web apps. Today, we will use Next.js to build an email contact form.
Forms are a key part of any website, letting users interact with the application. Whether it's for signing up, logging in, giving feedback, or collecting data, forms are vital for user experience. Without forms, a full-stack application wouldn't be able to gather and process user input properly.
In this blog, I will show you how to create an email contact form using Next.js, Resend, and Zod (for form validation). We will cover setting up the project, designing the form, handling form submissions, and creating a separate API route. By the end, you will know how to build and add forms to your Next.js apps, ensuring your web app works well and is easy to use.
So, without further delay, let's get started.
What is Resend?
Resend is a modern email API for developers. It's designed to make sending emails from your applications simple, reliable, and scalable. Unlike traditional email services, Resend is built with developers in mind, offering a straightforward API that integrates seamlessly with various programming languages and frameworks, including Next.js.
In our Next.js form project, we'll use Resend to send emails. When a user submits the form, we'll use Resend's API to send a confirmation email or process the form data as needed.
What is Zod?
Zod is a powerful tool for your data. It's a TypeScript-first library that helps you define and check the shape of your data. Think of it as setting rules for your data and then making sure the data matches those rules before you use it.
If you're using TypeScript (and if you're not, you should consider it!), Zod plays nicely with it. It can automatically infer TypeScript types from your schemas, which is a huge time-saver. While TypeScript checks types at compile-time, Zod does it at runtime. This means you can catch data issues that might slip through static type checking. You can use Zod for all sorts of data validation scenarios, from simple form inputs to complex API responses.
Project Setup
Let's start by setting up our Next.js project with all the necessary dependencies. We'll use TypeScript for type safety, Tailwind CSS for styling, Ant Design for UI components, Zod for form validation, and Resend for email functionality.
- Create a new Next.js project with TypeScript:
npx create-next-app@latest my-contact-form --typescript
cd my-contact-form
- Install additional dependencies:
yarn add antd zod resend react-icons
Setup Environment variables
For sending an email, we will use Resend, so we need the Resend API key. Before starting our server, let's go to Resend and get our API keys. Click here to go to the Resend site, and click the sign-in button.
After signing in, you'll be redirected to this page. Here, you'll see all the emails you received from your form.
Here, click on the API Keys section
And, generate an API key by clicking on this π button
Now, copy that API key and keep it safe. Next, open VSCode and create a new file named .env
in your root folder. Add an environment variable there.
RESEND_API_KEY=yourapikeywillbehere
Now you can also run your server using this command.
yarn dev
Email Template Component
Let's start by creating an email template. This will be the template you receive when someone sends you an email via the contact form.
import * as React from 'react';
interface EmailTemplateProps {
firstName: string;
message: string;
}
export const EmailTemplate: React.FC<EmailTemplateProps> = ({
firstName,
message,
}) => (
<div>
<h1>Hello, I am {firstName}!</h1>
<p>You have received a new message from your Portfolio:</p>
<p>{message}</p>
</div>
);
This simple React component defines the structure of the email that will be sent when someone submits the contact form. It takes two props: firstName
and message
. The component creates a personalized greeting using the first name and displays the submitted message.
Implementing Email Sending with Resend API
Here. we'll see how to implement email-sending functionality using the Resend API.
The Code Structure
First, let's look at where this code lives in our Next.js project:
app/
βββ api/
β βββ v1/
β βββ send/
β βββ route.ts
This structure follows Next.js 13's App Router convention, where API routes are defined as route handlers within the app directory.
This is our complete API route codeπ
import { EmailTemplate } from 'app/components/email-template';
import { NextResponse } from 'next/server';
import { Resend } from 'resend';
import { v4 as uuid } from 'uuid';
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(req: Request) {
try {
const { name, email, subject, message } = await req.json();
const { data, error } = await resend.emails.send({
from: 'Contact Form <onboarding@resend.dev>',
to: 'katare27451@gmail.com',
subject: subject || 'New Contact Form Submission',
reply_to: email,
headers: {
'X-Entity-Ref-ID': uuid(),
},
react: EmailTemplate({ firstName: name, message }) as React.ReactElement,
});
if (error) {
return NextResponse.json({ error }, { status: 500 });
}
return NextResponse.json({ data, message: 'success' }, { status: 200 });
} catch (error) {
console.error('Error processing request:', error);
return NextResponse.json({ error: 'Failed to process request' }, { status: 500 });
}
}
Breaking Down the Code
Now, let's examine each part of the code:
import { EmailTemplate } from 'app/components/email-template';
import { NextResponse } from 'next/server';
import { Resend } from 'resend';
import { v4 as uuid } from 'uuid';
These import statements bring in the necessary dependencies:
EmailTemplate
: A custom React component for our email content(That we already built above.NextResponse
: Next.js utility for creating API responses.Resend
: The Resend API client.uuid
: For generating unique identifiers.
const resend = new Resend(process.env.RESEND_API_KEY);
Here, we initialize the Resend client with our API key. It's crucial to keep this key secret, so we store it in an environment variable.
export async function POST(req: Request) {
// ... (code inside this function)
}
This exports an async function named POST
, which Next.js will automatically use to handle POST requests to this route.
const { name, email, subject, message } = await req.json();
We extract the form data from the request body. This assumes the client is sending a JSON payload with these fields.
const { data, error } = await resend.emails.send({
from: 'Contact Form <onboarding@resend.dev>',
to: 'katare27451@gmail.com',
subject: subject || 'New Contact Form Submission',
reply_to: email,
headers: {
'X-Entity-Ref-ID': uuid(),
},
react: EmailTemplate({ firstName: name, message }) as React.ReactElement,
});
This is where we'll get our emails! We use Resend's send
method to dispatch the email:
from
: The sender's email address.to
: The recipient's email address.subject
: The email subject, using the provided subject or a default.reply_to
: Sets the reply-to address to the form submitter's email.headers
: Includes a unique ID for tracking.react
: Uses our customEmailTemplate
component to generate the email content.
if (error) {
return NextResponse.json({ error }, { status: 500 });
}
return NextResponse.json({ data, message: 'success' }, { status: 200 });
Here, we handle the response from Resend. If there's an error, we return a 500 status
with the error details. Otherwise, we send a success
response.
catch (error) {
console.error('Error processing request:', error);
return NextResponse.json({ error: 'Failed to process request' }, { status: 500 });
}
This catch block handles any unexpected errors, logs them, and returns a generic error response.
And that's it! We've set up our API route. The only thing left is to set up our logic and UI. Let's do that too πͺ
Contact Page Component
In your app directory, create a new folder named contact-form
and inside this folder, create a file named page.tsx
.
app/
βββ contact-form/
β βββ page.tsx
Imports and Dependencies
First, import all necessary components from Ant Design, Next.js, and React Icons. We also import Zod for form validation.
import React from 'react';
import { Form, Input, Button, message, Space, Divider, Typography } from 'antd';
import Head from 'next/head';
import { FaUser } from 'react-icons/fa';
import { MdMail } from 'react-icons/md';
import { FaMessage } from 'react-icons/fa6';
import { z } from 'zod';
import Paragraph from 'antd/es/typography/Paragraph';
UI Layout and Design
Now, let's create our UI, and then we'll move on to the logic. Our form will look something like this.π
In your page.tsx
, after all the import statements, define a component and add a return statement first.
const ContactPage: React.FC = () => {
return (
<div className="max-w-3xl w-full space-y-8 bg-white p-10 rounded-xl shadow-2xl">
/* our code will be here */
</div>
);
};
export default ContactPage;
Currently, we have just a simple div with a few tailwind styling now, we'll first add our heading part.
...
<div>
<h2 className="mt-1 text-center text-3xl font-extrabold text-gray-900">Get in Touch</h2>
<p className="mt-1 mb-4 text-center text-sm text-gray-600">
I'd love to hear from you! Fill out the form below to get in touch.
</p>
</div>
...
Now, let's add our input fields
...
<Form
form={form}
name="contact"
onFinish={onFinish}
layout="vertical"
className="mt-8 space-y-6"
>
<Form.Item
name="name"
rules={[{ required: true, message: 'Please input your name!' }]}
>
<Input prefix={<FaUser className="site-form-item-icon" />} placeholder="Your Name" size="large" />
</Form.Item>
<Form.Item
name="email"
rules={[
{ required: true, message: 'Please input your email!' },
{ type: 'email', message: 'Please enter a valid email!' }
]}
>
<Input prefix={<MdMail className="site-form-item-icon" />} placeholder="Your Email" size="large" />
</Form.Item>
<Form.Item
name="subject"
rules={[{ required: true, message: 'Please input a subject!' }]}
>
<Input prefix={<FaMessage className="site-form-item-icon" />} placeholder="Subject" size="large" />
</Form.Item>
<Form.Item
name="message"
rules={[{ required: true, message: 'Please input your message!' }]}
>
<TextArea
placeholder="Your Message"
autoSize={{ minRows: 4, maxRows: 8 }}
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
disabled={isSubmitting}
>
{isSubmitting ? 'Sending...' : 'Send Message'}
</Button>
</Form.Item>
</Form>
...
Here, in the above code firstly we added a Form Component. This is the main Form component from Ant Design. It has the following props:
<Form
form={form}
name="contact"
onFinish={onFinish}
layout="vertical"
className="mt-8 space-y-6"
>
{/* Form items */}
</Form>
form
: Links the form to theform
object created usingForm.useForm()
.name
: Gives the form a name, in this case, "contact".onFinish
(we'll declare this function in our next section): Specifies the function to be called when the form is submitted successfully.layout
: Sets the form layout to "vertical".className
: Applies CSS classes for styling.
Then, we added a Form Items. Each Form.Item
represents a field in the form. Let's look at the name
field as an example
<Form.Item
name="name"
rules={[{ required: true, message: 'Please input your name!' }]}
>
<Input prefix={<FaUser className="site-form-item-icon" />} placeholder="Your Name" size="large" />
</Form.Item>
name
: Specifies the field name.rules
: An array of validation rules. Here, it's set as required.The
Input
component is used for text input, with a user icon prefix and a placeholder.
Similarly, we have Email and other fields.
<Form.Item
name="email"
rules={[
{ required: true, message: 'Please input your email!' },
{ type: 'email', message: 'Please enter a valid email!' }
]}
>
<Input prefix={<MdMail className="site-form-item-icon" />} placeholder="Your Email" size="large" />
</Form.Item>
This field has an additional rule to ensure the input is a valid email address.
Subject and Message Fields: These are similar to the name field, with the message field using a TextArea
component for multi-line input.
Then, we have a Submit Button to submit our form
<Form.Item>
<Button
type="primary"
htmlType="submit"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
disabled={isSubmitting}
>
{isSubmitting ? 'Sending...' : 'Send Message'}
</Button>
</Form.Item>
This is the submit button for the form. It's disabled when isSubmitting
(we'll add this state in our next section) is true, and its text changes to "Sending...
" during submission.
Form Submission Logic
So, in the logic part, we have a few things to do:
Setting up Zod schema for form validation
Adding new states
and, implementing a
onFinish
function
We'll start with setting up our schema first.
// Zod schema for form validation
const contactSchema = z.object({
name: z.string().min(4, 'Name must be at least 4 characters').max(50, 'Name must not exceed 50 characters'),
email: z.string().email('Invalid email address').regex(/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/, "Email must be a valid format"),
subject: z.string().min(5, 'Subject must be at least 5 characters').max(100, 'Subject must not exceed 100 characters'),
message: z.string().min(20, 'Message must be at least 20 characters').max(1000, 'Message must not exceed 1000 characters'),
});
type ContactFormData = z.infer<typeof contactSchema>;
This part defines a Zod schema for form validation. As we already learned, Zod is a TypeScript-first schema declaration and validation library. The contactSchema
object defines the structure and validation rules for the contact form:
name
: Must be a string between 4 and 50 characters.email
: Must be a valid email address and match the specified regex pattern.subject
: Must be a string between 5 and 100 characters.message
: Must be a string between 20 and 1000 characters.
The ContactFormData
type is inferred from the Zod schema, providing type safety for the form data.
Now, let's add 2 new states and implement our onFinish
function.
const ContactPage: React.FC = () => {
const [form] = Form.useForm<ContactFormData>();
const [isSubmitting, setIsSubmitting] = React.useState(false);
const onFinish = async (values: ContactFormData) => {
setIsSubmitting(true);
try {
contactSchema.parse(values);
const response = await fetch('/api/v1/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
});
if (!response.ok) {
message.error('Failed to send message. Please try again.');
setIsSubmitting(false);
}
const data = await response.json();
if (data.message === 'success') {
message.success('Message sent successfully!');
setIsSubmitting(false);
form.resetFields();
} else {
throw new Error('Failed to send message');
}
} catch (error) {
if (error instanceof z.ZodError) {
error.errors.forEach((err) => {
message.error(err.message);
setIsSubmitting(false);
});
} else {
message.error('Failed to send message. Please try again.');
setIsSubmitting(false);
}
} finally {
setIsSubmitting(false);
}
};
This part defines the ContactPage
functional component:
It uses the
Form.useForm
hook to create a form instance.It manages a
isSubmitting
state to track form submission status.The
onFinish
function is called when the form is submitted:It sets
isSubmitting
totrue
.It uses
contactSchema.parse(values)
to validate the form data against the Zod schema.If validation passes, it sends a POST request to
/api/v1/send
with the form data.It handles the response, showing success or error messages accordingly.
If there's a Zod validation error, it displays the error message.
Finally, it sets
isSubmitting
back tofalse
.
This setup ensures that the form data is validated on both the client-side (using Antd's form validation) and the server-side (using Zod schema validation) before sending the data to the server. It also manages the submission state and provides user feedback through success or error messages.
And, this is the complete code of our contact-form
file π
"use client";
import React from 'react';
import { Form, Input, Button, message, Space, Divider, Typography } from 'antd';
import Head from 'next/head';
import { FaUser } from 'react-icons/fa';
import { MdMail } from 'react-icons/md';
import { FaMessage } from 'react-icons/fa6';
import { z } from 'zod';
import { Container } from 'app/components/container';
import Paragraph from 'antd/es/typography/Paragraph';
const { TextArea } = Input;
const { Text } = Typography;
// Zod schema for form validation
const contactSchema = z.object({
name: z.string().min(4, 'Name must be at least 4 characters').max(50, 'Name must not exceed 50 characters'),
email: z.string().email('Invalid email address').regex(/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/, "Email must be a valid format"),
subject: z.string().min(5, 'Subject must be at least 5 characters').max(100, 'Subject must not exceed 100 characters'),
message: z.string().min(20, 'Message must be at least 20 characters').max(1000, 'Message must not exceed 1000 characters'),
});
type ContactFormData = z.infer<typeof contactSchema>;
const ContactPage: React.FC = () => {
const [form] = Form.useForm<ContactFormData>();
const [isSubmitting, setIsSubmitting] = React.useState(false);
const onFinish = async (values: ContactFormData) => {
setIsSubmitting(true);
try {
contactSchema.parse(values);
const response = await fetch('/api/v1/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(values),
});
if (!response.ok) {
message.error('Failed to send message. Please try again.');
setIsSubmitting(false);
}
const data = await response.json();
if (data.message === 'success') {
message.success('Message sent successfully!');
setIsSubmitting(false);
form.resetFields();
} else {
throw new Error('Failed to send message');
}
} catch (error) {
if (error instanceof z.ZodError) {
error.errors.forEach((err) => {
message.error(err.message);
setIsSubmitting(false);
});
} else {
message.error('Failed to send message. Please try again.');
setIsSubmitting(false);
}
} finally {
setIsSubmitting(false);
}
};
return (
<div className="max-w-3xl w-full space-y-8 bg-white p-10 rounded-xl shadow-2xl">
<div>
<h2 className="mt-1 text-center text-3xl font-extrabold text-gray-900">Get in Touch</h2>
<p className="mt-1 mb-4 text-center text-sm text-gray-600">
I'd love to hear from you! Fill out the form below to get in touch.
</p>
</div>
<Form
form={form}
name="contact"
onFinish={onFinish}
layout="vertical"
className="mt-8 space-y-6"
>
<Form.Item
name="name"
rules={[{ required: true, message: 'Please input your name!' }]}
>
<Input prefix={<FaUser className="site-form-item-icon" />} placeholder="Your Name" size="large" />
</Form.Item>
<Form.Item
name="email"
rules={[
{ required: true, message: 'Please input your email!' },
{ type: 'email', message: 'Please enter a valid email!' }
]}
>
<Input prefix={<MdMail className="site-form-item-icon" />} placeholder="Your Email" size="large" />
</Form.Item>
<Form.Item
name="subject"
rules={[{ required: true, message: 'Please input a subject!' }]}
>
<Input prefix={<FaMessage className="site-form-item-icon" />} placeholder="Subject" size="large" />
</Form.Item>
<Form.Item
name="message"
rules={[{ required: true, message: 'Please input your message!' }]}
>
<TextArea
placeholder="Your Message"
autoSize={{ minRows: 4, maxRows: 8 }}
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
disabled={isSubmitting}
>
{isSubmitting ? 'Sending...' : 'Send Message'}
</Button>
</Form.Item>
</Form>
</div>
);
};
export default ContactPage;
Testing
Till now, we're all set, now it's time to run and test our application.
Start your server:
yarn dev
First, let's try to hit the endpoint without filling out the form. As expected, the API doesn't get called, and we receive error messages.
Now, let's fill out the form
and hit the send button. It's in process.
Here we go, the message is sent. The sender receives a notification saying "Message sent," and the form is also refreshed.
The receiver also gets the messageπ₯³
And that's it. We have successfully built an email contact form in Next.js using Resend and Zod.
Conclusion
In this article, we built a contact form using Next.js and implemented features like form validation with Zod and email functionality with Resend. We start by setting up the Next.js project, configuring necessary dependencies, and managing environment variables for secure API access.
Then, we designed the email template, set up an API route for handling email submissions, and implemented the frontend form with Ant Design components.
If you want to see a live preview of it, you can check it out here. I have implemented the same form in my portfolio.
Thanks for reading this blog. If you learned something from it, please like and share it with your friends and community. I write blogs and share content on JavaScript, TypeScript, Open Source, and other web development-related topics. Feel free to follow me on my socials. I'll see you in the next one. Thank You :)