Mercado Pago
Payments module for Next.js App Router backed by Mercado Pago. Covers one-time payments (Checkout Pro), recurring subscriptions (Preapproval), customer sync, refunds, and plan-based authorization — with no database required, since Mercado Pago is used as the source of truth.
Dependencies
mercadopago: official Node SDK (v2) used to talk to the Mercado Pago API.zod: validates every public input (checkout params, refund params, webhook payloads).server-only: guaranteesservice.ts,actions.ts, andplans.tscan never be bundled into client code.
Folder structure
payments/mercadopago/
├── service.ts // internal — SDK init + all business logic (never imported by components)
├── actions.ts // "use server" — the public API (checkout, customers, subscriptions, refunds, authorization)
├── client.ts // "use client" — checkout redirect + Customer Portal stub, for Client Components
├── hooks.ts // "use client" — useCheckoutRedirect(), loading/error state around a Server Action
├── webhooks/
│ ├── handler.ts // internal — signature verification, event parsing, dispatch
│ └── route.ts // copy verbatim to app/api/webhooks/mercadopago/route.ts
├── plans.ts // internal — plan definitions, limits, and Preapproval Plan IDs
├── schemas.ts // internal — Zod validation for every public input
├── constants.ts // internal — technical config (headers, event map, statuses, timeouts)
├── types.ts // shared types — safe to import anywhere, including Client Components
├── env.example // required environment variables
└── setup.md // usage guide with import paths and examples
How the files fit together
service.tsis internal only. It initializes the Mercado Pago SDK clients and implements every operation (checkout, customer sync, subscription management, refunds, authorization) as plain async functions returning a typedApiResult<T>. It is never imported from a component — onlyactions.tsandwebhooks/handler.tsimport it.actions.tsis the real public entry point. Every function is a Server Action ("use server") that thinly wraps itsservice.tscounterpart and strips non-serializable data from errors before the result crosses the server/client boundary. Import from here in Server Components, forms, and buttons.client.tsholds the only two things a Client Component needs directly:redirectToCheckout()(a plainwindow.locationredirect to a hosted Checkout session) andopenCustomerPortal()— which intentionally throws, since Mercado Pago has no hosted Customer Portal. Build your own billing UI with the subscription-management actions instead.hooks.tsexposes a single hook,useCheckoutRedirect(), that wraps a Server Action call fromactions.tswith loading/error state usinguseTransition, then hands the resulting URL toredirectToCheckout().
What actions.ts covers
- Checkout —
createCheckout(one-time payment via Checkout Pro) andcreateSubscriptionCheckout(recurring subscription via Preapproval). - Customers —
syncCustomer(get-or-create) andgetCustomerByEmail, linking Mercado Pago customers back to your app'suserId. - Subscription management —
getSubscriptionById,getSubscriptionByUserId,changeSubscriptionPlan(upgrade/downgrade),cancelSubscription,pauseSubscription,resumeSubscription. - Refunds —
createRefund, full or partial. - Authorization —
hasSubscription,requireSubscription,requirePlan(gate a feature to specific plans), andcheckPlanLimit(evaluate numeric/boolean plan limits, falling back to thefreeplan when there's no active subscription).
Usage examples
One-time payment checkout
"use server";
import { createCheckout } from "@/payments/mercadopago/actions";
export async function startOneTimePayment(userId: string) {
return createCheckout({
userId,
items: [{ id: "sku_1", title: "Product", quantity: 1, unitPrice: 5000, currency: "ARS" }],
successUrl: "https://yourapp.com/payments/success",
failureUrl: "https://yourapp.com/payments/failure",
pendingUrl: "https://yourapp.com/payments/pending",
});
}
Subscription checkout
"use server";
import { createSubscriptionCheckout } from "@/payments/mercadopago/actions";
export async function startProCheckout(userId: string, email: string) {
return createSubscriptionCheckout({
userId,
email,
planId: "pro_monthly",
backUrl: "https://yourapp.com/account/billing",
});
}
Client Component + hook
"use client";
import { useCheckoutRedirect } from "@/payments/mercadopago/hooks";
import { startProCheckout } from "./actions";
export function SubscribeButton({ userId, email }: { userId: string; email: string }) {
const { redirect, isPending, error } = useCheckoutRedirect();
return (
<div>
<button disabled={isPending} onClick={() => redirect(() => startProCheckout(userId, email))}>
{isPending ? "Redirecting..." : "Subscribe to Pro"}
</button>
{error && <p>{error}</p>}
</div>
);
}
Subscription management and authorization
import {
getSubscriptionByUserId,
changeSubscriptionPlan,
cancelSubscription,
requirePlan,
checkPlanLimit,
} from "@/payments/mercadopago/actions";
const current = await getSubscriptionByUserId("user_123");
if (current.ok && current.data) {
await changeSubscriptionPlan(current.data.id, "pro_yearly");
}
// Gate a feature to paid plans only:
const pro = await requirePlan("user_123", ["pro_monthly", "pro_yearly"]);
// Enforce a numeric plan limit:
const limit = await checkPlanLimit("user_123", "maxProjects", currentProjectCount);
if (limit.ok && !limit.data.allowed) {
// block project creation
}
Refunds
import { createRefund } from "@/payments/mercadopago/actions";
await createRefund({ paymentId: "123456789" }); // full refund
await createRefund({ paymentId: "123456789", amount: 500 }); // partial refund
Webhook
webhooks/route.ts is copied verbatim to
app/api/webhooks/mercadopago/route.ts. Signature verification, event
parsing, and dispatch are already resolved — only the TODO markers
inside (what to do with each event) are yours to fill in:
await handleWebhookEvent(event, {
"subscription.updated": async (e) => {
const result = await getSubscriptionFromEvent(e);
if (result.ok) {
// TODO: sync plan/status changes to your database
}
},
});
What you can build with this
This module is enough to ship a full subscription-based SaaS billing
flow — tiered plans, upgrades/downgrades, cancellations, and
feature-gating by plan — as well as one-off paid products or services
with refund support, all without standing up a payments database: every
read goes straight to Mercado Pago as the source of truth. Pair it with
your own database sync in the webhook TODOs if you need faster reads
or reporting on top of it.