How to Create a Scalable KYC Onboarding Flow in React with Shadcn UI


Every B2B SaaS product with a compliance requirement (like banking, lending, payroll, or crypto) hits the same wall early on: before you can let a business use your platform, you need to verify who they are.

That means collecting a business type, pulling in registration documents, and showing the user where their verification stands, all without making onboarding feel like a customs form.

This article breaks down a working three step KYC (Know Your Customer) flow built with Shadcn UI: a stepper for progress, a radio group for account type, a file upload zone for documents, and an alert for verification status. You’ll see the actual component code, not a simplified stand-in, along with the reasoning behind each decision.

You can try the finished flow at onboarding-kyc-flow.vercel.app. Click through it once before reading on, as it makes the code below easier to follow. And it also comes in dark and light mode.

Table of Contents

Prerequisites

Before working through this flow, you should be comfortable with React function components and hooks, specifically useState, useRef, and useEffect.

You should have:

  • A Next.js project with the App Router and shadcn/ui already initialized, since this article doesn’t cover that initial setup.

  • A v0 account is optional. You can also use Bolt or Lovable, which support the same shadcn MCP prompt feature.

What You’re Building

The flow has three steps:

  1. Account type: The user picks Startup, Enterprise, or Government. This decision drives the rest of the experience. It’s shown back to the user as a confirmation line, and would typically decide which workspace defaults get applied.

  2. Document upload: The user drags in a business registration document, a tax return, or a company registry export, in PDF or CSV format.

  3. Verification status: The user sees a live status: checking in progress, then either verified or an issue that needs attention.

Project Structure

The project is a standard Next.js app with shadcn/ui already initialized. Here’s the top-level layout:

onboarding-kyc-flow/
├── .vercel/
├── app/
├── components/
├── lib/
├── public/
├── .env.development.local
├── .gitignore
├── components.json
├── next-env.d.ts
├── next.config.mjs
├── package.json
├── pnpm-lock.yaml
├── postcss.config.mjs
├── tsconfig.json
└── tsconfig.tsbuildinfo

components.json is the file the shadcn CLI reads to know where your components live and which style and primitives you’re using. components/ holds the shared UI pieces (Alert, Badge, Button, Card, Progress, RadioGroup, Separator) that the flow is built from. lib/utils.ts provides the cn helper used throughout the flow to combine conditional class names. app/ holds the page itself, shown in full below.

Radix UI vs Base UI: Which Primitives this Flow Uses

Shadcn components aren’t tied to one underlying primitive library. Most of the ecosystem defaults to Radix UI, but Base UI has become a solid alternative, and it’s what this flow is built on.

The underlying primitive library can affect how a component behaves and how you work with it in your project. If you’re pulling components from a set like Shadcn UI, check which primitive library it targets before mixing components from different sources.

Mixing Radix-based and Base UI-based components generally works, but it means using two different unstyled primitive libraries in the same project. You can compare Radix UI and Base UI here.

Scaffolding the Flow with v0 and an MCP Server

An MCP (Model Context Protocol) server exposes a component library to an AI coding assistant as a set of callable tools. Instead of the assistant guessing at component names and props from training data, it queries the server for the real, current API.

This matters here specifically, since there are now several shadcn-style component sets with similar names and different props.

The Shadcn Components library publishes an MCP server for its free set, connected to v0 by following its getting started guide. The video below covers the connection step by step. The same generated output can also be copied into Lovable or Bolt through their copy prompt feature, so the workflow isn’t locked to one AI builder.

The prompt used to scaffold this flow looked like this:

Create an Enterprise SaaS Onboarding & KYC Flow. Use free components of the shadcn space MCP server: shadcn alert, shadcn radio group, shadcn stepper, shadcn file upload. Only use free components, not pro ones, and list which free component was used for each part.

Step 1: Account Type (stepper) – radio group for Startup, Enterprise, or Government

Step 2: Upload Documents (stepper) – file upload for a business registration document

