Accept Payments with Paystack in Nuxt
To accept Paystack payments in Nuxt, create a server route at server/api/pay.post.ts that initializes transactions using your secret key stored in runtimeConfig. Your Nuxt page loads Paystack Inline.js, calls the server route, and opens the popup with the access code. After payment, another server route verifies the transaction.
Why Nuxt for Paystack
Nuxt is the full-stack framework for Vue. Unlike a plain Vue SPA, Nuxt gives you server routes where you can safely store your Paystack secret key and make API calls to Paystack. You do not need a separate Express, Django, or Laravel backend. Everything lives in one project.
Nuxt server routes run on the server during both SSR and as API endpoints. When you create a file at server/api/pay.post.ts, Nuxt exposes it at /api/pay as a POST endpoint. Your Vue pages call these endpoints with $fetch or useFetch.
Project Setup
Add your Paystack keys to the environment and Nuxt config:
# .env
PAYSTACK_SECRET_KEY=sk_test_xxxxxxxxxxxx
PAYSTACK_PUBLIC_KEY=pk_test_xxxxxxxxxxxx
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
paystackSecretKey: process.env.PAYSTACK_SECRET_KEY,
public: {
paystackPublicKey: process.env.PAYSTACK_PUBLIC_KEY,
},
},
});
The paystackSecretKey is only available in server routes via useRuntimeConfig(). It never reaches the browser. The public key is safe for the frontend.
Server Route: Initialize Transaction
// server/api/pay.post.ts
export default defineEventHandler(async (event) => {
var config = useRuntimeConfig();
var body = await readBody(event);
var email = body.email;
var amount = body.amount;
var reference = 'nuxt_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
var response = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + config.paystackSecretKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: email,
amount: Math.round(amount * 100),
reference: reference,
}),
});
var data = await response.json();
if (!data.status) {
throw createError({ statusCode: 400, message: data.message });
}
return {
access_code: data.data.access_code,
authorization_url: data.data.authorization_url,
reference: data.data.reference,
};
});
This runs on the server. The secret key never leaves the server environment.
Inline Popup Checkout
Create a checkout page that loads Paystack Inline.js and opens the popup:
<!-- pages/checkout.vue -->
<script setup>
useHead({
script: [{ src: 'https://js.paystack.co/v2/inline.js', defer: true }],
});
var email = ref('');
var loading = ref(false);
var amount = 5000; // 5,000 NGN
async function handlePay() {
if (!email.value) return;
loading.value = true;
try {
var data = await $fetch('/api/pay', {
method: 'POST',
body: { email: email.value, amount: amount },
});
var popup = new window.PaystackPop();
popup.checkout({
accessCode: data.access_code,
onSuccess: async function(transaction) {
var result = await $fetch('/api/verify', {
params: { reference: transaction.reference },
});
if (result.verified) {
navigateTo('/payment/success?ref=' + transaction.reference);
}
loading.value = false;
},
onCancel: function() {
loading.value = false;
},
});
} catch (err) {
alert('Payment initialization failed');
loading.value = false;
}
}
</script>
<template>
<div>
<h1>Checkout</h1>
<input v-model="email" type="email" placeholder="Email address" />
<p>Amount: NGN {{ amount.toLocaleString() }}</p>
<button @click="handlePay" :disabled="loading || !email">
{{ loading ? 'Processing...' : 'Pay Now' }}
</button>
</div>
</template>
Nuxt's $fetch calls your server routes directly. No need to configure CORS or specify a separate API URL. The useHead composable loads the Paystack script.
Server Route: Verify Transaction
// server/api/verify.get.ts
export default defineEventHandler(async (event) => {
var config = useRuntimeConfig();
var query = getQuery(event);
var reference = query.reference;
if (!reference) {
throw createError({ statusCode: 400, message: 'Reference required' });
}
var response = await fetch(
'https://api.paystack.co/transaction/verify/' + encodeURIComponent(String(reference)),
{
headers: { Authorization: 'Bearer ' + config.paystackSecretKey },
}
);
var data = await response.json();
if (data.status && data.data.status === 'success') {
// Update order in database
return {
verified: true,
amount: data.data.amount / 100,
currency: data.data.currency,
reference: data.data.reference,
};
}
throw createError({ statusCode: 400, message: 'Payment not verified' });
});
Redirect Checkout
For redirect checkout, send the customer to the authorization URL and handle the callback:
async function handleRedirectPay() {
var data = await $fetch('/api/pay', {
method: 'POST',
body: { email: email.value, amount: amount },
});
if (data.authorization_url) {
window.location.href = data.authorization_url;
}
}
Set the callback URL in your initialization to point to a Nuxt page:
<!-- pages/payment/callback.vue -->
<script setup>
var route = useRoute();
var { data, error } = await useFetch('/api/verify', {
params: { reference: route.query.reference || route.query.trxref },
});
</script>
<template>
<div v-if="data">
<h1>Payment Successful</h1>
<p>{{ data.currency }} {{ data.amount }}</p>
</div>
<div v-else-if="error">
<p>Verification failed. Please contact support.</p>
</div>
<div v-else>
<p>Verifying payment...</p>
</div>
</template>
With Nuxt SSR, useFetch runs the verification on the server during page load. The customer sees the result immediately without a loading spinner on most connections.
Production Checklist
- Switch keys. Replace
sk_test_andpk_test_with live keys in your production .env file. - Validate amounts server-side. Look up prices from your database in the server route. Do not trust the amount sent from the page.
- Set up webhooks. Initialize and verify give you immediate feedback, but webhooks handle edge cases. See Handle Paystack Webhooks in Nuxt.
- HTTPS. Nuxt must be deployed behind HTTPS for Paystack to work in production.
- Idempotent fulfillment. Both the verification call and the webhook can confirm the same payment. Use a database flag to avoid double-granting.
Key Takeaways
- ✓Nuxt 3 server routes replace the need for a separate backend. Your Paystack secret key stays in runtimeConfig (server-side only).
- ✓Create server/api/pay.post.ts to initialize transactions and server/api/verify.get.ts to verify them.
- ✓Load Paystack Inline.js with useHead or a client-side plugin. Open the popup in your page component.
- ✓Amounts must be in the smallest currency unit (kobo for NGN, pesewas for GHS, cents for KES/ZAR/USD).
- ✓For redirect checkout, set the callback_url to a Nuxt page that verifies on mount.
- ✓Store your Paystack keys in .env and access them through useRuntimeConfig().paystack_secret_key in server routes.
Frequently Asked Questions
- Do I need a separate backend with Nuxt?
- No. Nuxt server routes act as your backend. You create files in the server/ directory and they run on the server. Your Paystack secret key stays in runtimeConfig and never reaches the browser.
- Should I use useFetch or $fetch for the payment calls?
- Use $fetch inside event handlers (button clicks) because it runs on the client. Use useFetch in setup for data that should load with the page (like the callback verification page). useFetch also supports SSR.
- Can I use the Paystack inline popup with Nuxt SSR?
- Yes. The popup runs in the browser. Load the Paystack script with useHead and open the popup in a client-side event handler. SSR does not affect the popup because it only runs after user interaction.
- How do I handle the Paystack public key in Nuxt?
- Put it in runtimeConfig.public so it is available on both server and client. You only need the public key if you initialize the popup directly with it. When using the access_code approach (recommended), you do not need the public key on the frontend at all.
Ready to build real-world apps?
Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.
Apply to the McTaba Marathon