Pedro Alonso

Stripe Payment Links and Customer Portal in Next.js 15

12 min read

Introduction

This article is part of a comprehensive series on integrating Stripe with Next.js:

  1. Part 1: Stripe Checkout and Webhooks: Covers Stripe Checkout integration for one-time payments.
  2. Part 2: Stripe Subscriptions: Explores subscription-based billing implementation.
  3. Part 3: Stripe Webhooks Deep Dive: Advanced webhook handling and race conditions.
  4. Part 4 (Current Article): The easiest Stripe integration using Payment Links and Customer Portal.

If you’ve worked with Stripe before, you know that implementing a full checkout flow can involve quite a bit of code—creating checkout sessions, handling webhooks, managing customer data, and building interfaces for customers to manage their subscriptions or payment methods. While Stripe Checkout is powerful, sometimes you need something even simpler and faster to implement.

That’s where Stripe Payment Links and the Customer Portal come in. These are Stripe’s no-code solutions that allow you to accept payments and give customers self-service capabilities without writing extensive backend code. In this guide, we’ll explore how to integrate both of these features into a Next.js 15 application, providing the fastest path to accepting payments while maintaining professional functionality.

In this tutorial, we’ll cover:

  1. Understanding when to use Payment Links vs. Checkout Sessions
  2. Creating and managing Payment Links through the Stripe Dashboard
  3. Implementing dynamic Payment Links in your Next.js application
  4. Setting up the Stripe Customer Portal for self-service management
  5. Handling customer data and subscription lifecycle
  6. Best practices and production considerations

By the end of this guide, you’ll be able to implement a complete payment system with minimal code, perfect for MVPs, small businesses, or anyone who wants to get payments up and running quickly.

Let’s dive in!

Before we start implementing Payment Links, it’s important to understand the difference between Payment Links and the traditional Checkout Sessions we covered in earlier articles.

1.1 Understanding the Options

Stripe Checkout Sessions (covered in Part 1) require you to:

  • Create a backend API route to generate session IDs
  • Programmatically define products and prices
  • Handle the checkout redirect logic
  • Manage the complete checkout flow in your code

Stripe Payment Links, on the other hand:

  • Are created once in the Stripe Dashboard
  • Generate a shareable URL you can use anywhere
  • Require no backend code to create the checkout
  • Can be embedded or linked directly in your application

Here’s a visual comparison of both flows:

Payment Links Flow
Frontend redirects to Payment Link
User clicks Buy
User completes payment
Stripe redirects to success URL
Checkout Sessions Flow
Frontend calls API route
User clicks Buy
Backend creates session
Backend returns session ID
Frontend redirects to Stripe
User completes payment

Payment Links are ideal when you:

  • Want to get started quickly without backend code
  • Have a small number of products or pricing tiers
  • Need to share payment links via email, SMS, or social media
  • Want to embed buy buttons on landing pages
  • Are building an MVP and want to validate demand
  • Have simple pricing without complex customization

1.3 When to Use Checkout Sessions

Stick with Checkout Sessions when you:

  • Need to create dynamic pricing based on user input
  • Have complex shopping cart scenarios
  • Require custom metadata for each transaction
  • Need to apply dynamic discounts or coupons
  • Want to control the checkout experience programmatically
  • Have product catalogs with hundreds or thousands of items

For this tutorial, we’ll focus on Payment Links combined with the Customer Portal, which covers a surprising number of use cases with minimal code.

2. Setting Up Your Next.js 15 Project

Let’s start by creating a new Next.js 15 project configured for Stripe Payment Links integration.

2.1 Creating a New Next.js Project

Open your terminal and run the following command:

Terminal window
npx create-next-app@latest stripe-payment-links-demo

When prompted, select the following options:

✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like to use `src/` directory? … Yes
✔ Would you like to use App Router? … Yes
✔ Would you like to customize the default import alias? … No

Navigate to your new project:

Terminal window
cd stripe-payment-links-demo

2.2 Installing Dependencies

While Payment Links require minimal code, we still need the Stripe library for webhook handling and customer management:

Terminal window
npm install stripe @stripe/stripe-js

2.3 Setting Up Environment Variables

Create a .env.local file in your project root:

