Adyen Drop-in inside Receipt Morph Checkout, in the App Router
app/pay/checkout.tsx
"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { AdyenCheckout, Card, Dropin, GooglePay, PayPal, type ResultCode } from "@adyen/adyen-web";
import "@adyen/adyen-web/styles/adyen.css";
import "./adyen-theme.css"; // after Adyen's css, so its variables win
import { CheckoutReceiptMorph, type CardFieldsSlot, type PayResult } from "@/components/CheckoutReceiptMorph";
import { createAdyenSession } from "./actions";
type Outcome = { resultCode: ResultCode; sessionId: string; sessionResult?: string };
type Controller = { dropin: Dropin | null; settle: ((o: Outcome) => void) | null };
// Mounts Adyen's Drop-in where the receipt card would show its own number, expiry and CVC inputs.
function DropinField({ slot, ctl }: { slot: CardFieldsSlot; ctl: React.RefObject<Controller> }) {
const node = useRef<HTMLDivElement>(null);
const slotRef = useRef(slot);
slotRef.current = slot;
useEffect(() => {
let dropin: Dropin | null = null;
let cancelled = false;
(async () => {
const session = await createAdyenSession();
if (cancelled || !node.current) return;
// Every payment-method result lands in these two callbacks, so the Pay button can await them.
const done = (resultCode: ResultCode, sessionResult?: string) =>
ctl.current.settle?.({ resultCode, sessionId: session.id, sessionResult });
const checkout = await AdyenCheckout({
session,
clientKey: process.env.NEXT_PUBLIC_ADYEN_CLIENT_KEY!,
environment: "test", // "live" (or "live-us", "live-au"...) in production
countryCode: "US",
locale: "en-US",
// Drop-in asks every method for its state; isValid tells the receipt card whether Pay can go.
onChange: (state) => slotRef.current.onChange({ complete: state.isValid }),
onPaymentCompleted: (result) => done(result.resultCode, "sessionResult" in result ? result.sessionResult : undefined),
onPaymentFailed: (result) => done(result?.resultCode ?? "Error", result && "sessionResult" in result ? result.sessionResult : undefined),
onError: (error) => console.error(error.name, error.message),
});
if (cancelled) return;
dropin = new Dropin(checkout, {
paymentMethodComponents: [Card, PayPal, GooglePay], // only these are bundled; @adyen/adyen-web/auto has them all
showPayButton: false, // the receipt card's Pay button submits instead
disableFinalAnimation: true, // the receipt plays instead of Drop-in's tick
paymentMethodsConfiguration: {
card: {
hasHolderName: false, // the receipt card already asks for the name
// The number, expiry and CVC sit in Adyen's iframes: CSS can't reach them, this can.
styles: { base: { color: "#F4F4F6", fontSize: "16px" }, placeholder: { color: "#6B6F80" }, error: { color: "#FF5C6C" } },
onBrand: (b) => slotRef.current.onChange({ brand: b.brand }),
onFocus: (f) => {
// The receipt card flips while the CVC field has focus.
if ("focus" in f && f.fieldType === "encryptedSecurityCode") slotRef.current.onChange({ focus: f.focus ? "cvc" : null });
},
},
},
}).mount(node.current);
ctl.current.dropin = dropin;
})().catch((e) => console.error(e));
return () => {
cancelled = true;
dropin?.unmount();
ctl.current.dropin = null;
};
}, [ctl]);
return <div ref={node} className="adyen-dark" />;
}
export default function AdyenCheckoutPage() {
const router = useRouter();
const ctl = useRef<Controller>({ dropin: null, settle: null });
const confirm = (): Promise<PayResult> =>
new Promise<Outcome>((resolve, reject) => {
const dropin = ctl.current.dropin;
if (!dropin) return reject(new Error("The payment form hasn't loaded yet."));
ctl.current.settle = resolve;
// Cards and wallets resolve here. Redirect methods leave the page and finish on /pay/return.
dropin.submit();
}).then((o) => {
ctl.current.settle = null;
const done = `/pay/done?sessionId=${encodeURIComponent(o.sessionId)}&sessionResult=${encodeURIComponent(o.sessionResult ?? "")}`;
if (o.resultCode === "Authorised") {
setTimeout(() => router.push(done), 2600); // let the receipt print first
return { ok: true };
}
if (o.resultCode === "Received" || o.resultCode === "Pending") {
router.push(done); // not paid yet: no PAID stamp, the done page explains
return new Promise<PayResult>(() => {});
}
return { ok: false, message: o.resultCode === "Refused" ? "The bank refused it. Nothing was charged." : "That didn't go through. Try again." };
});
return <CheckoutReceiptMorph renderCardFields={(slot) => <DropinField slot={slot} ctl={ctl} />} onConfirm={confirm} />;
}
app/pay/actions.ts (Server Action: create the session)
"use server";
import { randomUUID } from "node:crypto";
import { checkout } from "@/lib/adyen";
import { ORDER } from "@/lib/order";
const SITE = process.env.NEXT_PUBLIC_SITE_URL!; // e.g. https://shop.example.com, no trailing slash
// Called when the checkout mounts. Returns only what the browser needs: the session id and sessionData.
// The amount comes from the server, never from the browser: a Server Action is a public endpoint.
export async function createAdyenSession(): Promise<{ id: string; sessionData: string }> {
const reference = `order-${randomUUID()}`;
const session = await checkout.PaymentsApi.sessions(
{
merchantAccount: process.env.ADYEN_MERCHANT_ACCOUNT!,
amount: ORDER.amount,
countryCode: ORDER.countryCode,
reference,
// Redirect methods (iDEAL, Klarna, some 3D Secure flows) come back here.
returnUrl: `${SITE}/pay/return`,
},
{ idempotencyKey: reference }
);
if (!session.sessionData) throw new Error("Adyen sent no sessionData");
return { id: session.id, sessionData: session.sessionData };
}
lib/adyen.ts and lib/order.ts (server)
// lib/adyen.ts
import "server-only";
import { CheckoutAPI, Client, EnvironmentEnum } from "@adyen/api-library";
// The API key stays on the server. server-only fails the build if a client file imports this.
// Going live: EnvironmentEnum.LIVE plus liveEndpointUrlPrefix (your prefix from the Customer Area).
const client = new Client({ apiKey: process.env.ADYEN_API_KEY!, environment: EnvironmentEnum.TEST });
export const checkout = new CheckoutAPI(client);
// lib/order.ts
import { NW } from "@/data/northwind";
// What the receipt shows and what Adyen charges come from the same data. Adyen wants minor units.
export const ORDER = {
amount: { currency: "USD", value: Math.round(NW.store.total * 100) },
countryCode: "US",
};
app/pay/page.tsx
import AdyenCheckoutPage from "./checkout";
// No session on page load from the server: the client creates it when the form mounts.
export default function PayPage() {
return <AdyenCheckoutPage />;
}
app/pay/adyen-theme.css
/* Drop-in outside its iframes reads these variables. Scoped to the wrapper, not :root. */
.adyen-dark {
--adyen-sdk-color-background-primary: #0d0e13;
--adyen-sdk-color-background-secondary: #15171f;
--adyen-sdk-color-label-primary: #f4f4f6;
--adyen-sdk-color-label-secondary: #a3a6b4;
--adyen-sdk-color-outline-primary: #2a2e3b;
--adyen-sdk-color-outline-primary-active: #c6ff3d;
--adyen-sdk-border-radius-m: 14px;
}
app/pay/done/page.tsx (asks Adyen for the result)
import Link from "next/link";
import { checkout } from "@/lib/adyen";
const MESSAGES: Record<string, string> = {
completed: "Paid. Thank you!",
paymentPending: "Your payment is processing. We'll email you when it clears.",
refused: "The payment was refused. Nothing was charged.",
canceled: "The payment was cancelled.",
expired: "The checkout timed out. Start again.",
active: "The payment isn't finished yet.",
};
// Ask Adyen for the result. The query string alone proves nothing. Fulfil from the webhook, not here.
export default async function Done({ searchParams }: { searchParams: Promise<{ sessionId?: string; sessionResult?: string }> }) {
const { sessionId, sessionResult } = await searchParams;
if (!sessionId || !sessionResult) return <p className="p-8">No payment found.</p>;
const result = await checkout.PaymentsApi.getResultOfPaymentSession(sessionId, sessionResult);
const status = result.status ?? "active";
return (
<main className="mx-auto max-w-md p-8">
<h1 className="text-2xl font-semibold">{MESSAGES[status] ?? "Something went wrong."}</h1>
{status !== "completed" && status !== "paymentPending" && (
<Link href="/pay" className="mt-6 inline-block underline">Back to checkout</Link>
)}
</main>
);
}
lib/orders.ts
import "server-only";
// Your database call. Adyen can send the same notification more than once: make this idempotent on pspReference.
export async function fulfillOrder(merchantReference: string, pspReference: string) {
console.log("fulfil", merchantReference, pspReference);
}