Skip to content

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

Terminal window
npx create-next-app@latest my-app --typescript --tailwind --app
cd my-app
pnpm add @murai-wallet/murai drizzle-orm postgres

Create the database tables and set up your .env.local:

Terminal window
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.

src/lib/wallet.ts
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 startup
const 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 spending
const wallet = createWallet({ storage });
// 4. Ledger + Checkout — top-ups via payment gateway
const ledger = createLedger(storage);
const checkout = createCheckoutManager(gateway, ledger, storage);
export { wallet, checkout };

3. Top-up via Server Action

src/app/actions.ts
'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

src/app/api/webhooks/midtrans/route.ts
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)

src/app/page.tsx
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

src/app/spend-action.ts
'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:

src/webhooks.ts
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.