# Stripe Keys
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key
STRIPE_SECRET_KEY=sk_test_your_secret_key
# URLs
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Stripe Webhook Secret (we'll add this later)
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret

You can find your API keys in the Stripe Dashboard under Developers > API keys.

2.4 Creating a Stripe Client Utility

Create a utility file to initialize Stripe on the server side. Create src/lib/stripe.ts:

import Stripe from 'stripe';
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error('STRIPE_SECRET_KEY is not defined in environment variables');
}
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2024-09-30.acacia',
typescript: true,
});

This utility ensures we have a consistent Stripe client throughout our application.

Now let’s create Payment Links in the Stripe Dashboard. Unlike programmatic checkout sessions, Payment Links are created once and can be reused indefinitely.

Follow these steps to create your first Payment Link:

  1. Log in to your Stripe Dashboard
  2. Navigate to Products in the left sidebar
  3. Click + Add product
  4. Fill in your product details:
    • Name: “Premium Plan”
    • Description: “Access to premium features”
    • Pricing: Set your price (e.g., $29.00 USD)
    • Billing period: One-time or Recurring (monthly/yearly for subscriptions)
  5. Click Save product

Once your product is created:

  1. Click on the product you just created
  2. Scroll down to Payment links
  3. Click Create payment link
  4. Configure your payment link:
    • Collect customer addresses: Optional
    • Collect phone numbers: Optional
    • Allow promotion codes: Enable if you want to offer discounts
    • After payment: Choose “Show a confirmation page” or “Redirect to a URL”
  5. Click Create link

Stripe will generate a URL like: https://buy.stripe.com/test_xxxxxxxxxxxxxx

Each Payment Link URL is unique and includes:

  • A unique identifier for the product/price
  • Configuration settings (e.g., whether to collect addresses)
  • Optional prefilled customer information
  • Success and cancel redirect URLs

You can share this URL anywhere:

  • In emails
  • On social media
  • Embedded in buttons on your website
  • In QR codes
  • Via SMS

3.3 Creating Multiple Pricing Tiers

For most SaaS applications, you’ll want multiple pricing tiers. Let’s create a complete pricing structure:

  1. Create three products:

    • Starter Plan: $9/month
    • Professional Plan: $29/month
    • Enterprise Plan: $99/month
  2. For each product, create a Payment Link

  3. Save each Payment Link URL—we’ll use these in our Next.js application

Here’s what a typical SaaS pricing structure might look like:

Pricing Page
Starter - $9/mo
Professional - $29/mo
Enterprise - $99/mo
Payment Link 1
Payment Link 2
Payment Link 3
Stripe Checkout
Success Redirect
Cancel Redirect

Now that we have our Payment Links created in Stripe, let’s integrate them into our Next.js application.

4.1 Creating a Pricing Configuration File

First, let’s create a configuration file to store our Payment Links and pricing information. Create src/config/pricing.ts:

export interface PricingTier {
name: string;
description: string;
price: number;
currency: string;
interval: 'month' | 'year' | 'one-time';
features: string[];
paymentLink: string;
recommended?: boolean;
}
export const pricingTiers: PricingTier[] = [
{
name: 'Starter',
description: 'Perfect for individuals and small projects',
price: 9,
currency: 'USD',
interval: 'month',
paymentLink: 'https://buy.stripe.com/test_xxxxxxxxxxxxxx', // Replace with your actual link
features: [
'10 projects',
'5GB storage',
'Basic analytics',
'Email support',
'Community access',
],
},
{
name: 'Professional',
description: 'For professionals who need more power',
price: 29,
currency: 'USD',
interval: 'month',
paymentLink: 'https://buy.stripe.com/test_yyyyyyyyyyyyyy', // Replace with your actual link
recommended: true,
features: [
'Unlimited projects',
'50GB storage',
'Advanced analytics',
'Priority email support',
'Custom integrations',
'API access',
],
},
{
name: 'Enterprise',
description: 'For teams that need enterprise features',
price: 99,
currency: 'USD',
interval: 'month',
paymentLink: 'https://buy.stripe.com/test_zzzzzzzzzzzzzz', // Replace with your actual link
features: [
'Unlimited everything',
'500GB storage',
'Real-time analytics',
'Dedicated support',
'Custom integrations',
'SSO & SAML',
'SLA guarantee',
],
},
];

4.2 Building the Pricing Page Component

Now let’s create a beautiful pricing page that displays our tiers. Create src/components/PricingCard.tsx:

