Create a contact form using Next.js server actions
One powerful feature that often comes into play, in order to enhance user engagement, is the creation of a dynamic and efficient contact form. In this blog post, we will learn how to craft a robust contact form using Next.js new server actions.
What are server actions ?
Server actions are asynchronous server-side functions that you can call from both server and client components. Allowing developers to handle form submissions and data mutations without the need to create separate API routes for that matter.
For server components, you can inline a server action inside the component using the 'use server'
directive at
the top of the function definition. Alternatively, you can create a new file with 'use server'
directive at
the top above any imports, this way all exported functions from that file will be marked as server
actions available for reuse.
For client components, you can only import actions from a file that uses the directive, as inlining won't work in this case.
Creating the contact form
In order to create our form, we are going to launch a Next.js (I'm using version 14) project with typescript. For other configurations, defaults are fine.
npx create-next-app@latest --typescript
We also need to install some dependencies that we are going to use later.
npm install nodemailer zod
npm install --save @types/nodemailer
Then, we are going to create a simple form that has three fields: name, email, and a message.
// app/page.tsx
export default function Home() {
return (
<main>
<h1>Contact</h1>
<form>
<input type="text" name="name" />
<input type="email" name="email" />
<textarea name="message" id="message"></textarea>
<button type="submit">Send message</button>
</form>
</main>
);
}
When a user submits a contact request, we want our server to process the data and then send and email with the
message to whatever mailbox you or your business have. Here, goes our server action with the help
of nodemailer
which is a library for sending emails from a Node.js environment.
So we create a new folder actions
and inside it we create a new file send-email.ts
where first, we are going
to create a transporter with the configuration needed for nodemailer to be able to send emails through SMTP. You
can use any known service provider to get these configurations
(e.g Sendgrid)
We save those credentials to our .env.local
file and retrieve them in our server action file.
'use server';
import nodemailer from 'nodemailer';
const transport = nodemailer.createTransport({
host: process.env.NEXT_TRANSPORT_HOST,
port: Number(process.env.NEXT_TRANSPORT_PORT) || 578,
auth: {
user: process.env.NEXT_TRANSPORT_USERNAME,
pass: process.env.NEXT_TRANSPORT_PASSWORD,
},
});
We can now define our action for sending the email, this function is getting a FormData
object with all
the data submitted from our form, and delivering an email using the sendEmail()
method from the previously
created transport.
export async function sendEmail(formData: FormData) {
const name = formData.get('name');
const email = formData.get('email');
const message = formData.get('message');
try {
await transport.sendMail({
to: 'your-receiving-email@example.com',
from: 'your-email-from-service-provider@example.com',
subject: 'New contact request!',
html: `<!DOCTYPE html>
<html lang="en">
<body>
<p>Name: ${name}</p>
<p>Email: ${email}</p>
<p>Message: ${message}</p>
</body>
</html>`,
});
return { sent: true };
} catch (error) {
return { sent: false };
}
}
Then, we can import this action in our component and use it in the form action attribute:
// app/page.tsx
import { sendEmail } from "./actions/send-email";
export default function Home() {
return (
// ...
<form action={sendEmail} >
// ...
);
}
Input validation
To validate our form input, we can use basic HTML validation for the client-side, here we can add the required
attribute to every input and also the email
type for the email input.
As our server is going to process the input sent directly from the client, we have to validate and escape
this input server-side as well. For this purpose, we are going to use zod
, which is a schema
declaration and validation library, to validate form fields before sending the email.
First, we have to create a schema object with the needed fields, all fields shouldn't be empty, with a maximum number of characters for both name and message, and a valid email address, we can also add error messages in case these rules are violated.
// app/actions/send-email.ts
const schema = z.object({
name: z
.string()
.max(100, { message: 'Name should be less than 100 characters' })
.min(1, { message: 'Name is required' }),
email: z.string().email({
message: 'Invalid email address',
}),
message: z
.string()
.max(500, { message: 'Message should be less than 500 characters' })
.min(1, { message: 'Message is required' }),
});
Then, we have to update our action, just before sending the email we have to safely parse our input using
safeParse
method on the previously created schema. If there are any errors, we are going to include them
in the returned response object.
export async function sendEmail(formData: FormData) {
// ...
const validatedFields = schema.safeParse({
name,
email,
message,
});
if (!validatedFields.success) {
return {
sent: false,
errors: validatedFields.error.flatten().fieldErrors,
};
}
try {
// ...
}
}
Back to our contact form, we would normally want to show error messages for each field in case they occur, for
that we are going to use the useFormState
React hook, and this will introduce two changes:
- The signature of our action's function, as it will now receive a new parameter as its first argument:
the
initialState
, which is basically the initial value of the state before invoking the action. - Our contact component must now be a client component since we are using a React hook.
So, let's start with our action by adding the state as a first argument
export async function sendEmail(initialState: any, formData: FormData) {
//...
}
Then, let's add the hook to our contact component, we pass it our sendEmail
action and the initialState
object,
notice that now we are going to use the action sendAction
returned from the hook. Also, we are going to
add error messages related to each field in the form.
'use client';
import { useFormState } from 'react-dom';
import { sendEmail } from './actions/send-email';
const initialState = {
sent: false,
errors: {},
};
export default function Home() {
const [state, sendAction] = useFormState(sendEmail, initialState);
return (
<main>
<h1>Contact</h1>
<form action={sendAction}>
<input type="text" name="name" required />
{state.errors?.name && <span>{state.errors.name[0]}</span>}
<input type="email" name="email" required />
{state.errors?.email && <span>{state.errors.email[0]}</span>}
<textarea name="message" id="message" required></textarea>
{state.errors?.message && <span>{state.errors.message[0]}</span>}
<button type="submit">Send message</button>
</form>
</main>
);
}
Pending state
One last thing we are going to add is a loading state to the submit button while our contact form is being submitted, this way users get a hint that their message is being sent. Also, a message indicating the success of the operation in the end is very useful.
Here, we can use another React hook useFormStatus
which will give us a boolean indicating the pending state.
For that to work, we need to first, make the submit button a separate client component while keeping it a child
of our form
element.
// app/submit-button.tsx
import { useFormStatus } from 'react-dom';
export default function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Sending...' : 'Send message'}
</button>
);
}
Finally, we update our form with the button and the success message.
// ...
import SubmitButton from "./submit-button";
//...
export default function Home() {
// ...
<main>
<h1>Contact</h1>
<form action={sendAction}>
// ...
<SubmitButton />
{state.sent && <span>Message sent!</span>}
</form>
</main>
);
}
You can find the full demo app in GitHub