Next.js Integration
This guide walks through building a complete token wallet integration with Next.js 15 App Router. A working example app is available at examples/nextjs/.
1. Project setup
npx create-next-app@latest my-app --typescript --tailwind --appcd my-apppnpm add @murai-wallet/murai drizzle-orm postgresCreate the database tables and set up your .env.local:
DATABASE_URL=postgres://...MIDTRANS_SERVER_KEY=SB-Mid-server-...MIDTRANS_CLIENT_KEY=SB-Mid-client-...2. Wallet singleton
Create a shared wallet instance. This file runs on the server only.
import { createCheckoutManager, createDrizzleStorage, createLedger, createMidtransGateway, createWallet,} from '@murai-wallet/murai';import { drizzle } from 'drizzle-orm/postgres-js';import postgres from 'postgres';
// 1. Storage — connect to PostgreSQL via Drizzle// biome-ignore lint/style/noNonNullAssertion: env vars validated at startupconst sql = postgres(process.env.DATABASE_URL!);const storage = createDrizzleStorage(drizzle(sql));
// 2. Gateway — Midtrans Snap (sandbox mode)const gateway = createMidtransGateway({ // biome-ignore lint/style/noNonNullAssertion: env vars validated at startup serverKey: process.env.MIDTRANS_SERVER_KEY!, // biome-ignore lint/style/noNonNullAssertion: env vars validated at startup clientKey: process.env.MIDTRANS_CLIENT_KEY!, sandbox: true,});
// 3. Wallet — balance queries and spendingconst wallet = createWallet({ storage });
// 4. Ledger + Checkout — top-ups via payment gatewayconst ledger = createLedger(storage);const checkout = createCheckoutManager(gateway, ledger, storage);
export { wallet, checkout };3. Top-up via Server Action
'use server';
import { checkout } from '@/lib/wallet';import { redirect } from 'next/navigation';
export async function createTopUp(formData: FormData) { const userId = formData.get('userId') as string; const amount = Number(formData.get('amount'));
const session = await checkout.createSession({ userId, amount, successRedirectUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/success`, failureRedirectUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/`, });
redirect(session.redirectUrl);}4. Webhook Route Handler
import { checkout } from '@/lib/wallet';import { WebhookVerificationError } from '@murai-wallet/murai';import { NextResponse } from 'next/server';
export async function POST(request: Request) { const body = await request.json();
try { const result = await checkout.handleWebhook({ payload: body, signature: body.signature_key, }); return NextResponse.json(result); } catch (error) { if (error instanceof WebhookVerificationError) { return NextResponse.json( { error: 'Invalid signature' }, { status: 401 }, ); } return NextResponse.json( { error: 'Internal server error' }, { status: 500 }, ); }}5. Balance display (Server Component)
import { wallet } from '@/lib/wallet';import { createTopUp } from './actions';
export default async function DashboardPage() { const userId = 'user_123'; // Replace with your auth const balance = await wallet.getBalance(userId);
return ( <main className="mx-auto max-w-md p-8"> <h1 className="text-2xl font-bold">Murai</h1> <p className="mt-4 text-4xl font-mono"> {balance.toLocaleString('id-ID')} tokens </p>
<form action={createTopUp} className="mt-8"> <input type="hidden" name="userId" value={userId} /> <input type="hidden" name="amount" value="100000" /> <button type="submit" className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700" > Top Up IDR 100,000 </button> </form> </main> );}6. Spend action
'use server';
import { wallet } from '@/lib/wallet';import { InsufficientBalanceError } from '@murai-wallet/murai';import { randomUUID } from 'crypto';
export async function spendTokens(userId: string, amount: number) { try { await wallet.spend(userId, amount, `spend-${randomUUID()}`); return { success: true }; } catch (error) { if (error instanceof InsufficientBalanceError) { return { success: false, error: 'Insufficient balance' }; } throw error; }}7. Hono alternative
If you’re using Hono instead of Next.js, here’s the equivalent webhook handler:
import { Hono } from 'hono';import { WebhookVerificationError } from '@murai-wallet/murai';
const app = new Hono();
app.post('/webhooks/midtrans', async (c) => { const body = await c.req.json();
try { const result = await checkout.handleWebhook({ payload: body, signature: body.signature_key, }); return c.json(result, 200); } catch (error) { if (error instanceof WebhookVerificationError) { return c.json({ error: 'Invalid signature' }, 401); } throw error; }});Example app
See the complete working example at examples/nextjs/ — includes dashboard, top-up flow, webhook handler, and spend action.
Try it live → — the deployed version of this example app.