Skip to content

Commit 9e7cdba

Browse files
authored
Add Twilio SMS verification example (#186)
* Add Twilio SMS verification example - Next.js OTP flow (enter phone -> verify code -> dashboard) using the official Twilio SDK against the embedded Twilio Verify emulator - Custom SDK request client rewrites Twilio product hosts (api/verify/messaging) onto the emulator's path prefixes via TWILIO_BASE_URL - No real SMS sent; uses seeded account/Verify Service defaults (code 123456) with a link to the Twilio inspector * Fix Twilio example programmatic verification * Fix Twilio example URL query preservation
1 parent b622f60 commit 9e7cdba

25 files changed

Lines changed: 875 additions & 0 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/node_modules
2+
/.next/
3+
/out/
4+
/build
5+
.DS_Store
6+
*.pem
7+
npm-debug.log*
8+
yarn-debug.log*
9+
yarn-error.log*
10+
.pnpm-debug.log*
11+
.env*
12+
.vercel
13+
*.tsbuildinfo
14+
next-env.d.ts
15+
.emulate/
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# SMS Verification with Twilio
2+
3+
A Next.js app demonstrating phone number verification (SMS OTP) using the [Twilio Verify](https://www.twilio.com/docs/verify) API, powered by the emulated Twilio service from `emulate`.
4+
5+
No real SMS is sent. The emulator runs the Verify flow entirely in-memory, so you can complete the verification with the seeded code or inspect state through the inspector UI.
6+
7+
## How it works
8+
9+
1. User enters their phone number on the home page
10+
2. The server starts a verification via the official Twilio SDK: `verify.v2.services(SID).verifications.create({ to, channel: "sms" })`
11+
3. The user is redirected to a verification page
12+
4. The user enters the code and the server checks it: `verify.v2.services(SID).verificationChecks.create({ to, code })`
13+
5. On success, a session cookie is set and the user lands on the dashboard
14+
15+
The seeded Verify Service accepts the code `123456` for every verification, so the demo can be completed without reading an SMS.
16+
17+
## Pointing the SDK at the emulator
18+
19+
The official Twilio SDK builds absolute URLs against product hosts like `api.twilio.com` and `verify.twilio.com`. `src/lib/twilio.ts` installs a custom request client that rewrites those hosts onto the embedded emulator, which mounts each product under a path prefix:
20+
21+
| Real Twilio URL | Emulator URL |
22+
| ---------------------------------------- | --------------------------------------------------- |
23+
| `https://api.twilio.com/2010-04-01/...` | `http://localhost:3000/emulate/twilio/2010-04-01/...` |
24+
| `https://verify.twilio.com/v2/...` | `http://localhost:3000/emulate/twilio/verify/v2/...` |
25+
| `https://messaging.twilio.com/v1/...` | `http://localhost:3000/emulate/twilio/messaging/v1/...` |
26+
27+
The base URL is set via `TWILIO_BASE_URL` in `next.config.ts`.
28+
29+
## Getting started
30+
31+
From the repository root:
32+
33+
```bash
34+
pnpm install
35+
pnpm --filter twilio-sms-verification dev
36+
```
37+
38+
Open [http://localhost:3000](http://localhost:3000).
39+
40+
## Inspecting verifications
41+
42+
### Inspector UI
43+
44+
Visit [http://localhost:3000/emulate/twilio/?tab=verify](http://localhost:3000/emulate/twilio/?tab=verify) to browse Verify services and verifications (including the issued code) in a web interface.
45+
46+
### Fetching the code programmatically
47+
48+
This is useful in tests or agent workflows where you need to complete the flow without a human reading the SMS:
49+
50+
```bash
51+
# Start a verification through the example API route (sets the pending_verification cookie)
52+
curl -s -c cookies.txt -X POST http://localhost:3000/api/verification/start \
53+
--data-urlencode "phone=+15555550123"
54+
55+
# Fetch the latest local code for that number
56+
curl -s -G http://localhost:3000/emulate/twilio/_twilio/simulate/verification-code \
57+
--data-urlencode "To=+15555550123" \
58+
--data-urlencode "ServiceSid=VA00000000000000000000000000000000" \
59+
-u "AC00000000000000000000000000000000:twilio_test_auth_token" | jq -r '.code'
60+
```
61+
62+
## Seeded defaults
63+
64+
The Twilio emulator boots with a ready-to-use account and Verify Service:
65+
66+
```text
67+
TWILIO_ACCOUNT_SID=AC00000000000000000000000000000000
68+
TWILIO_AUTH_TOKEN=twilio_test_auth_token
69+
TWILIO_VERIFY_SERVICE_SID=VA00000000000000000000000000000000
70+
Seeded Verify code=123456
71+
```
72+
73+
## Project structure
74+
75+
```
76+
src/
77+
app/
78+
page.tsx Phone entry form
79+
phone-form.tsx Client component for phone input
80+
actions.ts Server actions (send code, check code, sign out)
81+
verify/
82+
page.tsx Verification page (enter code)
83+
verify-form.tsx Client component for code input
84+
dashboard/
85+
page.tsx Verified landing page
86+
api/
87+
verification/start/
88+
route.ts Programmatic send-code route for tests and agents
89+
emulate/
90+
[...path]/route.ts Embedded emulator (Twilio)
91+
lib/
92+
twilio.ts Twilio SDK client + request client that targets the emulator
93+
session.ts Cookie-based session helpers
94+
verification.ts Shared verification starter
95+
```
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { defineConfig, globalIgnores } from "eslint/config";
2+
import nextVitals from "eslint-config-next/core-web-vitals";
3+
import nextTs from "eslint-config-next/typescript";
4+
5+
const eslintConfig = defineConfig([
6+
...nextVitals,
7+
...nextTs,
8+
globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
9+
]);
10+
11+
export default eslintConfig;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { resolve } from "path";
2+
import type { NextConfig } from "next";
3+
import { withEmulate } from "@emulators/adapter-next";
4+
5+
const port = process.env.PORT ?? "3000";
6+
7+
const nextConfig: NextConfig = {
8+
turbopack: {
9+
root: resolve(import.meta.dirname, "../.."),
10+
},
11+
env: {
12+
// Point the official Twilio SDK at the embedded emulator. The custom request
13+
// client in src/lib/twilio.ts rewrites Twilio product hosts to this base URL.
14+
TWILIO_BASE_URL: `http://localhost:${port}/emulate/twilio`,
15+
},
16+
};
17+
18+
export default withEmulate(nextConfig);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"name": "twilio-sms-verification",
3+
"version": "0.1.0",
4+
"private": true,
5+
"scripts": {
6+
"dev": "next dev",
7+
"build": "next build",
8+
"start": "next start",
9+
"lint": "eslint"
10+
},
11+
"dependencies": {
12+
"@base-ui/react": "^1.3.0",
13+
"@emulators/adapter-next": "workspace:*",
14+
"@emulators/twilio": "workspace:*",
15+
"class-variance-authority": "^0.7.1",
16+
"clsx": "^2.1.1",
17+
"lucide-react": "^0.577.0",
18+
"next": "16.2.0",
19+
"twilio": "^6.0.2",
20+
"react": "19.2.4",
21+
"react-dom": "19.2.4",
22+
"tailwind-merge": "^3.5.0",
23+
"tw-animate-css": "^1.4.0"
24+
},
25+
"devDependencies": {
26+
"@tailwindcss/postcss": "^4",
27+
"@types/node": "^20",
28+
"@types/react": "^19",
29+
"@types/react-dom": "^19",
30+
"eslint": "^9",
31+
"eslint-config-next": "16.2.0",
32+
"tailwindcss": "^4",
33+
"typescript": "^5"
34+
}
35+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
const config = {
2+
plugins: {
3+
"@tailwindcss/postcss": {},
4+
},
5+
};
6+
7+
export default config;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"use server";
2+
3+
import { cookies } from "next/headers";
4+
import { redirect } from "next/navigation";
5+
import { twilioClient, VERIFY_SERVICE_SID } from "@/lib/twilio";
6+
import { encodeSession, getPendingVerification } from "@/lib/session";
7+
import { startSmsVerification } from "@/lib/verification";
8+
9+
export async function sendCodeAction(_prev: { error: string } | null, formData: FormData) {
10+
const phone = (formData.get("phone") as string)?.trim();
11+
if (!phone) return { error: "Phone number is required" };
12+
13+
try {
14+
await startSmsVerification(phone);
15+
} catch (err) {
16+
return { error: err instanceof Error ? err.message : "Failed to send verification code" };
17+
}
18+
19+
redirect("/verify");
20+
}
21+
22+
export async function verifyCodeAction(_prev: { error: string } | null, formData: FormData) {
23+
const code = (formData.get("code") as string)?.trim();
24+
if (!code) return { error: "Code is required" };
25+
26+
const pending = await getPendingVerification();
27+
if (!pending) return { error: "No pending verification. Please start over." };
28+
29+
let approved = false;
30+
try {
31+
const check = await twilioClient.verify.v2.services(VERIFY_SERVICE_SID).verificationChecks.create({
32+
to: pending.phone,
33+
code,
34+
});
35+
approved = check.status === "approved";
36+
} catch (err) {
37+
return { error: err instanceof Error ? err.message : "Failed to verify code" };
38+
}
39+
40+
if (!approved) return { error: "Invalid code. Please try again." };
41+
42+
const cookieStore = await cookies();
43+
cookieStore.delete("pending_verification");
44+
cookieStore.set("session", encodeSession({ phone: pending.phone, verifiedAt: new Date().toISOString() }), {
45+
httpOnly: true,
46+
path: "/",
47+
maxAge: 86400,
48+
});
49+
50+
redirect("/dashboard");
51+
}
52+
53+
export async function signOutAction() {
54+
const cookieStore = await cookies();
55+
cookieStore.delete("session");
56+
redirect("/");
57+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { NextResponse } from "next/server";
2+
import { startSmsVerification } from "@/lib/verification";
3+
4+
async function phoneFromRequest(request: Request): Promise<string | null> {
5+
const contentType = request.headers.get("content-type") ?? "";
6+
if (contentType.includes("application/json")) {
7+
const body = (await request.json()) as { phone?: unknown };
8+
return typeof body.phone === "string" ? body.phone.trim() : null;
9+
}
10+
11+
const formData = await request.formData();
12+
const phone = formData.get("phone");
13+
return typeof phone === "string" ? phone.trim() : null;
14+
}
15+
16+
export async function POST(request: Request) {
17+
const phone = await phoneFromRequest(request);
18+
if (!phone) {
19+
return NextResponse.json({ error: "Phone number is required" }, { status: 400 });
20+
}
21+
22+
try {
23+
await startSmsVerification(phone);
24+
} catch (err) {
25+
return NextResponse.json(
26+
{ error: err instanceof Error ? err.message : "Failed to send verification code" },
27+
{ status: 500 },
28+
);
29+
}
30+
31+
return NextResponse.json({ ok: true, phone, next: "/verify" });
32+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { redirect } from "next/navigation";
2+
import { getSession } from "@/lib/session";
3+
import { signOutAction } from "../actions";
4+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
5+
import { buttonVariants } from "@/components/ui/button-variants";
6+
import { cn } from "@/lib/utils";
7+
8+
export default async function DashboardPage() {
9+
const session = await getSession();
10+
if (!session) redirect("/");
11+
12+
return (
13+
<div className="flex flex-1 items-center justify-center p-4">
14+
<Card className="w-full max-w-sm">
15+
<CardHeader className="text-center">
16+
<CardTitle className="text-2xl">Phone verified</CardTitle>
17+
</CardHeader>
18+
<CardContent className="flex flex-col gap-4 items-center">
19+
<p className="text-sm text-muted-foreground">
20+
Verified as <strong className="text-foreground">{session.phone}</strong>
21+
</p>
22+
<form action={signOutAction}>
23+
<button type="submit" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
24+
Sign out
25+
</button>
26+
</form>
27+
</CardContent>
28+
</Card>
29+
</div>
30+
);
31+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { createEmulateHandler } from "@emulators/adapter-next";
2+
import * as twilio from "@emulators/twilio";
3+
4+
export const { GET, POST, PUT, PATCH, DELETE } = createEmulateHandler({
5+
services: {
6+
twilio: {
7+
emulator: twilio,
8+
},
9+
},
10+
});

0 commit comments

Comments
 (0)