Setting up Stripe Connect with Next.js
A Complete Guide to Onboarding Sellers
Table of contents
Introduction
Stripe Connect enables platforms to facilitate payments between customers and service providers. In this guide, we'll implement Connect for an education platform where students can register and pay for programs offered by different providers (teachers, schools, or organizations).
Here's how it works:
Providers (organizations) connect their Stripe accounts to our platform
Students browse and select programs offered by these providers
When a student registers for a program, they pay through our platform
The payment is automatically split:
The majority goes to the provider
A small platform fee is retained by us
Stripe handles all the money movement and compliance
This setup is perfect for educational marketplaces because:
Providers maintain control of their financial information
The platform handles the payment flow seamlessly
Stripe manages the complexity of payment routing
Each provider gets their own connected account for receiving payments
For example, when a student pays $100 for a class:
$90 goes directly to the provider
$10 is retained as the platform fee (configurable)
All money movement is handled automatically by Stripe
Prerequisites
Next.js app using App Router and Typescript
Stripe account
Stripe Dashboard Setup
This guide focuses on the Next.js implementation, assuming you've already completed the basic Stripe setup. For the Stripe parts:
Create your Stripe account at dashboard.stripe.com
Enable Connect in your Stripe Dashboard:
Follow the setup guide to set up Connect for your account. Once complete, your account will be a Connect account, meaning that it’s a platform for onboarding other accounts
Add these platform settings:
Platform profile information
Branding settings
Business type settings
Set up your webhook endpoints: dashboard.stripe.com → Developers → Webhooks → Add endpoint
Add these URLs:
Development: http://localhost:3000/api/stripe/webhook*
Production: https://yourdomain.com/api/stripe/webhook*
*Note: this doesn’t have to match exactly, as long as you place your return route in the correct location. In this case, it would be app/api/stripe/webhook/route.ts
.
For the account setup, we will only need to monitor one event in our webhook: account.updated
.
Environment Variables
Get your API keys: dashboard.stripe.com → Developers → API keys
Add these to your .env.local
:
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_ROOT_URL=
http://localhost:3000
//your website url
Installation
First, install the required Stripe packages:
npm install stripe @stripe/stripe-js @stripe/react-stripe-js
stripe
: Server-side Stripe SDK@stripe/stripe-js:
Client-side Stripe SDK@stripe/react-stripe-js:
React components for Stripe
Initialize Stripe
Create a server-side Stripe instance in @/lib/stripe.ts
:
import Stripe from 'stripe';
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error('Missing STRIPE_SECRET_KEY environment variable');
}
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
aapiVersion: '2025-02-24.acacia', // Use the latest API version, must match installation
typescript: true,
});
This creates a reusable Stripe instance for server-side operations. The typescript: true option enables better TypeScript support.
You'll use this stripe instance in your server actions when interacting with Stripe's API. In fact, this is how you’ll use to call stripe.accounts.create
and stripe.accountLinks.create
when getting your sellers to sign up for stripe connect.
Database Schema
First, let's add Stripe fields to your organization/provider/seller model (using Prisma as an example):
model Organization {
id String @id @default(cuid())
name String
// Stripe Connect fields
stripeConnectedAccountId String? // The connected account ID from Stripe
stripeAccountEnabled Boolean? // Whether the account can accept payments
// ... other fields
}
1. Creating a Connected Account
Create a server action to handle Connect account creation. src/stripe/actions/stripe-actions.ts
:
import { stripe } from '@/lib/stripe'; // import stripe initialization we created earlier
export async function createStripeConnectAccount(organizationId: string) {
try {
const organization = await prisma.organization.findUnique({
where: { id: organizationId },
select: { name: true }
});
if (!organization) {
throw new Error('Organization not found');
}
// Create a Connect account
const account = await stripe.accounts.create({
type: 'express', // express offers a simpler user experience
email: organization.email,
business_type: 'company',
company: { //prefill any organization info here
name: organization.name || '',
phone: organization.phone || '',
address: {
line1: organization.streetAddress || '',
city: organization.city || '',
state: organization.state || '',
postal_code: organization.zip || '',
country: 'US',
},
},
business_profile: {
name: organization.name,
},
metadata: {
organizationId //add any other data you need
}
});
// Save the account ID
await prisma.organization.update({
where: { id: organizationId },
data: {
stripeConnectedAccountId: account.id,
stripeAccountEnabled: false // Will be updated via webhook
}
});
// Create an account link for onboarding
const accountLink = await stripe.accountLinks.create({
account: account.id,
refresh_url: `${process.env.NEXT_PUBLIC_ROOT_URL}/provider/${organizationId}/admin/settings`,
return_url: `${process.env.NEXT_PUBLIC_ROOT_URL}/provider/${organizationId}/admin/settings?setup_complete=true`,
type: 'account_onboarding',
});
return { url: accountLink.url };
} catch (error) {
console.error('Error creating Connect account:', error);
throw error;
}
}
Understanding the Connect Onboarding Flow
When you call createStripeConnectAccount
and redirect to the account link URL, here's what happens:
Initial Redirect:
// This URL from createStripeAccountLink redirects the provider to Stripe
window.location.href = accountLink.url;
Stripe Hosted Onboarding
The provider is taken to Stripe's hosted onboarding page where they'll need to provide:
Business information
Bank account details for payouts
Tax information
Identity verification documents
Business verification documents (if required)
Return and Refresh URLs
We specified two important URLs in our account link creation:
const accountLink = await stripe.accountLinks.create({
account: account.id,
refresh_url: `${process.env.NEXT_PUBLIC_ROOT_URL}/provider/${organizationId}/admin/settings`,
return_url: `${process.env.NEXT_PUBLIC_ROOT_URL}/provider/${organizationId}/admin/settings?setup_complete=true`,
type: 'account_onboarding',
});
return_url
: Where providers are sent after completing onboardingrefresh_url
: Where to send providers if their session expires (they'll get a fresh link to continue)
Both the return and refresh URLs point to our settings page, which handles different scenarios based on the search parameters. If returning from Stripe, it calls the handleStripeRedirect
function to update our database and refresh the page. Otherwise it simply displays status and RegistrationSettings
component which will handle onboarding initiation for new clients.
mysite.com/provider/[providerId]/admin/settings/page.tsx
export default async function OrganizationSettingsPage({ params, searchParams }: PageProps) {
const organization = await getOrgById(params.id) as any;
// Handle return from Stripe onboarding
if (searchParams.setup_complete) {
// Handle successful completion
await handleStripeRedirect(organization.stripeConnectedAccountId);
revalidatePath(`/provider/${params.id}/admin/settings`);
redirect(`/provider/${params.id}/admin/settings`);
}
return (
<div className="max-w-4xl mx-auto p-6">
<h1 className="text-2xl font-semibold mb-6">Provider Settings</h1>
{searchParams.setup_complete === 'true' && (
<div className="rounded-md bg-green-50 p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<CheckCircleIcon className="h-5 w-5 text-green-400" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-green-800">
Stripe Account Created
</h3>
<div className="mt-2 text-sm text-green-700">
<p>See below for account status.</p>
</div>
</div>
</div>
</div>
)}
<RegistrationSettings organization={organization}/>
</div>
);
}
Handling Stripe Redirect
Here is the handleStripeRedirect
function from stripe-actions.ts
async function handleStripeRedirect(stripeAccountId: string) {
if (!stripeAccountId) {
throw new Error('No Stripe account ID provided');
}
// Retrieve the latest account information from Stripe
const account = await stripe.accounts.retrieve(stripeAccountId);
// Update the organization's Stripe status in our database
await prisma.organization.update({
where: { stripeConnectedAccountId: stripeAccountId },
data: {
stripeAccountEnabled: account.charges_enabled &&
account.capabilities?.card_payments === 'active'
}
});
}
This ensures that as soon as a provider completes their Stripe setup:
We fetch their latest status
Update our database
Show the updated status in the UI
While we also handle status updates via webhooks, this immediate check provides a better user experience by updating the UI right away.
2. Account Setup UI Component
Create a component to handle the Connect setup flow. This component handles the Stripe Connect setup button and displays the account status:
'use client';
import { useState } from 'react';
import { createStripeConnectAccount, createStripeAccountLink } from '@/src/stripe/actions/stripe-actions';
interface RegistrationSettingsProps {
organization: {
id: string;
stripeConnectedAccountId?: string | null;
stripeAccountEnabled?: boolean;
};
}
export default function RegistrationSettings({ organization }: RegistrationSettingsProps) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleStripeSetup = async () => {
setIsLoading(true);
setError(null); // reset error to null before handling
try {
// If organization already has a Stripe account, create new account link
// Otherwise, create new Connect account
const response = organization.stripeConnectedAccountId
? await createStripeAccountLink(organization.id) // Resume incomplete setup
: await createStripeConnectAccount(organization.id); // Create new account
if (response.url) {
window.location.replace(response.url);
} else {
throw new Error('Failed to create Stripe setup URL');
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to set up Stripe account');
} finally {
setIsLoading(false);
}
};
return (
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-xl font-semibold mb-4">Payment Settings</h2>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium">Stripe Connect Status</h3>
<p className="text-sm text-gray-500">
{organization.stripeAccountEnabled
? 'Ready to accept payments'
: 'Setup required to accept payments'}
</p>
</div>
<button
onClick={handleStripeSetup}
disabled={isLoading}
className="bg-blue-600 text-white px-4 py-2 rounded-md
hover:bg-blue-700 disabled:opacity-50"
>
{isLoading
? 'Loading...'
: organization.stripeConnectedAccountId
? 'Complete Setup'
: 'Connect with Stripe'}
</button>
</div>
{error && (
<div className="text-red-600 text-sm">{error}</div>
)}
</div>
</div>
);
}
Explaining handleStripeSetup
The most important part of the RegistrationSettings component is the handleStripeSetup function. This function handles two scenarios:
- New Account Creation (
!stripeConnectedAccountId
)
await createStripeConnectAccount(
organization.id
)
Creates a new Stripe Connect account
Associates it with your organization
Returns URL for initial onboarding
- Resume Incomplete Setup (
stripeConnectedAccountId
exists)
Creates a fresh account link
Allows provider to continue where they left off
Preserves existing Connect account
The function:
Shows loading state during API calls
Handles errors gracefully
Redirects to Stripe's onboarding when ready
Works for both new and existing accounts
This is the heart of the Connect integration - it bridges your platform with Stripe's onboarding flow, making it seamless for providers to set up their payment processing. This component is used in the settings page:
<RegistrationSettings organization={organization}/>
3. Handling Account Updates via Webhook
If you have not already done so, create a webhook handler to track Connect account status. In the post on Embedded Checkout, we’ll be adding checkout-related webhooks into this file. For now, we only need account.updated
.
import { headers } from 'next/headers';
import { stripe } from '@/lib/stripe';
import prisma from '@/lib/prisma';
import Stripe from 'stripe';
export async function POST(req: Request) {
const body = await req.text();
const signature = headers().get('stripe-signature');
if (!signature || !process.env.STRIPE_WEBHOOK_SECRET) {
return new Response('Missing signature', { status: 400 });
}
try {
const event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
switch (event.type) {
case 'account.updated':
const account = event.data.object as Stripe.Account;
// update database to indicate if stripe account is ready for payments
await prisma.organization.update({
where: { stripeConnectedAccountId: account.id },
data: {
stripeAccountEnabled: account.charges_enabled &&
account.capabilities?.card_payments === 'active'
}
});
break;
}
return new Response('Webhook processed', { status: 200 });
} catch (err) {
console.error('Webhook error:', err);
return new Response('Webhook error', { status: 400 });
}
}
Test
When you’re ready to test, click on the Connect with Stripe button, which should redirect you to the Stripe Connect setup page. After entering some test data and completing the setup using test bank accounts provided, you should be redirected back to your settings page with your completed status:
Conclusion
This setup provides a foundation for a marketplace using Stripe Connect in your Next.js application. Once this is in place, you can implement the Embedded Checkout flow for payments. Test it by clicking on the registration link and filling in some test data into your stripe connect flow. When finished, you should be redirected back to your settings page, informing you of your registration status.
In the next post, I will show how to implement embedded checkout for connected accounts.