Send transactional email from Next.js

Add password resets, receipts, and other operational email to a Next.js app with the Loops SDK. The examples run on the server and cover contact sync, environment variables, and password reset sends.

Who this is for

You have a Next.js app that needs to send operational email: password resets, verifications, receipts. And when you want marketing and lifecycle email later, you won’t need a second tool. See how transactional email works on Loops.

Give this to your agent

Using Claude Code, Cursor, or Codex? Use this prompt to start the integration, then review and test the result.

Install Loops (loops.so) in this Next.js app: npm install loops, create
lib/loops.ts exporting a server-only LoopsClient reading LOOPS_API_KEY,
upsert a contact on signup with updateContact, and add a password-reset
endpoint using sendTransactionalEmail with a transactionalId from the
Loops dashboard. Docs: https://loops.so/docs/sdks/javascript and
https://loops.so/docs/transactional/guide

Install the SDK

npm install loops

The npm package is loops, even though the GitHub repository is named loops-js. Then create a small server-only client:

// lib/loops.ts (server side only)
import { LoopsClient } from "loops";

let client: LoopsClient | null = null;

export function getLoops(): LoopsClient {
  if (!process.env.LOOPS_API_KEY) {
    throw new Error("LOOPS_API_KEY is not set");
  }
  client ??= new LoopsClient(process.env.LOOPS_API_KEY);
  return client;
}

Sync a contact only after marketing consent

updateContact is an upsert: it creates a contact when no match exists and updates the existing contact otherwise. That prevents duplicate contacts, but it does not collect marketing consent. Run this sync only when the product signup flow has a valid reason to create an Audience contact, and do not set subscribed to true on every request because that can overwrite an existing unsubscribe.

// pages/api/signup.ts
import { getLoops } from "../../lib/loops";

// Run only after the signup flow has collected marketing consent.
// Omitting subscribed preserves an existing contact's preference.
await getLoops().updateContact({
  email,
  properties: { source: "signup", signedUpAt: new Date().toISOString() },
});

Important: as of September 2, 2026, Loops double opt-in gates Form endpoints, not Create contact or Update contact API calls. If confirmation is required, use the Loops form flow for the opt-in step instead of treating this server-side sync as confirmation.

Send the password reset

First create a transactional email in the Loops dashboard with a {resetUrl} data variable and publish it. The transactionalId is on the Publish page under API details.

// pages/api/password-reset.ts
import { getLoops } from "../../lib/loops";

await getLoops().sendTransactionalEmail({
  transactionalId: process.env.LOOPS_PASSWORD_RESET_TRANSACTIONAL_ID!,
  email,
  dataVariables: { resetUrl },
});

Return the same generic 200 whether or not the account exists, so the endpoint can’t be used to discover which emails have accounts.

Environment

# .env.local (server side only, no NEXT_PUBLIC_ prefix)
LOOPS_API_KEY=                          # Settings -> API
LOOPS_PASSWORD_RESET_TRANSACTIONAL_ID=  # the template's Publish page
  • Contacts are keyed by email, or by userId if you prefer.

  • properties accepts string, number, boolean, and null values. Create custom properties such as signedUpAt in Loops before sending them.

  • dataVariables keys are case sensitive: letters, numbers, underscores, and dashes.

Test it

  1. Run npm run dev, sign up with your own email, and confirm the contact appears in your Loops audience.

  2. POST to /api/password-reset with your email and check the inbox.

  3. Run npx tsc --noEmit and make sure it passes.

Common failure modes

  • Wrong package installed. Use the official loops package and check the package name in package.json.

  • Calling from the client. The API key must never reach browser code. Keep every call in API routes or server components.

  • Unpublished template. sendTransactionalEmail needs a published template. Drafts don’t send.

  • Data variable case mismatch. {resetUrl} and {resetURL} are different variables.

  • Rate limits. The API allows 10 requests per second per team. The SDK throws RateLimitExceededError, so catch it if you send in bursts.

Keep going

Trigger onboarding sequences and trial nudges from product events with sendEvent, or point your coding agent at the Loops agents page. For the bigger picture, see transactional email on Loops.

Common questions

Is the Loops API safe to call from the browser?

Why isn’t my transactional email sending?

Does updateContact create duplicate contacts?

Does updateContact trigger double opt-in?