Next.js App RouterContact forms

Get a text when a new lead comes in.

Someone fills out your contact form. Their inquiry stays in your inbox, and a short alert lands on your phone.

Add it to your form10 free texts a month. No credit card.

From your form to your phone

Interactive preview. No text is sent.

Your website

Let's work together.

Tell us a little about your project.

Edit the sample details, then try it.

Your phone

Messagesnow

SelfPing

New lead: Sam Taylor (sam@example.com). Hi! Could you help us build a new website?

The alert goes to you, not your visitor.

Get your SelfPing key

Sign in with your own phone number, then copy your key from the dashboard. If you don't have a key yet, create one there. Every alert goes to your phone, never the person filling out the form.

Get your free key

Already using SelfPing? Use your existing key.

Keep the key on your server

Add this to .env.local in your project root and replace the placeholder with your key. Restart your dev server after saving.

.env.local
SELFPING_API_KEY=sp_your_api_key

Add the same variable to your hosting environment before deploying. Keep it out of Git and never prefix it with NEXT_PUBLIC_.

Add the notification

Create lib/notify-lead.ts. It sends a short preview after the form response, so a slow or failed alert won't hold up the visitor.

lib/notify-lead.ts
import { after } from "next/server";

type Lead = { name: string; email: string; message: string };

export function notifyLead(lead: Lead) {
  after(async () => {
    try {
      const message = Array.from(
        `New lead: ${lead.name} (${lead.email}). ${lead.message}`
          .replace(/\s+/g, " "),
      ).slice(0, 320).join("");

      const response = await fetch("https://www.selfping.com/api/sms", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${process.env.SELFPING_API_KEY}`,
        },
        body: JSON.stringify({ message }),
        signal: AbortSignal.timeout(10000),
      });

      if (!response.ok) {
        const body = await response.json().catch(() => null);
        console.error("Lead alert failed:", response.status, body?.error?.code);
      }
    } catch {
      console.error("Lead alert could not be sent.");
    }
  });
}

In your existing server-side form handler, call it after the inquiry has been saved or accepted by your email service. Use the values you already validated.

Your existing form handler
import { notifyLead } from "@/lib/notify-lead";

notifyLead({ name, email, message });

Keep the full inquiry in your inbox or CRM.

The text is a preview, capped at 320 characters. Delivery can fail, so keep your existing form storage and spam protection. Each send uses one credit.

Starting without a form?Copy a complete form and server action.

This example uses Resend to email the full inquiry first, then calls the helper from step 3. Use an existing App Router project with the @/ import alias.

Terminal
npm install resend zod

Get a Resend API key and verify your sending domain. Add these variables alongside your SelfPing key, locally and on your host:

.env.local
RESEND_API_KEY=re_your_api_key
LEAD_FROM_EMAIL=Website <leads@your-verified-domain.com>
LEAD_TO_EMAIL=you@example.com

Add these two files. The form reports email failures to the visitor; SMS failures are logged separately. Style the form to match your site and keep your normal spam protection.

app/contact/actions.ts
"use server";

import { Resend } from "resend";
import { z } from "zod";
import { notifyLead } from "@/lib/notify-lead";

export type FormState = { success: boolean; message: string };

const leadSchema = z.object({
  name: z.string().trim().min(1).max(80),
  email: z.string().trim().email().max(254),
  message: z.string().trim().min(1).max(5000),
});

export async function submitLead(
  _previous: FormState,
  formData: FormData,
): Promise<FormState> {
  if (formData.get("website")) {
    return { success: true, message: "Thanks. We'll be in touch." };
  }

  const parsed = leadSchema.safeParse({
    name: formData.get("name"),
    email: formData.get("email"),
    message: formData.get("message"),
  });

  if (!parsed.success) {
    return { success: false, message: "Please check your form details." };
  }

  const { name, email, message } = parsed.data;
  const apiKey = process.env.RESEND_API_KEY;
  const from = process.env.LEAD_FROM_EMAIL;
  const to = process.env.LEAD_TO_EMAIL;

  if (!apiKey || !from || !to) {
    return { success: false, message: "The form is unavailable. Try later." };
  }

  try {
    const { error } = await new Resend(apiKey).emails.send({
      from,
      to,
      replyTo: email,
      subject: "New website inquiry",
      text: `Name: ${name}\nEmail: ${email}\n\n${message}`,
    });

    if (error) {
      return { success: false, message: "Couldn't submit. Please try again." };
    }
  } catch {
    return { success: false, message: "Couldn't submit. Please try again." };
  }

  notifyLead(parsed.data);
  return { success: true, message: "Thanks. We'll be in touch." };
}
app/contact/page.tsx
"use client";

import { useActionState } from "react";
import { submitLead, type FormState } from "./actions";

const initialState: FormState = { success: false, message: "" };

export default function ContactPage() {
  const [state, action, pending] = useActionState(submitLead, initialState);

  return (
    <main>
      <h1>Get in touch</h1>
      <form action={action}>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" required maxLength={80} />

        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" required maxLength={254} />

        <label htmlFor="message">What can we help with?</label>
        <textarea id="message" name="message" required maxLength={5000} />

        <div hidden>
          <label htmlFor="website">Leave this empty</label>
          <input id="website" name="website" tabIndex={-1} autoComplete="off" />
        </div>

        <button type="submit" disabled={pending}>
          {pending ? "Submitting..." : "Send inquiry"}
        </button>
        <p role="status">{state.message}</p>
      </form>
    </main>
  );
}

Try your setup

Submit your own form once with sample details. This sends a real text and uses one credit.

  • The full inquiry reaches your inbox or CRM.
  • The short notification arrives on your phone.
  • Your SelfPing dashboard shows the final delivery status.

A successful API response means the text was accepted for sending. The dashboard tells you whether it was delivered. This helper sends a best-effort alert; it doesn't queue retries.

If the text doesn't arrive

Check your key and balance
Check server logs for the response status. A 401 means check your key. For a 429, check the error code: you may have run out of credits or hit a rate limit.
Check delivery on your dashboard
An accepted text can still fail later. Open your dashboard to see the final status and any delivery problem.
Check the phone number
Your key texts the number you signed in with. There's no recipient field to set, and the visitor's email is just part of the notification.