Setting up Stripe Connect with Next.js

A Complete Guide to Onboarding Sellers

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:

  1. Providers (organizations) connect their Stripe accounts to our platform

  2. Students browse and select programs offered by these providers

  3. 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:

*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.createwhen 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:

  1. Initial Redirect:

    // This URL from createStripeAccountLink redirects the provider to Stripe

    window.location.href = accountLink.url;

  2. 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 onboarding

  • refresh_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:

  1. New Account Creation (!stripeConnectedAccountId)

await createStripeConnectAccount(organization.id)

  • Creates a new Stripe Connect account

  • Associates it with your organization

  • Returns URL for initial onboarding

  1. 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.