'use client';
import { PricingTier } from '@/config/pricing';
interface PricingCardProps {
tier: PricingTier;
}
export default function PricingCard({ tier }: PricingCardProps) {
const handleSubscribe = () => {
// Redirect to Stripe Payment Link
window.location.href = tier.paymentLink;
};
return (
<div
className={`relative flex flex-col rounded-2xl border p-8 shadow-sm ${
tier.recommended
? 'border-blue-500 ring-2 ring-blue-500'
: 'border-gray-200'
}`}
>
{tier.recommended && (
<div className="absolute -top-4 left-1/2 -translate-x-1/2">
<span className="rounded-full bg-blue-500 px-4 py-1 text-sm font-semibold text-white">
Recommended
</span>
</div>
)}
<div className="mb-6">
<h3 className="text-2xl font-bold">{tier.name}</h3>
<p className="mt-2 text-sm text-gray-600">{tier.description}</p>
</div>
<div className="mb-6">
<div className="flex items-baseline">
<span className="text-5xl font-bold">${tier.price}</span>
<span className="ml-2 text-gray-600">
/{tier.interval === 'one-time' ? 'once' : tier.interval}
</span>
</div>
</div>
<button
onClick={handleSubscribe}
className={`mb-6 w-full rounded-lg px-6 py-3 font-semibold transition-colors ${
tier.recommended
? 'bg-blue-500 text-white hover:bg-blue-600'
: 'bg-gray-900 text-white hover:bg-gray-800'
}`}
>
Get Started
</button>
<ul className="space-y-3">
{tier.features.map((feature, index) => (
<li key={index} className="flex items-start">
<svg
className="mr-3 h-5 w-5 flex-shrink-0 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
<span className="text-sm text-gray-700">{feature}</span>
</li>
))}
</ul>
</div>
);
}

4.3 Creating the Pricing Page

Create src/app/pricing/page.tsx:

import PricingCard from '@/components/PricingCard';
import { pricingTiers } from '@/config/pricing';
export default function PricingPage() {
return (
<div className="min-h-screen bg-gradient-to-b from-white to-gray-50 py-16">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="text-center">
<h1 className="text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl">
Simple, Transparent Pricing
</h1>
<p className="mt-4 text-lg text-gray-600">
Choose the perfect plan for your needs. Cancel anytime.
</p>
</div>
{/* Pricing Cards */}
<div className="mt-16 grid gap-8 lg:grid-cols-3">
{pricingTiers.map((tier) => (
<PricingCard key={tier.name} tier={tier} />
))}
</div>
{/* FAQ or Additional Info */}
<div className="mt-16 text-center">
<p className="text-sm text-gray-600">
All plans include a 14-day free trial. No credit card required.
</p>
</div>
</div>
</div>
);
}

4.4 Adding Success and Cancel Pages

When customers complete or cancel a payment, they’ll be redirected to URLs you specify. Let’s create these pages.

Create src/app/success/page.tsx:

'use client';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { Suspense } from 'react';
function SuccessContent() {
const searchParams = useSearchParams();
const sessionId = searchParams.get('session_id');
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-b from-green-50 to-white">
<div className="max-w-md rounded-lg bg-white p-8 text-center shadow-lg">
<div className="mb-4 flex justify-center">
<div className="rounded-full bg-green-100 p-3">
<svg
className="h-12 w-12 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
</div>
<h1 className="mb-2 text-3xl font-bold text-gray-900">
Payment Successful!
</h1>
<p className="mb-6 text-gray-600">
Thank you for your subscription. Your account has been activated.
</p>
{sessionId && (
<p className="mb-6 text-sm text-gray-500">
Session ID: {sessionId.slice(0, 20)}...
</p>
)}
<div className="space-y-3">
<Link
href="/dashboard"
className="block w-full rounded-lg bg-blue-500 px-6 py-3 font-semibold text-white transition-colors hover:bg-blue-600"
>
Go to Dashboard
</Link>
<Link
href="/"
className="block w-full text-sm text-gray-600 hover:text-gray-900"
>
Back to Home
</Link>
</div>
</div>
</div>
);
}
export default function SuccessPage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<SuccessContent />
</Suspense>
);
}

Create src/app/cancel/page.tsx:

