Next.js sering dipakai sebagai frontend sekaligus backend ringan. Itu enak untuk integrasi OTP, asal batasnya jelas: API key OTP.ID tidak boleh pernah masuk ke browser. Request ke OTP.ID harus berjalan dari backend Next.js, misalnya lewat Route Handler di app/api/*.
Tutorial ini memakai SDK resmi OTP.ID untuk Node/TypeScript, yaitu @otp-id/sdk. Kita akan membuat dua endpoint backend:
- `POST /api/otp/request` untuk meminta OTP.
- `POST /api/otp/verify` untuk memverifikasi Kode OTP.
UI boleh memanggil dua endpoint internal itu, tapi UI tidak boleh memanggil https://api.otp.id langsung.
Kenapa harus lewat backend Next.js?
API key OTP.ID adalah kredensial milik Client. Kalau key itu ditaruh di komponen React, bundle JavaScript, local storage, atau request dari browser ke OTP.ID, siapa pun bisa melihat dan menyalahgunakannya.
Dengan Route Handler Next.js, alurnya lebih aman:
- End User mengisi nomor HP di halaman Next.js.
- Browser memanggil `/api/otp/request` di aplikasi Client.
- Route Handler Next.js membaca `OTP_ID_API_KEY` dari environment server.
- Route Handler memanggil OTP.ID memakai SDK resmi.
- Browser hanya menerima response yang sudah dikurasi.

Pola ini menjaga API key tetap di server dan membuat validasi bisnis tetap berada di backend Client.
Prasyarat
Siapkan project Next.js App Router dan Node.js 20 atau lebih baru, karena SDK resmi Node/TypeScript OTP.ID memakai global fetch.
Install SDK resmi:
npm install @otp-id/sdk
Tambahkan environment variable server di .env.local:
OTP_ID_API_KEY=<OTP_ID_API_KEY>
Jangan memakai prefix NEXT_PUBLIC_. Variable dengan prefix itu akan tersedia di browser, dan itu berbahaya untuk API key.
Membuat helper server-only OTP.ID
Buat file src/lib/otpid.ts:
import 'server-only';
import { OtpIdClient } from '@otp-id/sdk';
const apiKey = process.env.OTP_ID_API_KEY;
if (!apiKey) {
throw new Error('OTP_ID_API_KEY belum diisi di environment server');
}
export const otpId = new OtpIdClient(apiKey);
Import server-only membantu mencegah file ini tidak sengaja dipakai dari Client Component. Intinya tetap sama: SDK dan API key hanya hidup di server.
Route Handler untuk request OTP
Buat file src/app/api/otp/request/route.ts:
import { NextResponse } from 'next/server';
import { otpId } from '@/lib/otpid';
const phonePattern = /^62\d{8,15}$/;
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const phone = body?.phone;
if (typeof phone !== 'string' || !phonePattern.test(phone)) {
return NextResponse.json(
{ message: 'Nomor harus format 628xxxxxxxxxx' },
{ status: 400 },
);
}
const result = await otpId.requestOtp({
channel: 'whatsapp',
destination: phone,
brand: 'TokoAnda',
otp_length: 6,
ttl: 300,
external_id: `nextjs-demo:${phone}`,
});
if (result.status === 'failed') {
return NextResponse.json(
{ message: 'OTP belum berhasil dikirim' },
{ status: 502 },
);
}
return NextResponse.json({
message: 'OTP berhasil diminta',
otp_id: result.otp_id,
status: result.status,
});
}
Di demo sederhana ini, otp_id dikembalikan ke browser agar mudah dites. Untuk production, lebih aman menyimpan otp_id di session, database, atau cache server-side yang terikat ke akun/register attempt.
Route Handler untuk verify OTP
Buat file src/app/api/otp/verify/route.ts:
import { NextResponse } from 'next/server';
import { otpId } from '@/lib/otpid';
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const otpIdValue = body?.otp_id;
const otp = body?.otp;
if (typeof otpIdValue !== 'string' || typeof otp !== 'string') {
return NextResponse.json(
{ message: 'otp_id dan otp wajib diisi' },
{ status: 400 },
);
}
const result = await otpId.verifyOtp(otpIdValue, otp);
if (!result.verified) {
return NextResponse.json(
{ message: 'Kode OTP salah', reason: result.reason },
{ status: 400 },
);
}
return NextResponse.json({
message: 'Nomor berhasil diverifikasi',
verified: true,
});
}
Pada kontrak OTP.ID V3 dan SDK resmi Node, kode salah adalah hasil normal: verified:false dengan reason mismatch, bukan exception. Jadi aplikasi harus mengecek result.verified.
Error handling yang aman untuk End User
SDK resmi melempar APIError untuk error dari API OTP.ID. Di production, bungkus Route Handler dengan helper agar pesan teknis tidak langsung dilempar ke End User.
import { APIError } from '@otp-id/sdk';
export function toPublicOtpError(error: unknown) {
if (error instanceof APIError) {
switch (error.code) {
case 'UNAUTHORIZED':
return { status: 500, message: 'Konfigurasi API key perlu dicek' };
case 'INSUFFICIENT_BALANCE':
return { status: 402, message: 'Kredit OTP tidak cukup' };
case 'RATE_LIMITED':
case 'DESTINATION_RATE_LIMITED':
return { status: 429, message: 'Terlalu banyak request OTP, coba lagi nanti' };
case 'OTP_EXPIRED':
return { status: 400, message: 'Kode OTP sudah kedaluwarsa' };
case 'TOO_MANY_ATTEMPTS':
return { status: 400, message: 'Terlalu banyak percobaan verifikasi' };
case 'ALREADY_USED':
return { status: 400, message: 'Kode OTP sudah dipakai' };
default:
return { status: 502, message: 'Request OTP gagal' };
}
}
return { status: 502, message: 'Gagal menghubungi layanan OTP' };
}

Jangan meneruskan detail error mentah ke UI tanpa kurasi. Developer butuh log internal, tapi End User cukup butuh instruksi yang jelas.
Contoh pemanggilan dari UI
Komponen React di browser cukup memanggil endpoint internal:
await fetch('/api/otp/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: '628xxxxxxxxxx' }),
});
Yang penting: browser hanya memanggil /api/otp/request, bukan API OTP.ID langsung. API key tetap dibaca dari environment server.
Checklist sebelum production
- [ ] `OTP_ID_API_KEY` tidak memakai prefix `NEXT_PUBLIC_`.
- [ ] SDK `@otp-id/sdk` hanya dipakai di file server-side.
- [ ] Route Handler tidak meneruskan API key, Kode OTP, atau nomor HP mentah ke log publik.
- [ ] `otp_id` disimpan server-side, bukan bergantung penuh pada browser.
- [ ] Ada cooldown resend OTP di UI.
- [ ] `verified:false` ditangani sebagai kode salah.
- [ ] Error `INSUFFICIENT_BALANCE`, `RATE_LIMITED`, `DESTINATION_RATE_LIMITED`, `OTP_EXPIRED`, `TOO_MANY_ATTEMPTS`, dan `ALREADY_USED` punya pesan yang aman.
Penutup
Integrasi OTP.ID di Next.js paling aman ketika backend route menjadi perantara. Frontend mengatur pengalaman End User, sementara Route Handler Next.js menyimpan API key, memanggil SDK resmi OTP.ID, dan menerjemahkan response menjadi pesan yang aman untuk aplikasi.
