sebee.website🌱
  • Articles
  • CV
  • About
  • Contact

Sebastian Pieczynski's website and blog

Published on: yyyy.mm.dd

Back to Articles
sebee.website
  • Created with ❤️ by Sebastian Pieczyński © 2023-2025.

Links

  • Terms of Use
  • Privacy Policy
  • Cookie Policy
  • ethernal
  • @spieczynski

How to send emails for free using Next.js and nodemailer with Server Actions

Published on: 11/9/2023

Contents

  1. The Problem
  2. Breaking it down
  3. The Solution
    1. Email account
    2. Contact Form
    3. The Library
    4. Securing the data
    5. Writing nodemailerSendMail function
    6. Wiring up the form with Server Action
    7. Notifying users
  4. Conclusion
  5. Form Demo

The Problem

When I was building this site I wanted to give people ability to contact me directly. Social media is one way but I don't want to use third party service and sending an email is a bit more personal and direct. But how to do it without using external services?

Breaking it down

What do we need to send emails?

  1. Email account from which to send emails,
  2. a reusable contact form for users to fill out,
  3. a library that will send the emails programmatically,
  4. code that will handle our custom data and form securely,
  5. a way to notify users about status of email being sent.

There is quite a bit that goes into such a simple task but breaking it down will make it easier to complete.

The Solution

We'll take it one step at a time. First let's prepare our "infrastructure".

Email account

For email account I'll be using Google Mail, but the way I'm showing should work with any email provider.

Contact Form

Since there can be many places where we might want to use the contact form let's create a component that will encapsulate the functionality. We'll split the form into two components: Submit and Form. Why will become clear later when we're using Server Actions and new React hooks to handle the form status.

Submit button and the form:

Nothing too fancy for now. We are creating three inputs for: name, email and message in the form and allowing it to be submitted with a button. Usually we would create a 'handleSubmit' function and attach it to the onSubmit event on the form, but not today 🙂.

With our form done we can start thinking about functionality.

The Library

When sending emails using javascript, nodemailer is the first solution that comes to mind.

Let's install it and start wiring our form.

To add nodemailer to the project with npm use the following command:

With the library prepared for use let's start adding functionality...

Securing the data

Before we start coding we need a way to secure our connection data (username and password). We'll use .env file to store it.

Install dotenv package:

Then in .gitignore modify .env section and add the following before proceeding:

Create two files: .env and .env.dist

First let's fill out data for .env.dist. Note that this file is used to "save" what data is required to be set as the file with actual data will NOT be saved in the repository. Without it we would need to check what environmental variables are set in code and if we missed any it would be a pain to find.

You can make a copy of empty .env.dist and name it .env and fill it out.

To use gmail account we need to add password to .env. Instead of using you password you can generate application password. This is different from less secure apps access, see: Sign in with app passwords

Here are the steps required to obtain the application password:

Create & use app passwords

Important: To create an app password, you need 2-Step Verification on your Google Account.

If you use 2-Step-Verification and get a "password incorrect" error when you sign in, you can try to use an app password.

  1. Go to your Google Account.
  2. Select Security.
  3. Under "Signing in to Google," select 2-Step Verification.
  4. At the bottom of the page, select App passwords.
  5. Enter a name that helps you remember where you’ll use the app password.
  6. Select Generate.
  7. To enter the app password, follow the instructions on your screen. The app password is the 16-character code that generates on our device.
  8. Select Done.

The password is visible only once. Copy it to your .env file.

Example:

If you want more flexibility you could add HOST and PORT variables like so as well, here's data for Gmail:

Now we are ready to start writing the function that will be sending emails.

Writing nodemailerSendMail function

The nodemailerSendMail function will accept four parameters: replyTo, subject, toEmail and otpText.

replyTo : the email address of the sender, as by default the sender will be set to our internal email

subject : the subject of the email

toEmail : the email address of the recipient

otpText : the text of the email

The section of note is:

When deploying to serverless we must wrap the sendMail in a promise and await for it to either resolve or reject then we return that result.

We can now send emails from your server. We'll now use server actions to handle the form submission.

Wiring up the form with Server Action

To add server action to the form instead of using onSubmit event handler we connect the function to the action attribute of the form.

First let's define the formAction and form state using useFormState hook. This hook "allows you to update state based on the result of a form action".

Let's define initial form state. Note that I've also added error as it's a property that can be returned from the form action.

And the action itself:

Our form will now change from:

to:

You probably noticed that we do not have sendEmailAction defined anywhere. Let's fix that.

Go to the /utils/sendMail.ts fire and add sendEmailAction function:

The ('use server'); in the beginning of the function tells Next.js that this is a server action and that it should be run on the server.

Here we are verifying data sent to the server from the form making sure it's non empty and attempting to send the message using the previously defined nodemailerSendMail function. If it sends successfully we return true otherwise we return false. To verify this we also check the response from the server, see: SMTP response codes . This will be important when we want to notify users about the message status.

You can check the form now and it will send the mail to selected inbox.

Note that for it to work you need to check how to set Environment Variables for your hosting provider as .env files are not part of the repository and SHOULD NEVER be committed.

Notifying users

For notifications I prefer the toast messages as they are generally unobtrusive. We will use react-toastify package to display them.

First let's install it:

You can see the react-toastify documentation here .

And as per document suggestion:

Remember to render the ToastContainer once in your application tree. If you can't figure out where to put it, rendering it in the application root would be the best bet.

We'll put it in the main layout as we may want send out messages from different places and about various events.

The settings you see for the ToastContainer can also be configured using official website .

The most important that we care about is position, we change it to "bottom-right". We also use toastClassName and progressClassName properties to style the component. Import the component's styles before global tailwind styles as they will get overwritten otherwise.

Let's add toast notifications for our users!

We will need to update the formAction to change the status of the toast if the email was sent successfully or not. If everything went fine we'll invoke updateMailSuccess and if not we'll invoke updateMailError. This is why the function was returning boolean value as it makes it easy now to know if the email was sent of not.

To control how notifications are displayed we will use the method shown in the official documentation show here .

To create a toast it is enough to call the toast function like so:

You are not limited to calling or using the toast function to place where ToastContainer is defined. It can be any other place in the app. Just remember to have only one ToastContainer.

Our case, as it usually stands with projects I start, is a bit more complicated. Luckily for us react-toastify package has all the building blocks and examples to fit our need exactly.

We need to render the initial toast state (sending message in progress, without a timeout) and then depending on the state of the response update the existing toast to show success (if true was returned from the email function) or error (if the response was false). If you remember the docs or notes above the toast function arguments supersede the ToastContainer component. We'll use that to our advantage.

To accomplish our goal we need a reference to the toast which we will update to success or error states later.react-toastify shows how to do this in the official documentation .

Let's start writing our toast functions. First let's start with sending initial information that sending is in progress:

It's as simple as the example with the two exceptions: first is that we are assigning the toast to the toastId ref (see more in the React documentation ) that is the reference to the toast. This is because we want to update it later on. Second is that we set autoClose prop to false that way the toast message will not have a timeout and it will never disappear. Now we need functions that will update it to show success or error, these are again standalone and do not depend on the way we implement the toast.

Here we use the update method of a toast with the reference to the toast we want to update. We set autoClose to 5000 so that the toast message will disappear after five seconds after the update.

Now we can update the formAction method in the useFormState hook as well as the form itself to show the initial toast when starting to process the action:

When we receive the response from the sendEmailAction function we update the toast message to success or error depending on the response value returned. If it was successful we also reset the form state using the form reference created with another useRef hook.

We should also disable the send button when mail is being sent as we do not want to be spammed with the same message.

Congratulations! You now have a contact form that users can fill out and tell you how awesome your website is 😉 and all of it free!

Below is the full source of the ContactForm component.

Conclusion

Today we have created a contact form for our visitors to contact us using new feature in Next.js 14, we explored how to use server actions to achieve the task and how much simpler that process is from setting up the full API route. We also learned how to send emails using nodemailer and how to use react-toastify to display notifications to the users about the status of the process. We have also learned a bit about how to use refs in React and how they can be useful for forms.

As always you can contact me (via the form 👇 for example 😄) and let me know what you think. Any feedback is welcome and I will use it to improve the article.

Form Demo