import Link from 'next/link';
export default function CancelPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-b from-gray-50 to-white">
<div className="max-w-md rounded-lg bg-white p-8 text-center shadow-lg">
<div className="mb-4 flex justify-center">
<div className="rounded-full bg-gray-100 p-3">
<svg
className="h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</div>
</div>
<h1 className="mb-2 text-3xl font-bold text-gray-900">
Payment Cancelled
</h1>
<p className="mb-6 text-gray-600">
No charges were made. You can try again whenever you're ready.
</p>
<div className="space-y-3">
<Link
href="/pricing"
className="block w-full rounded-lg bg-blue-500 px-6 py-3 font-semibold text-white transition-colors hover:bg-blue-600"
>
Back to Pricing
</Link>
<Link
href="/"
className="block w-full text-sm text-gray-600 hover:text-gray-900"
>
Back to Home
</Link>
</div>
</div>
</div>
);
}

Now you have a complete pricing page with Payment Links integration! When users click “Get Started”, they’ll be redirected to Stripe’s hosted checkout page, and after payment, they’ll be sent back to your success or cancel page.

5. Implementing the Stripe Customer Portal

One of the most powerful features of Stripe is the Customer Portal. It’s a hosted page where customers can manage their own subscriptions, payment methods, and billing information—all without you having to build any UI for it.

5.1 Understanding the Customer Portal

The Stripe Customer Portal allows customers to:

  • Update payment methods
  • View billing history and invoices
  • Manage subscriptions (upgrade, downgrade, cancel)
  • Update billing information
  • Download receipts

Here’s how the Customer Portal fits into your application flow:

UserNextJSStripeClicks "Manage Subscription"Create portal session with customer IDPOST /billing_portal/sessionsReturn portal URLRedirect to portal URLManages subscriptionRedirects back to appSends webhook eventsUpdate customer dataUserNextJSStripe

5.2 Enabling the Customer Portal in Stripe

Before we can use the Customer Portal in our code, we need to enable and configure it in the Stripe Dashboard:

  1. Go to your Stripe Dashboard
  2. Navigate to Settings > Billing > Customer Portal
  3. Click Activate on the Customer Portal
  4. Configure the following settings:
    • Business information: Add your business name and website
    • Functionality: Enable features like:
      • Cancel subscriptions
      • Update payment methods
      • View invoice history
      • Update billing information
    • Branding: Customize colors to match your brand
  5. Set the Default redirect URL to http://localhost:3000/dashboard (or your production URL)
  6. Click Save

5.3 Creating the Portal Session API Route

Now let’s create an API route that generates a Customer Portal session. Create src/app/api/create-portal-session/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
export async function POST(req: NextRequest) {
try {
const { customerId } = await req.json();
if (!customerId) {
return NextResponse.json(
{ error: 'Customer ID is required' },
{ status: 400 }
);
}
// Create a portal session
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
});
return NextResponse.json({ url: portalSession.url });
} catch (error) {
console.error('Error creating portal session:', error);
return NextResponse.json(
{ error: 'Failed to create portal session' },
{ status: 500 }
);
}
}

5.4 Creating a Customer Dashboard Component

Let’s create a dashboard where customers can access the portal. Create src/components/CustomerDashboard.tsx:

'use client';
import { useState } from 'react';
interface CustomerDashboardProps {
customerId: string;
customerEmail: string;
subscriptionStatus?: string;
currentPlan?: string;
}
export default function CustomerDashboard({
customerId,
customerEmail,
subscriptionStatus,
currentPlan,
}: CustomerDashboardProps) {
const [isLoading, setIsLoading] = useState(false);
const handleManageSubscription = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/create-portal-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ customerId }),
});
const { url } = await response.json();
if (url) {
window.location.href = url;
}
} catch (error) {
console.error('Error opening portal:', error);
alert('Failed to open customer portal. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<div className="rounded-lg bg-white p-6 shadow-md">
<h2 className="mb-4 text-2xl font-bold">Account Overview</h2>
<div className="mb-6 space-y-3">
<div className="flex justify-between border-b pb-2">
<span className="font-medium text-gray-700">Email:</span>
<span className="text-gray-900">{customerEmail}</span>
</div>
{currentPlan && (
<div className="flex justify-between border-b pb-2">
<span className="font-medium text-gray-700">Current Plan:</span>
<span className="text-gray-900">{currentPlan}</span>
</div>
)}
{subscriptionStatus && (
<div className="flex justify-between border-b pb-2">
<span className="font-medium text-gray-700">Status:</span>
<span
className={`font-semibold ${
subscriptionStatus === 'active'
? 'text-green-600'
: 'text-yellow-600'
}`}
>
{subscriptionStatus.charAt(0).toUpperCase() +
subscriptionStatus.slice(1)}
</span>
</div>
)}
</div>
<button
onClick={handleManageSubscription}
disabled={isLoading}
className="w-full rounded-lg bg-blue-500 px-6 py-3 font-semibold text-white transition-colors hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{isLoading ? 'Loading...' : 'Manage Subscription'}
</button>
<p className="mt-4 text-center text-sm text-gray-600">
Update your payment method, view invoices, or cancel your subscription
</p>
</div>
);
}

