{"id":408,"date":"2026-09-16T01:21:00","date_gmt":"2026-09-16T06:51:00","guid":{"rendered":"https:\/\/photonconsole.com\/blog\/?p=408"},"modified":"2026-09-16T07:51:30","modified_gmt":"2026-09-16T13:21:30","slug":"sending-email-from-next-js-server-actions-api-routes-and-edge-limits","status":"publish","type":"post","link":"https:\/\/photonconsole.com\/blog\/sending-email-from-next-js-server-actions-api-routes-and-edge-limits\/","title":{"rendered":"Sending Email from Next.js: Server Actions, API Routes and Edge Limits"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">You add Nodemailer to a Next.js project, wire it into a contact form, and the build fails with a module resolution error about <code>net<\/code> or <code>tls<\/code>. Or it builds fine, deploys, and every send times out. Or worse, it works \u2014 and a security scan later shows your SMTP password sitting in the browser bundle.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Next.js makes email harder than a traditional Node server because code can run in three different places: the browser, a Node.js serverless function, or the Edge runtime. Only one of those can open an SMTP connection.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This guide covers where email code belongs in a Next.js app, how to configure the runtime correctly, and how to avoid the credential leak that catches a surprising number of projects.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Quick Answer: How Do You Send Email in Next.js?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Send from a Route Handler or Server Action running on the Node.js runtime, never from a client component:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ app\/api\/send\/route.ts\nimport nodemailer from 'nodemailer';\nimport { NextResponse } from 'next\/server';\n\nexport const runtime = 'nodejs';   \/\/ required \u2014 Edge cannot open SMTP\n\nconst transporter = nodemailer.createTransport({\n  host: process.env.SMTP_HOST,\n  port: Number(process.env.SMTP_PORT),\n  secure: false,\n  auth: {\n    user: process.env.SMTP_USER,\n    pass: process.env.SMTP_PASS,\n  },\n});\n\nexport async function POST(request: Request) {\n  const { email, message } = await request.json();\n\n  await transporter.sendMail({\n    from: process.env.MAIL_FROM,\n    to: 'you@yourdomain.com',\n    replyTo: email,\n    subject: 'New contact form submission',\n    text: message,\n  });\n\n  return NextResponse.json({ ok: true });\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two things decide whether this works: the runtime must be <code>nodejs<\/code>, and the credentials must never carry the <code>NEXT_PUBLIC_<\/code> prefix. An authenticated relay such as <a href=\"https:\/\/www.photonconsole.com\/\">PhotonConsole<\/a> handles the delivery side once the code is in the right place.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why You Cannot Send Email from the Browser<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">SMTP is a raw TCP protocol. Browsers do not permit raw TCP connections \u2014 they speak <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/Fetch_API\" target=\"_blank\" rel=\"noopener\">HTTP<\/a> and WebSockets only. There is no browser API that can open an SMTP session, and no library can work around that.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is a security feature rather than a limitation. If a browser could send SMTP, your mail credentials would have to be present in code the user can read, which means anyone could extract them and send mail as your domain.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So every email path in Next.js has to terminate on the server. The question is only which server context you use.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The Three Places Code Runs in Next.js<\/h2>\n\n\n\n<figure class=\"wp-block-image size-large is-resized\"><img fetchpriority=\"high\" decoding=\"async\" width=\"1024\" height=\"576\" src=\"https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/ChatGPT-Image-Sep-16-2026-01_04_40-PM-1024x576.png\" alt=\"Diagram showing which Next.js execution contexts can open an SMTP connection: browser and Edge cannot, Node.js runtime can\" class=\"wp-image-411\" style=\"aspect-ratio:1.7777777777777777;width:1200px;height:auto\" srcset=\"https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/ChatGPT-Image-Sep-16-2026-01_04_40-PM-1024x576.png 1024w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/ChatGPT-Image-Sep-16-2026-01_04_40-PM-300x169.png 300w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/ChatGPT-Image-Sep-16-2026-01_04_40-PM-768x432.png 768w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/ChatGPT-Image-Sep-16-2026-01_04_40-PM-1536x864.png 1536w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/ChatGPT-Image-Sep-16-2026-01_04_40-PM.png 1672w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\">Only the Node.js runtime can open an SMTP connection. The browser and Edge runtime cannot.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Understanding this table prevents most Next.js email problems.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Context<\/th><th>Can open SMTP?<\/th><th>Use for email?<\/th><\/tr><\/thead><tbody><tr><td>Client component (browser)<\/td><td>No \u2014 no raw TCP<\/td><td>Never. Credentials would be exposed<\/td><\/tr><tr><td>Edge runtime<\/td><td>No \u2014 no Node <code>net<\/code> or <code>tls<\/code> modules<\/td><td>Only via an HTTP email API<\/td><\/tr><tr><td>Node.js runtime<\/td><td>Yes<\/td><td>Yes \u2014 this is where SMTP belongs<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/nextjs.org\/docs\" target=\"_blank\" rel=\"noopener\">Next.js documentation<\/a> covers runtime selection in detail. The practical rule is simple: anything touching Nodemailer needs <code>export const runtime = 'nodejs'<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why Next.js Email Fails<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Most failures come from the runtime, the environment variables, or the serverless execution model rather than from the mail code itself.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">1. The Route Is Running on the Edge<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Edge functions run in a restricted JavaScript environment without Node&#8217;s <a href=\"https:\/\/nodejs.org\/api\/net.html\" target=\"_blank\" rel=\"noopener\"><code>net<\/code><\/a> and <code>tls<\/code> modules. <a href=\"https:\/\/nodemailer.com\/smtp\" target=\"_blank\" rel=\"noopener\">Nodemailer<\/a> depends on both. You will see build errors about unresolved modules, or runtime errors about missing Node APIs.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Setting <code>export const runtime = 'nodejs'<\/code> in the route file fixes this.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">2. Credentials Are Prefixed with NEXT_PUBLIC_<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Any environment variable starting with <code>NEXT_PUBLIC_<\/code> is inlined into the JavaScript bundle sent to the browser. Naming your SMTP password <code>NEXT_PUBLIC_SMTP_PASS<\/code> publishes it to every visitor.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This happens because developers hit an &#8220;undefined variable&#8221; error in a client component and add the prefix to make the error go away. It does make the error go away. It also leaks the credential.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">3. Nodemailer Is Imported into a Client Component<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Importing a server-only library in a component that runs in the browser produces bundling errors. Keep mail code in Route Handlers, Server Actions, or files marked <code>'use server'<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">4. The Function Times Out Before the Send Completes<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Serverless functions have execution time limits that vary by platform and plan. An SMTP handshake on a cold start can take several seconds, and a slow mail server can push past the limit. The request fails with a platform timeout rather than a mail error, which makes it look like a Next.js fault.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">5. No Authentication Records on the Sending Domain<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Without SPF and DKIM, receiving servers cannot verify your application is authorised to send as your domain, in line with <a href=\"https:\/\/support.google.com\/mail\/answer\/81126\" target=\"_blank\" rel=\"noopener\">Google&#8217;s sender guidelines<\/a>. Our guide to <a href=\"https:\/\/photonconsole.com\/blog\/spf-dkim-dmarc-explained-simply\/\">SPF, DKIM and DMARC<\/a> covers the records, and the free <a href=\"https:\/\/www.photonconsole.com\/email-deliverability-checker.php\">email deliverability checker<\/a> shows what your domain publishes today.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Common Mistake<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Adding <code>NEXT_PUBLIC_<\/code> to an SMTP variable to silence an undefined error. Everything with that prefix is compiled into the browser bundle and is readable by anyone who opens developer tools. If you have ever deployed with <code>NEXT_PUBLIC_SMTP_PASS<\/code> or similar, treat that credential as compromised, rotate it, and move the send into a Route Handler or Server Action where server-only variables are available.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Quick Fix<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Module Not Found: net, tls or dns<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Add <code>export const runtime = 'nodejs'<\/code> to the route file<\/li>\n\n\n\n<li>Confirm the file is a Route Handler or Server Action, not a client component<\/li>\n\n\n\n<li>Check the file has no <code>'use client'<\/code> directive at the top<\/li>\n\n\n\n<li>Move the Nodemailer import out of any shared file that a client component also imports<\/li>\n\n\n\n<li>For Middleware, which is always Edge, use an HTTP email API instead of SMTP<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Method 1: Route Handler (App Router)<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The most common approach. Create <code>app\/api\/send\/route.ts<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import nodemailer from 'nodemailer';\nimport { NextResponse } from 'next\/server';\n\nexport const runtime = 'nodejs';\n\nconst transporter = nodemailer.createTransport({\n  host: process.env.SMTP_HOST,\n  port: Number(process.env.SMTP_PORT ?? 587),\n  secure: false,\n  auth: {\n    user: process.env.SMTP_USER,\n    pass: process.env.SMTP_PASS,\n  },\n  connectionTimeout: 8000,\n  socketTimeout: 8000,\n});\n\nexport async function POST(request: Request) {\n  try {\n    const { name, email, message } = await request.json();\n\n    if (!email || !message) {\n      return NextResponse.json(\n        { error: 'Missing required fields' },\n        { status: 400 }\n      );\n    }\n\n    await transporter.sendMail({\n      from: process.env.MAIL_FROM,\n      to: process.env.CONTACT_INBOX,\n      replyTo: email,\n      subject: `New enquiry from ${name ?? 'website'}`,\n      text: message,\n    });\n\n    return NextResponse.json({ ok: true });\n  } catch (err) {\n    console.error('Send failed:', err);\n    return NextResponse.json(\n      { error: 'Unable to send message' },\n      { status: 500 }\n    );\n  }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Note the error handling. Never return the raw exception to the client \u2014 it can contain your host name and account details.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Method 2: Server Action<\/h2>\n\n\n\n<figure class=\"wp-block-image size-large is-resized\"><img decoding=\"async\" width=\"1024\" height=\"576\" src=\"https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/ChatGPT-Image-Sep-16-2026-01_06_57-PM-1024x576.png\" alt=\"Next.js server action email flow from browser form submission through the server boundary to an SMTP relay\" class=\"wp-image-412\" style=\"aspect-ratio:1.7777777777777777;width:1200px;height:auto\" srcset=\"https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/ChatGPT-Image-Sep-16-2026-01_06_57-PM-1024x576.png 1024w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/ChatGPT-Image-Sep-16-2026-01_06_57-PM-300x169.png 300w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/ChatGPT-Image-Sep-16-2026-01_06_57-PM-768x432.png 768w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/ChatGPT-Image-Sep-16-2026-01_06_57-PM-1536x864.png 1536w, https:\/\/photonconsole.com\/blog\/wp-content\/uploads\/2026\/09\/ChatGPT-Image-Sep-16-2026-01_06_57-PM.png 1672w\" sizes=\"(max-width: 1024px) 100vw, 1024px\" \/><figcaption class=\"wp-element-caption\">Credentials stay behind the server boundary. Only the form data crosses it.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Server Actions remove the need for a separate API route and give progressive enhancement for free.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ app\/actions\/send-contact.ts\n'use server';\n\nimport nodemailer from 'nodemailer';\n\nconst transporter = nodemailer.createTransport({\n  host: process.env.SMTP_HOST,\n  port: Number(process.env.SMTP_PORT ?? 587),\n  secure: false,\n  auth: {\n    user: process.env.SMTP_USER,\n    pass: process.env.SMTP_PASS,\n  },\n});\n\nexport async function sendContact(formData: FormData) {\n  const email = String(formData.get('email') ?? '');\n  const message = String(formData.get('message') ?? '');\n\n  if (!email || !message) {\n    return { ok: false, error: 'Missing required fields' };\n  }\n\n  try {\n    await transporter.sendMail({\n      from: process.env.MAIL_FROM,\n      to: process.env.CONTACT_INBOX,\n      replyTo: email,\n      subject: 'New contact form submission',\n      text: message,\n    });\n    return { ok: true };\n  } catch {\n    return { ok: false, error: 'Unable to send message' };\n  }\n}<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ app\/contact\/page.tsx\nimport { sendContact } from '..\/actions\/send-contact';\n\nexport default function ContactPage() {\n  return (\n    &lt;form action={sendContact}&gt;\n      &lt;input name=\"email\" type=\"email\" required \/&gt;\n      &lt;textarea name=\"message\" required \/&gt;\n      &lt;button type=\"submit\"&gt;Send&lt;\/button&gt;\n    &lt;\/form&gt;\n  );\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The page itself stays a Server Component. The action file carries <code>'use server'<\/code>, so Nodemailer never reaches the browser bundle.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Environment Variables Done Correctly<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code># .env.local \u2014 never commit this file\n\n# Server-only. No NEXT_PUBLIC_ prefix.\nSMTP_HOST=smtp.photonrelay.com\nSMTP_PORT=587\nSMTP_USER=your_project_api_user\nSMTP_PASS=your_secret_api_key\nMAIL_FROM=\"Your App &lt;noreply@yourdomain.com&gt;\"\nCONTACT_INBOX=hello@yourdomain.com<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Server-only variables are available in Route Handlers, Server Actions and Server Components. They are not available in client components, and that restriction is the point rather than an obstacle.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Note<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Environment variables must also be set in your hosting platform&#8217;s dashboard, not only in <code>.env.local<\/code>. A local file is not deployed. Missing variables in production produce an authentication failure that looks identical to wrong credentials, which is why the same code works locally and fails once deployed.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Serverless Constraints Worth Planning For<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A Next.js app on a serverless platform behaves differently from a long-running Node server.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Connection pooling gives little benefit.<\/strong> The container is often destroyed after the request, so a pooled connection has nothing to be reused by. Set <code>pool: false<\/code> and rely on short-lived connections instead.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Every invocation may pay the full handshake cost.<\/strong> A cold start plus TCP and TLS negotiation can take several seconds before the message is even accepted.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Execution time is capped.<\/strong> Limits vary by platform and plan, so check your provider&#8217;s current documentation and keep your Nodemailer timeouts comfortably below whatever that limit is. Setting <code>connectionTimeout<\/code> and <code>socketTimeout<\/code> to around 8 seconds gives the function room to return a clean error rather than being killed mid-send.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Heavy sending belongs in a queue.<\/strong> If a single request needs to send more than one or two messages, push the work to a background job rather than holding the request open. Our guide to <a href=\"https:\/\/photonconsole.com\/blog\/transactional-email-queue-architecture-explained\/\">transactional email queue architecture<\/a> covers the pattern.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Quick Fix<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Works Locally, Fails After Deploy<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Confirm every SMTP variable is set in the hosting dashboard, not just <code>.env.local<\/code><\/li>\n\n\n\n<li>Check the deployed route is using the Node.js runtime, not Edge<\/li>\n\n\n\n<li>Try port 2525 if the platform blocks 587 outbound<\/li>\n\n\n\n<li>Lower Nodemailer timeouts below the platform function timeout<\/li>\n\n\n\n<li>Log <code>err.code<\/code> server side \u2014 <code>EAUTH<\/code> means credentials, <code>ETIMEDOUT<\/code> means network<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Port Reference<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Port<\/th><th>secure value<\/th><th>When to use<\/th><\/tr><\/thead><tbody><tr><td>587<\/td><td><code>false<\/code> (STARTTLS)<\/td><td>Recommended default<\/td><\/tr><tr><td>465<\/td><td><code>true<\/code> (implicit SSL)<\/td><td>When the provider requires it<\/td><\/tr><tr><td>2525<\/td><td><code>false<\/code><\/td><td>When the platform blocks 587<\/td><\/tr><tr><td>25<\/td><td>n\/a<\/td><td>Avoid \u2014 blocked on nearly all hosts<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Configuring Next.js With PhotonConsole<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Two steps: authenticate the domain in DNS, then set the environment variables.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>TXT    @                    v=spf1 include:relay.photonconsole.com ~all\nCNAME  photon._domainkey    dkim.photonconsole.com<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-code\"><code>SMTP_HOST=smtp.photonrelay.com\nSMTP_PORT=587          # 465 for implicit SSL, 2525 if 587 is blocked\nSMTP_USER=your_project_api_user\nSMTP_PASS=your_secret_api_key\nMAIL_FROM=\"Your App &lt;noreply@yourdomain.com&gt;\"<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Port 2525 matters here specifically because serverless platforms sometimes restrict outbound ports, which resolves the deploy-time failures described above. Every PhotonConsole account includes 5,000 free emails per month, enough to validate the full path \u2014 runtime configuration, environment variables and DNS \u2014 before any spend. Details are on the <a href=\"https:\/\/www.photonconsole.com\/relay.php\">PhotonRelay page<\/a>, and <a href=\"https:\/\/www.photonconsole.com\/pricing.php\">pricing<\/a> has no monthly minimum.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you are comparing options first, our analysis of <a href=\"https:\/\/photonconsole.com\/blog\/free-smtp-servers\/\">free SMTP servers<\/a> covers where each stops being practical.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">When to Use an HTTP Email API Instead<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">SMTP is the right default for most Next.js apps, but an HTTP API is the better fit in two cases.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Edge runtime.<\/strong> If the send has to happen in Middleware or an Edge function, SMTP is impossible. An HTTP request works anywhere <code>fetch<\/code> is available.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Very short function limits.<\/strong> A single HTTP POST completes faster than an SMTP conversation, which matters when the execution ceiling is tight.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Both approaches reach the same delivery infrastructure. Our <a href=\"https:\/\/photonconsole.com\/blog\/email-api-integration\/\">email API integration guide<\/a> covers the HTTP approach in more depth.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Pro Tips<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Add rate limiting to public forms.<\/strong> An unprotected contact endpoint becomes a spam relay within days of going live.<\/li>\n\n\n\n<li><strong>Use replyTo, not from, for the visitor&#8217;s address.<\/strong> Sending as the visitor&#8217;s domain fails SPF and DMARC. Send from your own address and put theirs in <code>replyTo<\/code>.<\/li>\n\n\n\n<li><strong>Never return raw errors to the client.<\/strong> Exception messages can expose your SMTP host and account name.<\/li>\n\n\n\n<li><strong>Validate on the server too.<\/strong> Client-side validation is a convenience, not a control \u2014 anyone can post directly to the route.<\/li>\n\n\n\n<li><strong>Verify DNS after any change.<\/strong> <a href=\"https:\/\/mxtoolbox.com\/\" target=\"_blank\" rel=\"noopener\">MXToolbox<\/a> checks SPF, DKIM and blocklist status in one lookup.<\/li>\n\n\n\n<li><strong>Score a real send before launch.<\/strong> <a href=\"https:\/\/www.mail-tester.com\/\" target=\"_blank\" rel=\"noopener\">Mail Tester<\/a> flags authentication problems before users hit them.<\/li>\n\n\n\n<li><strong>Keep the transporter at module scope.<\/strong> Creating it inside the handler adds avoidable work on every invocation.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Related Issues You May Hit Next<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/photonconsole.com\/blog\/how-to-send-emails-in-node-js-with-nodemailer-a-production-setup-guide\/\">Nodemailer production setup<\/a> for the underlying transport configuration<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/smtp-authentication-error\/\">SMTP authentication errors<\/a> when credentials are rejected<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/smtp-connection-timeout\/\">SMTP connection timeouts<\/a> when the connection hangs<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/why-emails-go-to-spam-in-gmail\/\">Emails landing in Gmail spam<\/a> despite a successful send<\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/emails-sent-but-not-delivered\/\">Emails sent but not delivered<\/a> when the server reports success<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Frequently Asked Questions<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Can I use Nodemailer in Next.js?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes, in Route Handlers, Server Actions or Server Components running on the Node.js runtime. It cannot run in client components or on the Edge runtime, because both lack the Node networking modules it depends on.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why do I get &#8220;Module not found: can&#8217;t resolve net&#8221;?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The code is being bundled for the browser or the Edge runtime. Add <code>export const runtime = 'nodejs'<\/code> and make sure the file is not imported by a client component.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Should I use a Route Handler or a Server Action?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Server Actions are simpler for form submissions within your own app. Route Handlers are better when an external service or a separate client needs to call the endpoint. Both run on the server.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Is it safe to put SMTP credentials in .env.local?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes, provided the variable names do not begin with <code>NEXT_PUBLIC_<\/code> and the file is excluded from version control. Server-only variables are never sent to the browser.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why does email work locally but not on Vercel?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Usually missing environment variables in the platform dashboard, or a route defaulting to the Edge runtime in production. Check both before investigating the mail server.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How do I send email from Middleware?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">You cannot use SMTP there, because Middleware always runs on the Edge. Either call an HTTP email API, or have Middleware trigger a Node.js route that performs the send.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Do I still need SPF and DKIM with a relay?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes. The relay delivers the message, but the DNS records prove your domain authorised it. Without them, delivery is far less reliable regardless of which relay you use.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Conclusion<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Next.js email problems are nearly always placement problems rather than mail problems. The code has to run on the Node.js runtime, the credentials have to stay server-side, and the timeouts have to fit inside the platform&#8217;s execution limit.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Get those three right and Nodemailer behaves exactly as it does on any Node server. Get the second one wrong and you publish your SMTP password to every visitor, which is worth checking in any existing project today.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Once the code is in the right place, what remains is delivery: whether the platform permits outbound SMTP, and whether receiving providers trust your domain. A dedicated <a href=\"https:\/\/www.photonconsole.com\/relay.php\">transactional email solution<\/a> handles authentication, routing and reputation, using the same configuration shown above. Developers working across stacks may also want our guides to <a href=\"https:\/\/photonconsole.com\/blog\/sending-email-in-python-smtplib-vs-an-email-api-with-working-code\/\">sending email in Python<\/a> and <a href=\"https:\/\/photonconsole.com\/blog\/how-to-send-emails-in-node-js-with-nodemailer-a-production-setup-guide\/\">sending email in Node.js<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Read More<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/www.photonconsole.com\/email-deliverability-checker.php\">Free Email Deliverability Checker<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/how-to-send-emails-in-node-js-with-nodemailer-a-production-setup-guide\/\">Sending Email in Node.js with Nodemailer<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/email-api-integration\/\">Email API Integration Guide<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/transactional-email-queue-architecture-explained\/\">Transactional Email Queue Architecture Explained<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/photonconsole.com\/blog\/spf-dkim-dmarc-explained-simply\/\">SPF, DKIM and DMARC Explained Simply<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/www.photonconsole.com\/relay.php\">PhotonRelay: SMTP Relay Service<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/www.photonconsole.com\/pricing.php\">PhotonConsole Pricing<\/a><\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>You add Nodemailer to a Next.js project, wire it into a contact form, and the build fails with a module resolution error about net or tls. Or it builds fine, deploys, and every send times out. Or worse, it works \u2014 and a security scan later shows your SMTP password sitting in the browser bundle. [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":410,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[312],"tags":[532,530,531],"class_list":["post-408","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-email-engineering-guide","tag-and-how-to-keep-credentials-off-the-client","tag-nodemailer-failing-in-next-js-with-a-net-or-tls-error-here-is-where-email-code-belongs","tag-which-runtime-to-use"],"_links":{"self":[{"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/posts\/408","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/comments?post=408"}],"version-history":[{"count":1,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/posts\/408\/revisions"}],"predecessor-version":[{"id":413,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/posts\/408\/revisions\/413"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/media\/410"}],"wp:attachment":[{"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/media?parent=408"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/categories?post=408"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/photonconsole.com\/blog\/wp-json\/wp\/v2\/tags?post=408"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}