Back to Articles
1# local env files
2.env*.local
3.env
4.env.*
5!.env.dist
1NODEMAILER_PASS=""
2NODEMAILER_USER=""
3NODEMAILER_RECIPIENT=""
1NODEMAILER_PASS="[PASSSWORD]"
2NODEMAILER_USER="[EMAIL]"
3NODEMAILER_RECIPIENT="[DEFAUL_RECEPIENT]"
1NODEMAILER_PASS="abcd efgh ijkl mnop"
2NODEMAILER_USER="example.mail@gmail.com"
3NODEMAILER_RECIPIENT="private-email@gmail.com"
1NODEMAILER_HOST="smtp.gmail.com"
2NODEMAILER_PORT=465
1npm install nodemailer
1npm install --save dotenv
1npm install --save react-toastify
utils/sendMail.ts
1'use server';
2// ...
3type SendMailParams = {
4 replyTo: string;
5 subject: string;
6 toEmail: string;
7 otpText: string;
8};
9
10export async function nodemailerSendMail({
11 replyTo,
12 subject,
13 toEmail,
14 otpText,
15}: SendMailParams): Promise<Error | SentMessageInfo> {
16 const transporter = nodemailer.createTransport({
17 host: 'smtp.gmail.com',
18 port: 465,
19 secure: true,
20 auth: {
21 user: process.env.NODEMAILER_USER,
22 pass: process.env.NODEMAILER_PASS,
23 },
24 });
25
26 const mailOptions = {
27 replyTo: replyTo,
28 to: toEmail,
29 subject: subject,
30 text: otpText,
31 };
32
33 // for serverless environment use promises
34 return await new Promise<Error | SentMessageInfo>((resolve, reject) => {
35 // send mail
36 transporter.sendMail(
37 mailOptions,
38 (err: Error | null, info: SentMessageInfo) => {
39 if (err) {
40 // console errors will be logged only to the server output
41 console.error(err);
42 reject(err);
43 } else {
44 resolve(info);
45 }
46 },
47 );
48 });
49}
utils/sendMail.ts
1'use server';
2// ...
3return await new Promise<Error | SentMessageInfo>((resolve, reject) => {
4 // send mail
5 transporter.sendMail(
6 mailOptions,
7 (err: Error | null, info: SentMessageInfo) => {
8 if (err) {
9 // console errors will be logged only to the server output
10 console.error(err);
11 reject(err);
12 } else {
13 resolve(info);
14 }
15 },
16 );
17 });
ContactForm.tsx
1'use client';
2
3function Submit({
4 className,
5 children,
6}: {
7 className?: string;
8 children: React.ReactNode;
9}) {
10
11 return (
12 <button type="submit" className={className}>
13 {children}
14 </button>
15 );
16}
17
18function ContactForm({ className }: { className?: string }) {
19 const initialFormState: ContactFormData = {
20 email: '',
21 name: '',
22 message: '',
23 };
24
25 return (
26<form
27 className={cn('flex flex-col gap-4 pb-10', className)}
28>
29 <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
30 <input
31 name="name"
32 type="text"
33 placeholder="Name"
34 className="p-3 dark:bg-theme-dark-background-secondary bg-theme-light-background-secondary placeholder:text-theme-light-text-muted dark:placeholder:text-theme-dark-text-muted dark:text-theme-dark-text-light rounded-theme-default outline-theme-accent focus:outline-2 disabled:cursor-not-allowed disabled:bg-opacity-50"
35 required
36 min={3}
37 />
38 <input
39 name="email"
40 type="email"
41 placeholder="Email Address"
42 className="p-3 dark:bg-theme-dark-background-secondary bg-theme-light-background-secondary placeholder:text-theme-light-text-muted dark:placeholder:text-theme-dark-text-muted dark:text-theme-dark-text-light rounded-theme-default outline-theme-accent disabled:cursor-not-allowed disabled:bg-opacity-50"
43 required
44 />
45 </div>
46
47 <textarea
48 name="message"
49 placeholder="Message description"
50 className="p-3 dark:bg-theme-dark-background-secondary bg-theme-light-background-secondary placeholder:text-theme-light-text-muted dark:placeholder:text-theme-dark-text-muted dark:text-theme-dark-text-light rounded-theme-default outline-theme-accent col-span-1 disabled:cursor-not-allowed disabled:bg-opacity-50"
51 // ignore this error - this is available in Chrome 120
52 //@ts-ignore
53 style={{ formSizing: 'content' }}
54 minLength={10}
55 />
56 <Submit className="text-base font-heading max-sm:text-2xl min-w-min sm:self-start text-theme-white bg-theme-accent rounded-md shadow-md shadow-black px-10 text-[clamp(0.925rem,-0.875rem+3vw,1.75rem)] disabled:bg-gray-400 disabled:pointer-events-none disabled:cursor-not-allowed">
57 Send
58 </Submit>
59 <p aria-live="polite" className="sr-only">
60 {state?.error}
61 </p>
62</form>
63 );
64}
65
66export default ContactForm;
ContactForm.tsx
1const initialFormState = {
2 email: '',
3 name: '',
4 message: '',
5 };
ContactForm.tsx
1const [state, formAction] = useFormState(
2 async (prev: any, formData: FormData) => {
3 const res = await sendEmailAction(prev, formData);
4 if (res === true) {
5 formRef.current?.reset();
6 }
7 },
8 initialFormState,
9 );
ContactForm.tsx
1<form
2 className={cn('flex flex-col gap-4 pb-10', className)}
3>
4// ...
5</form>
ContactForm.tsx
1<form
2 className={cn('flex flex-col gap-4 pb-10', className)}
3 action={(formData) => {
4 formAction(formData);
5 }}
6>
7//...
8</form>
utils/sendMail.tsx
1export async function sendEmailAction(prevState: any, formData: FormData) {
2 ('use server');
3 const name = formData.get('name') as string;
4 const email = formData.get('email') as string;
5 const message = formData.get('message') as string;
6 toast;
7
8 if (name === null || email === null || message === null) {
9 return false;
10 }
11
12 try {
13 const response = await nodemailerSendMail({
14 replyTo: email,
15 subject: 'Email from: ' + name + ' (' + email + ')',
16 toEmail: process.env.NODEMAILER_RECIPIENT ?? 'default@example.com',
17 otpText: `${message}`,
18 });
19
20 // check if the response is 2xx and if so return true
21 // see:
22 if ('response' in response && response?.response[0] === '2') {
23 return true;
24 } else {
25 return false;
26 }
27 } catch (e) {
28 console.error(e);
29 return false;
30 }
31}
src/app/layout.tsx
1import 'react-toastify/dist/ReactToastify.css'; // import toastify BEFORE the app styles or it will overwrite custom settings
2import '@/app/globals.css';
3
4import { ToastContainer } from 'react-toastify';
5
6// ....
7
8function RootLayout({ children }: { children: ReactNode }) {
9 const savedThemeCookie = cookies().get('theme');
10 const savedTheme: ColorThemeType = (savedThemeCookie?.value ??
11 'light') as ColorThemeType;
12
13return (
14<MotionPreferencesConfig>
15 <Theme initialTheme={savedTheme}>
16 <html>
17 <body>
18 {/* ... */}
19 <ToastContainer
20 position="bottom-right"
21 autoClose={4000}
22 hideProgressBar={false}
23 newestOnTop
24 closeOnClick
25 rtl={false}
26 pauseOnFocusLoss
27 draggable
28 pauseOnHover
29 toastClassName="bg-theme-light-background-secondary dark:bg-theme-dark-background-secondary"
30 progressClassName={'bg-theme-accent'}
31 />
32 </NextUIThemeProvider>
33 </body>
34 </html>
35 </Theme>
36</MotionPreferencesConfig>
37);
38}
code-from-the-docs.tsx
1 import React from 'react';
2 import { ToastContainer, toast } from 'react-toastify';
3
4 import 'react-toastify/dist/ReactToastify.css';
5 // minified version is also included
6 // import 'react-toastify/dist/ReactToastify.min.css';
7
8 function App(){
9 const notify = () => toast("Wow so easy !");
10
11 return (
12 <div>
13 <button onClick={notify}>Notify !</button>
14 <ToastContainer />
15 </div>
16 );
17 }
1 const toastId = React.useRef<Id | null>(null);
2
3 const sendMailInProgress = () =>
4 (toastId.current = toast('Sending your message. Please wait...', {
5 autoClose: false,
6 }));
1const updateMailSuccess = () =>
2 toastId.current &&
3 toast.update(toastId.current, {
4 render: 'Your message has been sent. Thank you!',
5 type: 'success',
6 autoClose: 5000,
7 });
8
9 const updateMailError = () =>
10 toastId.current &&
11 toast.update(toastId.current, {
12 render: 'Something went wrong. Please try again later.',
13 type: 'error',
14 autoClose: 5000,
15 });
1const formRef = React.useRef<HTMLFormElement | null>(null);
2
3const [state, formAction] = useFormState(
4 async (prev: any, formData: FormData) => {
5 const res = await sendEmailAction(prev, formData);
6 if (res === true) {
7 updateMailSuccess();
8 formRef.current?.reset();
9 } else updateMailError();
10 },
11 initialFormState,
12);
13
14//...
15return (
16 <form
17 className={cn('flex flex-col gap-4 pb-10', className)}
18 action={(formData) => {
19 sendMailInProgress();
20 formAction(formData);
21 }}
22 ref={formRef}
23 >
24 {** ... **/}
25 </form>
26);
1function Submit({
2 className,
3 children,
4}: {
5 className?: string;
6 children: React.ReactNode;
7}) {
8 const status = useFormStatus();
9 return (
10 <button type="submit" disabled={status.pending} className={className}>
11 {children}
12 </button>
13 );
14}
ContactForm.tsx
1'use client';
2import * as React from 'react';
3import { useFormState, useFormStatus } from 'react-dom';
4import { Id, toast } from 'react-toastify';
5
6import { sendEmailAction } from '@/utils/sendMail';
7import { cn } from '@/utils/utils';
8
9type ContactFormData = {
10 email: string;
11 name: string;
12 message: string;
13};
14
15function Submit({
16 className,
17 children,
18}: {
19 className?: string;
20 children: React.ReactNode;
21}) {
22 const status = useFormStatus();
23 return (
24 <button type="submit" disabled={status.pending} className={className}>
25 {children}
26 </button>
27 );
28}
29
30function ContactForm({ className }: { className?: string }) {
31 const initialFormState: ContactFormData = {
32 email: '',
33 name: '',
34 message: '',
35 };
36
37 const toastId = React.useRef<Id | null>(null);
38
39 const formRef = React.useRef<HTMLFormElement | null>(null);
40 const [state, formAction] = useFormState(
41 async (prev: any, formData: FormData) => {
42 const res = await sendEmailAction(prev, formData);
43 if (res === true) {
44 updateMailSuccess();
45 formRef.current?.reset();
46 } else updateMailError();
47 },
48 initialFormState,
49 );
50
51 const sendMailInProgress = () =>
52 (toastId.current = toast('Sending your message. Please wait...', {
53 autoClose: false,
54 }));
55
56 const updateMailSuccess = () =>
57 toastId.current &&
58 toast.update(toastId.current, {
59 render: 'Your message has been sent. Thank you!',
60 type: 'success',
61 autoClose: 5000,
62 });
63
64 const updateMailError = () =>
65 toastId.current &&
66 toast.update(toastId.current, {
67 render: 'Something went wrong. Please try again later.',
68 type: 'error',
69 autoClose: 5000,
70 });
71
72 state?.error ? updateMailError() : null;
73 return (
74 <>
75 <form
76 className={cn('flex flex-col gap-4 pb-10', className)}
77 action={(formData) => {
78 sendMailInProgress();
79 formAction(formData);
80 }}
81 ref={formRef}
82 >
83 <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
84 <input
85 name="name"
86 type="text"
87 placeholder="Name"
88 className="p-3 dark:bg-theme-dark-background-secondary bg-theme-light-background-secondary placeholder:text-theme-light-text-muted dark:placeholder:text-theme-dark-text-muted dark:text-theme-dark-text-light rounded-theme-default outline-theme-accent focus:outline-2 disabled:cursor-not-allowed disabled:bg-opacity-50"
89 required
90 min={3}
91 />
92 <input
93 name="email"
94 type="email"
95 placeholder="Email Address"
96 className="p-3 dark:bg-theme-dark-background-secondary bg-theme-light-background-secondary placeholder:text-theme-light-text-muted dark:placeholder:text-theme-dark-text-muted dark:text-theme-dark-text-light rounded-theme-default outline-theme-accent disabled:cursor-not-allowed disabled:bg-opacity-50"
97 required
98 />
99 </div>
100 <textarea
101 name="message"
102 placeholder="Message description"
103 className="p-3 dark:bg-theme-dark-background-secondary bg-theme-light-background-secondary placeholder:text-theme-light-text-muted dark:placeholder:text-theme-dark-text-muted dark:text-theme-dark-text-light rounded-theme-default outline-theme-accent col-span-1 disabled:cursor-not-allowed disabled:bg-opacity-50"
104 // ignore this error - this is available in Chrome 120
105 //@ts-ignore
106 style={{ formSizing: 'content' }}
107 minLength={10}
108 />
109 <Submit className="text-base font-heading max-sm:text-2xl min-w-min sm:self-start text-theme-white bg-theme-accent rounded-md shadow-md shadow-black px-10 text-[clamp(0.925rem,-0.875rem+3vw,1.75rem)] disabled:bg-gray-400 disabled:pointer-events-none disabled:cursor-not-allowed">
110 Send
111 </Submit>
112 </form>
113 </>
114 );
115}
116
117export default ContactForm;