Logo FormaUI

FormaUI

All providers

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: guarantees service.ts, actions.ts, and plans.ts can 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.ts is 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 typed ApiResult<T>. It is never imported from a component — only actions.ts and webhooks/handler.ts import it.
  • actions.ts is the real public entry point. Every function is a Server Action ("use server") that thinly wraps its service.ts counterpart 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.ts holds the only two things a Client Component needs directly: redirectToCheckout() (a plain window.location redirect to a hosted Checkout session) and openCustomerPortal() — which intentionally throws, since Mercado Pago has no hosted Customer Portal. Build your own billing UI with the subscription-management actions instead.
  • hooks.ts exposes a single hook, useCheckoutRedirect(), that wraps a Server Action call from actions.ts with loading/error state using useTransition, then hands the resulting URL to redirectToCheckout().

What actions.ts covers

  • CheckoutcreateCheckout (one-time payment via Checkout Pro) and createSubscriptionCheckout (recurring subscription via Preapproval).
  • CustomerssyncCustomer (get-or-create) and getCustomerByEmail, linking Mercado Pago customers back to your app's userId.
  • Subscription managementgetSubscriptionById, getSubscriptionByUserId, changeSubscriptionPlan (upgrade/downgrade), cancelSubscription, pauseSubscription, resumeSubscription.
  • RefundscreateRefund, full or partial.
  • AuthorizationhasSubscription, requireSubscription, requirePlan (gate a feature to specific plans), and checkPlanLimit (evaluate numeric/boolean plan limits, falling back to the free plan 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.