---
title: "Use with Next.js"
description: "Send SMS from Next.js route handlers and server actions, and receive verified provider webhooks in the App Router."
---

This guide sends SMS from a Next.js App Router app and receives delivery and inbound webhooks. SMS SDK runs in route handlers and server actions on the Node.js runtime, never in a client component. See [Installation](/getting-started/installation) for credentials and supported runtimes.

## 1. Create the client in a server-only module

```ts lib/sms.ts
import { createSmsClient, isE164 } from "@opencoredev/sms-sdk";
import { telnyx } from "@opencoredev/sms-sdk/telnyx";

const from = process.env.TELNYX_FROM;
if (!isE164(from)) {
  throw new Error("Set TELNYX_FROM to an E.164 number.");
}

export const sms = createSmsClient({
  adapters: [telnyx({ apiKey: process.env.TELNYX_API_KEY ?? "", from })],
});
```

Put `TELNYX_API_KEY`, `TELNYX_FROM`, and `TELNYX_PUBLIC_KEY` in `.env.local` and in your host's environment settings. Never prefix them with `NEXT_PUBLIC_`, which ships them to the browser.

With the `server-only` package installed, add `import "server-only";` at the top of this file so a client import fails the build.

## 2. Send from a route handler

```ts app/api/orders/[id]/shipped/route.ts
import { HandoffUnknownError, isE164, isSmsError } from "@opencoredev/sms-sdk";
import { sms } from "./sms";

export const runtime = "nodejs";

export async function POST(request: Request, context: { params: Promise<{ id: string }> }): Promise<Response> {
  const { id } = await context.params;
  const body: unknown = await request.json();
  const phone = typeof body === "object" && body !== null && "phone" in body ? body.phone : undefined;
  if (typeof phone !== "string" || !isE164(phone)) {
    return Response.json({ error: "phone must be an E.164 number" }, { status: 400 });
  }

  try {
    const result = await sms.send({ to: phone, body: `Order ${id} has shipped.`, idempotencyKey: `order:${id}:shipped:v1` });
    return Response.json({ providerId: result.providerId });
  } catch (error) {
    if (error instanceof HandoffUnknownError) {
      return Response.json({ status: "unknown", message: "Check the provider before retrying." }, { status: 202 });
    }
    if (isSmsError(error)) {
      return Response.json(error.toJSON(), { status: error.retrySafe ? 422 : 500 });
    }
    throw error;
  }
}
```

In your project, import from `@/lib/sms` or your own alias instead of `./sms`. A `POST` with `{ "phone": "+14155550123" }` returns the provider's message ID, and a rejection returns the redacted `error.toJSON()`. `HandoffUnknownError` means the provider may or may not have the message. Read [Idempotency](/sending/idempotency) before you retry one.

## 3. Send from a server action

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

import { isE164 } from "@opencoredev/sms-sdk";
import { sms } from "./sms";

export async function sendReminder(formData: FormData): Promise<{ ok: boolean; message: string }> {
  const phone = formData.get("phone");
  if (typeof phone !== "string" || !isE164(phone)) {
    return { ok: false, message: "Enter the number with its country code, such as +14155550123." };
  }
  await sms.send({ to: phone, body: "Reminder: your appointment is tomorrow at 9:30." });
  return { ok: true, message: "Reminder sent." };
}
```

## 4. Receive webhooks

```ts app/api/webhooks/sms/telnyx/route.ts
import { parseSmsWebhook, WebhookSignatureError } from "@opencoredev/sms-sdk/webhooks";
import { db } from "./db";

export const runtime = "nodejs";

export async function POST(request: Request): Promise<Response> {
  try {
    const event = await parseSmsWebhook({
      provider: "telnyx",
      request,
      credentials: { publicKey: process.env.TELNYX_PUBLIC_KEY ?? "" },
    });
    if (!(await db.webhookEvents.insertIfNew(event.dedupeKey))) {
      return new Response(null, { status: 200 });
    }
    if (event.type === "message.delivered" || event.type === "message.undelivered") {
      await db.messages.updateStatus(event.providerId, event.type, event.errorCode);
    } else if (event.type === "recipient.opted_out") {
      await db.suppressions.add(event.from);
    }
    return new Response(null, { status: 200 });
  } catch (error) {
    if (error instanceof WebhookSignatureError) {
      return new Response("Invalid signature", { status: 401 });
    }
    throw error;
  }
}
```

`parseSmsWebhook()` needs the unread body, which route handlers provide. Do not call `request.json()` or `request.text()` before it.

Twilio and Plivo sign the URL, and on Vercel and most hosts your handler sees a different URL from the public one. For those two providers, pass `publicUrl` with the exact URL from the provider console, such as `https://example.com/api/webhooks/sms/twilio`. See [Webhook security](/receiving/webhook-security).

Point the provider console at your deployed route, such as `https://example.com/api/webhooks/sms/telnyx`. In local development, expose `localhost:3000` with a tunnel and use the tunnel's HTTPS URL as `publicUrl`.

## Runtime and caching notes

- Set `export const runtime = "nodejs"`. Edge runtimes have the needed APIs, but SMS SDK is not tested on them.
- Next.js does not cache `POST` route handlers, so every request runs.
- `memoryIdempotencyStore()` does not survive across serverless invocations. Use a [shared store](/guides/multiple-instances) in production.
- The platform can stop a serverless function right after the response. Store webhook events before you respond, or use your platform's background task API.

## Next steps

- [Delivery status webhooks](/receiving/delivery-status-webhooks)
- [Deploy with multiple instances](/guides/multiple-instances)