5.5 Creating the Dashboard Page

Now let’s create a dashboard page that uses our component. Create src/app/dashboard/page.tsx:

import CustomerDashboard from '@/components/CustomerDashboard';
// In a real application, you would fetch this data from your database
// based on the authenticated user's session
export default function DashboardPage() {
// Example customer data - replace with actual data from your auth system
const customerData = {
customerId: 'cus_xxxxxxxxxxxxx', // This should come from your database
customerEmail: '[email protected]',
subscriptionStatus: 'active',
currentPlan: 'Professional',
};
return (
<div className="min-h-screen bg-gray-50 py-16">
<div className="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8">
<h1 className="mb-8 text-4xl font-bold">Dashboard</h1>
<CustomerDashboard {...customerData} />
<div className="mt-8 rounded-lg bg-white p-6 shadow-md">
<h2 className="mb-4 text-xl font-bold">Quick Actions</h2>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border border-gray-200 p-4">
<h3 className="font-semibold">Need help?</h3>
<p className="mt-2 text-sm text-gray-600">
Contact our support team for assistance
</p>
</div>
<div className="rounded-lg border border-gray-200 p-4">
<h3 className="font-semibold">Documentation</h3>
<p className="mt-2 text-sm text-gray-600">
Learn how to make the most of your subscription
</p>
</div>
</div>
</div>
</div>
</div>
);
}

6. Handling Webhooks for Customer Data

While Payment Links and the Customer Portal handle most of the UI, you still need to track customer data in your database. Webhooks are essential for keeping your application in sync with Stripe.

6.1 Understanding Critical Webhook Events

When using Payment Links and Customer Portal, these are the key events you should handle:

Stripe Events
checkout.session.completed
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted
invoice.paid
invoice.payment_failed
Create/Update Customer Record
Activate Subscription
Update Subscription Status
Deactivate Subscription
Record Payment
Handle Failed Payment

6.2 Setting Up the Webhook Endpoint

Create src/app/api/webhooks/stripe/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { headers } from 'next/headers';
import { stripe } from '@/lib/stripe';
import Stripe from 'stripe';
// This is important - it tells Next.js not to parse the body
export const dynamic = 'force-dynamic';
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = headers().get('stripe-signature');
if (!signature) {
return NextResponse.json(
{ error: 'No signature found' },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return NextResponse.json(
{ error: 'Webhook signature verification failed' },
{ status: 400 }
);
}
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
await handleCheckoutSessionCompleted(session);
break;
}
case 'customer.subscription.created': {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionCreated(subscription);
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionUpdated(subscription);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await handleSubscriptionDeleted(subscription);
break;
}
case 'invoice.paid': {
const invoice = event.data.object as Stripe.Invoice;
await handleInvoicePaid(invoice);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
await handleInvoicePaymentFailed(invoice);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true });
} catch (error) {
console.error('Error processing webhook:', error);
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 500 }
);
}
}
// Webhook handler functions
async function handleCheckoutSessionCompleted(
session: Stripe.Checkout.Session
) {
console.log('Checkout session completed:', session.id);
// TODO: Update your database with customer information
// const customerId = session.customer as string;
// const customerEmail = session.customer_email;
// const subscriptionId = session.subscription as string;
// Example:
// await db.customer.upsert({
// where: { stripeCustomerId: customerId },
// update: {
// email: customerEmail,
// stripeSubscriptionId: subscriptionId,
// },
// create: {
// stripeCustomerId: customerId,
// email: customerEmail,
// stripeSubscriptionId: subscriptionId,
// },
// });
}
async function handleSubscriptionCreated(subscription: Stripe.Subscription) {
console.log('Subscription created:', subscription.id);
// TODO: Update your database
// const customerId = subscription.customer as string;
// const status = subscription.status;
// const currentPeriodEnd = new Date(subscription.current_period_end * 1000);
// Example:
// await db.customer.update({
// where: { stripeCustomerId: customerId },
// data: {
// subscriptionStatus: status,
// subscriptionCurrentPeriodEnd: currentPeriodEnd,
// },
// });
}
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
console.log('Subscription updated:', subscription.id);
// Handle subscription changes (plan upgrades/downgrades)
// TODO: Update your database with new subscription details
}
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
console.log('Subscription deleted:', subscription.id);
// Handle subscription cancellation
// TODO: Revoke access in your application
}
async function handleInvoicePaid(invoice: Stripe.Invoice) {
console.log('Invoice paid:', invoice.id);
// Handle successful payment
// TODO: Update payment history, send receipt email
}
async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) {
console.log('Invoice payment failed:', invoice.id);
// Handle failed payment
// TODO: Send payment failure notification to customer
}