Step 3: Verification (stepper) – alert showing verification status

This produces a working first draft fast. What follows is the result after cleaning that draft up: real state management, real validation, and states that a generated draft tends to skip.

Step 1: Account Type with a Radio Group

Account type is the first decision in the flow because it’s the one most likely to affect what comes after it. Asking it early keeps the rest of the flow feeling relevant to the choice the user just made.

const tiers: { id: Tier; name: string; description: string; tag: string }[] = [
  { id: 'startup', name: 'Startup', description: 'For teams building and scaling fast', tag: 'Up to 25 seats' },
  { id: 'enterprise', name: 'Enterprise', description: 'For established teams with advanced needs', tag: 'Unlimited seats' },
  { id: 'government', name: 'Government', description: 'For public sector and regulated teams', tag: 'FedRAMP-ready' },
]
 setTier(value as Tier)} className="grid gap-3">
  
Account type {tiers.map((item) => ( ))}

Two things worth noticing here. The tier data lives in a plain array outside the component, so adding a fourth tier later is a one-line change, not a markup change. And the fieldset with a visually hidden (sr-only) legend groups the three options as one related choice for screen readers. Sighted users never see it, since the card title above already states “Choose your account type” visually.

This step uses a shadcn radio group rather than a select or checkboxes, since account type is a single, mutually exclusive choice, and a radio group is the only one of the three that makes both the options and the current selection visible at a glance.

Live Preview:

Step 1: Account type with a radio group


Step 2: Document Upload with Drag and Drop

The upload zone needs to handle three states cleanly: nothing selected yet, a file selected and ready, and a rejected file with a specific reason why.

function FileUpload({ file, onFile, onRemove, error }: {
  file: File | null
  onFile: (file: File) => void
  onRemove: () => void
  error: string
}) {
  const inputRef = useRef(null)
  const [dragging, setDragging] = useState(false)

  const accept = (candidate: File) => {
    if (candidate.type !== 'application/pdf' && candidate.type !== 'text/csv' && !candidate.name.toLowerCase().endsWith('.csv')) {
      return 'Upload a PDF or CSV file only.'
    }
    if (candidate.size > 10 * 1024 * 1024) {
      return 'Files must be smaller than 10 MB.'
    }
    onFile(candidate)
    return ''
  }

  return (
    
  )
}

The accept function is the whole validation layer, and it runs from two different places: the change handler on the hidden file input, and the drop handler on the drag zone.

Both paths call the same function, so a file dragged in gets the same validation checks as a file selected by clicking browse. It ensures that only PDF or CSV files are allowed, regardless of how the file is added.

This is where shadcn file upload earns its place over a plain : the drag zone, the selected state, and the rejected state are all handled as one component instead of three separate pieces wired together by hand.

Live Preview:

Step 2: Document upload with drag and drop

Step 3: Verification Status with an Alert

Verification isn’t instant, so the interface needs to say clearly what’s happening and what happens next, rather than showing a spinner with no explanation.

{verified ? (
  
    
) : (
  <>
    
      
    

Checking business registry {checking ? '68%' : '100%'}

)}

Pairing the shadcn alert with a progress bar does two jobs at once: the alert states the current status in words, while the progress bar gives a rough sense of how much is left, without promising a specific time. Neither one alone tells the full story, the alert alone feels static, and a progress bar alone doesn’t explain what’s actually being checked.

Worth adding here, and easy to skip when a demo only shows the success path: a mismatch state, where the tax ID on the document doesn’t match the company registry, deserves its own alert with a clear next step: contact support or re-upload a corrected document. It’s not shown above, since the flow currently resolves to either checking or verified, but it’s the state a production version of this flow would hit the most.

Live Preview:

Step 3: Verification status with an alert


Adding a Stepper to the Flow

The stepper is the visual anchor of the whole flow. It’s the piece that tells the user how much is left before the checking and account-type-selecting are done.

function Stepper({ current }: { current: Step }) {
  return (
    



Source link

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top