How to Send OTP with SvelteKit (2026)
SvelteKit OTP tutorial using StartMessaging — uses form actions, server-only modules, hooks for session, and Zod for validation. Production-ready end-to-end flow.
StartMessaging Team
Engineering
SvelteKit’s server-only modules + form actions are an excellent pattern for OTP flows: every secret stays server-side, every state change goes through a typed action, and progressive enhancement is free. This guide builds an end-to-end OTP login on top of StartMessaging.
Project Setup
pnpm create svelte@latest otp-sveltekit
cd otp-sveltekit
pnpm install
pnpm add zod
pnpm add -D @types/nodeEnvironment Variables
# .env
SM_API_KEY=sm_live_xxxxxxxxxxxxxxxx
SM_BASE_URL=https://api.startmessaging.com
SESSION_SECRET=replace-with-a-real-secretSvelteKit exposes these via $env/static/private — perfectly unreachable from client code.
A Server-Only StartMessaging Module
// src/lib/server/sm.ts
import { SM_API_KEY, SM_BASE_URL } from '$env/static/private';
import { randomUUID } from 'node:crypto';
async function smPost<T>(path: string, body: object): Promise<T> {
const res = await fetch(`${SM_BASE_URL}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': SM_API_KEY },
body: JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
});
const json = await res.json().catch(() => ({} as any));
if (!res.ok) throw Object.assign(new Error(json.message ?? 'OTP API'), { status: res.status });
return json.data as T;
}
export interface SendData { requestId: string; expiresAt: string; attemptsLeft: number; }
export const sm = {
send: (phoneNumber: string) =>
smPost<SendData>('/otp/send', { phoneNumber, idempotencyKey: randomUUID() }),
verify: (requestId: string, otpCode: string) =>
smPost<{ verified: true }>('/otp/verify', { requestId, otpCode }),
};Send OTP — Form Action
// src/routes/login/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import { z } from 'zod';
import type { Actions } from './$types';
import { sm } from '$lib/server/sm';
const PhoneSchema = z.object({ phoneNumber: z.string().regex(/^\+91\d{10}$/) });
const CodeSchema = z.object({ otpCode: z.string().regex(/^\d{4,8}$/) });
export const actions: Actions = {
send: async ({ request, cookies }) => {
const data = Object.fromEntries(await request.formData());
const parsed = PhoneSchema.safeParse(data);
if (!parsed.success) return fail(400, { error: 'Invalid phone number' });
try {
const { requestId, expiresAt } = await sm.send(parsed.data.phoneNumber);
cookies.set('otp_req', requestId, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 15 * 60,
});
return { stage: 'verify', expiresAt };
} catch (e: any) {
return fail(e.status ?? 500, { error: e.message ?? 'Failed' });
}
},
verify: async ({ request, cookies }) => {
const data = Object.fromEntries(await request.formData());
const parsed = CodeSchema.safeParse(data);
if (!parsed.success) return fail(400, { error: 'Invalid OTP' });
const requestId = cookies.get('otp_req');
if (!requestId) return fail(400, { error: 'No active OTP request' });
try {
await sm.verify(requestId, parsed.data.otpCode);
cookies.delete('otp_req', { path: '/' });
cookies.set('session', 'verified-user', {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 30 * 60,
});
throw redirect(303, '/dashboard');
} catch (e: any) {
return fail(e.status ?? 500, { error: e.message ?? 'Failed' });
}
},
};Verify OTP — Form Action
Already wired into the same actions object above (the verify action). The two actions read from cookies, never from the browser, so a stolen requestId from the network does not let an attacker complete the flow without the matching session.
Session via hooks.server.ts
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
event.locals.user = event.cookies.get('session') ? { phone: 'verified' } : null;
if (event.url.pathname.startsWith('/dashboard') && !event.locals.user) {
return new Response(null, { status: 303, headers: { location: '/login' } });
}
return resolve(event);
};The Login Forms
<!-- src/routes/login/+page.svelte -->
<script lang="ts">
import type { ActionData } from './$types';
export let form: ActionData;
</script>
<form method="POST" action="?/send">
<label>Phone <input name="phoneNumber" placeholder="+919876543210" /></label>
<button type="submit">Send OTP</button>
</form>
{#if form?.stage === 'verify'}
<form method="POST" action="?/verify">
<label>OTP <input name="otpCode" placeholder="482910" /></label>
<button type="submit">Verify</button>
</form>
{/if}
{#if form?.error}
<p style="color: red">{form.error}</p>
{/if}FAQ
Looking for the same flow on Next.js, Nuxt or Remix? See Next.js App Router and the full tutorials index.
Related Articles
Send and verify SMS OTPs from a Next.js 14/15 App Router app using server actions and the StartMessaging API. Includes a full login form, server actions, and middleware.
Step-by-step Node.js tutorial to send and verify OTP via SMS using the StartMessaging API. Includes fetch examples, error handling, and verification flow.
Production-ready Express.js OTP guide using StartMessaging. Covers send, verify, idempotency, rate-limit middleware, error mapping and a session-based verification flow.
Ready to Send OTPs?
Integrate StartMessaging in 5 minutes. No DLT registration required.