6.3 Testing Webhooks Locally

To test webhooks on your local machine, you’ll need the Stripe CLI:

  1. Install the Stripe CLI from stripe.com/docs/stripe-cli

  2. Login to Stripe:

Terminal window
stripe login
  1. Forward webhooks to your local server:
Terminal window
stripe listen --forward-to localhost:3000/api/webhooks/stripe
  1. The CLI will output a webhook signing secret. Add it to your .env.local:
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx
  1. Trigger a test event:
Terminal window
stripe trigger checkout.session.completed

You should see the webhook being received in your terminal and processed by your application.

Now that we have the basics working, let’s explore some advanced features of Payment Links.

7.1 Prefilling Customer Information

You can prefill customer information in Payment Links by adding query parameters:

// In your PricingCard component or anywhere you redirect to the payment link
const handleSubscribeWithPrefill = (email: string, name: string) => {
const url = new URL(tier.paymentLink);
url.searchParams.append('prefilled_email', email);
url.searchParams.append('client_reference_id', userId); // Track the user
window.location.href = url.toString();
};

7.2 Tracking Conversions with Client Reference ID

Use the client_reference_id parameter to track which user initiated the checkout:

const handleSubscribe = (userId: string) => {
const url = new URL(tier.paymentLink);
url.searchParams.append('client_reference_id', userId);
window.location.href = url.toString();
};

Then in your webhook:

async function handleCheckoutSessionCompleted(
session: Stripe.Checkout.Session
) {
const userId = session.client_reference_id;
// Update your user record with the Stripe customer ID
// await db.user.update({
// where: { id: userId },
// data: { stripeCustomerId: session.customer as string }
// });
}

If you need more flexibility, you can create Payment Links programmatically:

Create src/app/api/create-payment-link/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
export async function POST(req: NextRequest) {
try {
const { priceId, quantity = 1 } = await req.json();
const paymentLink = await stripe.paymentLinks.create({
line_items: [
{
price: priceId,
quantity: quantity,
},
],
after_completion: {
type: 'redirect',
redirect: {
url: `${process.env.NEXT_PUBLIC_APP_URL}/success`,
},
},
});
return NextResponse.json({ url: paymentLink.url });
} catch (error) {
console.error('Error creating payment link:', error);
return NextResponse.json(
{ error: 'Failed to create payment link' },
{ status: 500 }
);
}
}

7.4 Handling Promotional Codes

Enable promotional codes in your Payment Links to offer discounts:

  1. In the Stripe Dashboard, navigate to Products > Coupons
  2. Create a new coupon (e.g., “LAUNCH50” for 50% off)
  3. When creating your Payment Link, enable “Allow promotion codes”
  4. Customers can enter the code at checkout

You can also automatically apply a coupon via query parameters:

const handleSubscribeWithCoupon = (couponCode: string) => {
const url = new URL(tier.paymentLink);
url.searchParams.append('prefilled_promo_code', couponCode);
window.location.href = url.toString();
};

8. Production Considerations and Best Practices

Before deploying your Payment Links integration to production, consider these important points.

8.1 Environment Configuration

Create separate Payment Links for test and production:

src/config/pricing.ts
const getPaymentLink = (testLink: string, prodLink: string) => {
return process.env.NODE_ENV === 'production' ? prodLink : testLink;
};
export const pricingTiers: PricingTier[] = [
{
name: 'Starter',
// ...
paymentLink: getPaymentLink(
'https://buy.stripe.com/test_xxxxxx',
'https://buy.stripe.com/xxxxxxx'
),
},
];

8.2 Security Best Practices

Always validate webhook signatures:

// In your webhook route
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
// Never process unverified webhooks
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}

Store sensitive data securely:

  • Never expose Stripe Secret Keys in client-side code
  • Use environment variables for all API keys
  • Implement proper authentication before showing customer dashboards

8.3 Error Handling

Implement comprehensive error handling:

// Example error handling in portal session creation
export async function POST(req: NextRequest) {
try {
const { customerId } = await req.json();
if (!customerId) {
return NextResponse.json(
{ error: 'Customer ID is required' },
{ status: 400 }
);
}
// Verify customer exists in Stripe
try {
await stripe.customers.retrieve(customerId);
} catch (error) {
return NextResponse.json(
{ error: 'Customer not found in Stripe' },
{ status: 404 }
);
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
});
return NextResponse.json({ url: portalSession.url });
} catch (error) {
console.error('Error creating portal session:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

8.4 Database Schema Recommendations

Here’s a recommended database schema for tracking customers and subscriptions:

// Example Prisma schema
model User {
id String @id @default(cuid())
email String @unique
name String?
// Stripe fields
stripeCustomerId String? @unique
stripeSubscriptionId String? @unique
stripePriceId String?
stripeCurrentPeriodEnd DateTime?
stripeSubscriptionStatus String? // active, canceled, past_due, etc.
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

8.5 Monitoring and Logging

Implement proper logging for debugging:

// Create a logger utility
export function logStripeEvent(event: Stripe.Event) {
console.log(`[Stripe Webhook] ${event.type}`, {
id: event.id,
created: new Date(event.created * 1000),
livemode: event.livemode,
});
}
// Use in webhook handler
switch (event.type) {
case 'checkout.session.completed':
logStripeEvent(event);
await handleCheckoutSessionCompleted(event.data.object);
break;
}

8.6 Testing Checklist

Before going live, test these scenarios:

  • Successful payment flow
  • Payment cancellation
  • Customer portal access
  • Subscription upgrade
  • Subscription downgrade
  • Subscription cancellation
  • Failed payment handling
  • Webhook signature validation
  • Customer data synchronization
  • Promotional code application

9. Comparing Payment Methods: Quick Reference

Here’s a comprehensive comparison to help you choose the right approach:

FeaturePayment LinksCheckout SessionsCustomer Portal
Setup ComplexityVery EasyModerateVery Easy
Code RequiredMinimalSignificantMinimal
CustomizationLimitedExtensiveLimited
Best ForMVPs, Simple ProductsComplex CartsSelf-Service
Dynamic PricingVia APIYesN/A
Metadata SupportLimitedFullN/A
URL SharingYesNoNo
Hosted by StripeYesYesYes

10. Conclusion

Congratulations! You’ve learned how to implement Stripe Payment Links and the Customer Portal in a Next.js 15 application. This approach gives you a production-ready payment system with minimal code.

What We Covered

  1. Payment Links: The fastest way to accept payments with no backend code
  2. Customer Portal: Self-service subscription management
  3. Webhooks: Keeping your database in sync with Stripe
  4. Best Practices: Security, error handling, and production readiness

When to Use This Approach

Payment Links and Customer Portal are perfect for:

  • ✅ MVPs and rapid prototyping
  • ✅ SaaS applications with simple pricing
  • ✅ Digital products and subscriptions
  • ✅ Small to medium-sized businesses
  • ✅ Projects with limited development resources

When to Use Checkout Sessions Instead

Consider the traditional Checkout Session approach (from Part 1) when you need:

  • ❌ Complex shopping carts with multiple items
  • ❌ Dynamic pricing based on user input
  • ❌ Extensive customization of the checkout flow
  • ❌ Custom metadata for each transaction
  • ❌ Multi-step checkout processes

Next Steps

To further enhance your Stripe integration:

  1. Add authentication: Implement user authentication to protect the dashboard
  2. Email notifications: Send custom emails for payment confirmations
  3. Usage-based billing: Explore metered billing for API or usage-based products
  4. Analytics: Track conversion rates and customer lifetime value
  5. Multi-currency: Support international customers with local currencies

Additional Resources

The beauty of Payment Links and the Customer Portal is that they let you focus on building your product while Stripe handles the complexity of payments. You get enterprise-grade payment processing with just a few lines of code!

Happy coding, and may your conversions be high! 🚀

Enjoyed this article? Subscribe for more!