DEV Community

Kiyo
Kiyo

Posted on

How I built a context-switching profile builder with Next.js, dnd-kit, and Supabase

I've been building Introlo, a link-in-bio tool where your single URL shows different content depending on the context you set. I call these "persona modes" — you might show one version of your profile when you're networking, another when you're fundraising, another when you're promoting creative work.

This post is about how I built the Studio (the profile editor) and the architectural decisions behind making persona modes work. Hopefully useful if you're building anything with dynamic content rendering, drag-and-drop interfaces, or multi-state profiles.

The Stack

  • React 18 + Next.js 14 (App Router)
  • TypeScript
  • Tailwind CSS (custom component layer, no shadcn/MUI/Chakra)
  • Supabase (auth + database)
  • Stripe (payments)
  • dnd-kit (drag-and-drop)

No component library. Every UI element is custom. That was a deliberate choice I'll get into below.

The Core Problem: One URL, Multiple States

The fundamental challenge is this: a single URL (introlo.com/yourname) needs to render different content based on which persona mode the owner has active. The visitor doesn't choose. The owner sets it in Studio.

This means the rendering layer needs to:

  1. Resolve the username from the URL
  2. Look up which persona mode is currently active
  3. Fetch the blocks, layout, and content associated with that mode
  4. Render it — fast

The important architectural decision: persona modes aren't separate pages or profiles. They're visibility layers on top of a single set of content blocks. Each block has a visibility array that defines which modes it appears in. When you switch modes, you're not loading a different page. You're filtering which blocks render.

This matters for performance. There's no additional database query when the mode changes. The blocks are already loaded. The mode switch is a client-side filter operation.

Why dnd-kit Over Alternatives

I evaluated three options for drag-and-drop: react-beautiful-dnd, dnd-kit, and building from scratch with native drag events.

react-beautiful-dnd was the obvious first choice since it's well-documented and widely used. But it's been in maintenance mode and the API felt rigid for what I needed. I wanted drag handles, sortable lists, and the ability to drag blocks between different containers (moving a block from one section to another).

Native drag events would have been lightweight but the amount of edge-case handling (touch devices, scroll containers, accessibility) would have eaten weeks.

dnd-kit won because:

  • It's modular. You import only what you need (@dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities).
  • The sensor system handles mouse, touch, and keyboard out of the box.
  • Custom drag overlays let me show a styled preview of the block being dragged, not just a ghost clone.
  • It plays well with React 18's concurrent features.

The one thing that tripped me up early: dnd-kit's sortable context needs a stable array of IDs. If your block list is filtered by persona mode, the ID array changes when you switch modes. I had to make sure the sortable container re-initializes cleanly on mode switch rather than trying to reconcile the old sort order with a new filtered list.

No Component Library - Why and How

This was the most opinionated decision in the stack. Every tutorial and boilerplate I looked at used shadcn/ui or Radix or Chakra. I skipped all of them.

The reason: Introlo's design language is specific. Dark warm backgrounds (#1C1208), cream text (#FAF6EF), orange accents (#F97316), Instrument Serif for headings, DM Sans for body. Geometric unicode icons instead of emoji. No rounded-everything Material Design aesthetic.

If I had started with a component library, I would have spent more time overriding defaults than building. Every Button component would need custom variants. Every Dialog would need restyling. The "saved time" from a library disappears fast when your design system doesn't match its assumptions.

Instead, I built a thin component layer on top of Tailwind:

  • A Block component that handles the shared structure (drag handle, visibility toggle, mode badge, expand/collapse)
  • A Panel component for the right-side editing interface
  • A ModeSelector that manages the active mode state and broadcasts it to the block list

Total custom components: maybe 15-20. Each one does exactly what I need and nothing else. They're small enough that I can read any component file in under 30 seconds.

The tradeoff is real: no pre-built modals, no pre-built dropdowns, no pre-built tooltips. I built those by hand. But each one matches the design system perfectly without fighting overrides.

Supabase as the Data Layer

Supabase handles two things: auth and the profile data store.

Auth was straightforward with @supabase/ssr. The Next.js App Router integration works well once you understand the middleware pattern for session refresh.

The data model concept is simple: profiles own blocks, and each block knows which persona modes it should appear in. When the public profile renders, it checks which mode is active and filters the blocks accordingly. The filtering happens at the query level so you're not loading unnecessary data.

Supabase's Row Level Security handles the read/write boundaries so the public profile page and the authenticated Studio editor can share the same tables without exposing anything they shouldn't.

One thing I'd do differently: I'd use Supabase Realtime from the start for the Studio preview. Right now, the preview updates on save. With Realtime, the preview could update as you type, which would make the editing experience feel much more responsive.

Stripe Integration: Subscriptions + One-Time Payments

Introlo has three tiers: Free, Pro ($12.99/mo), and Lifetime Pro ($59 one-time). Stripe handles both the recurring subscription and the one-time payment.

The challenge was managing two different payment types in a single billing system. Subscriptions use Stripe's subscription API. Lifetime Pro uses a one-time payment intent. But in the app, both need to resolve to the same question: does this user have Pro access or not?

The solution: a unified access check that doesn't care how you got Pro. Stripe webhooks listen for the relevant subscription and payment events, then update the user's access level accordingly. Whether you're paying monthly or bought lifetime, the app treats you the same.

This keeps the codebase simple. Feature gates check one thing. Stripe handles the complexity of different payment flows behind the scenes.

What I'd Do Differently

Start with Supabase Realtime for the editor. The save-then-preview loop adds friction. Live preview would make Studio feel like a proper design tool.

Invest in keyboard shortcuts earlier. Power users (and I'm one of them) want to reorder blocks, switch modes, and toggle visibility without touching the mouse. I'm adding these now but they should have been in from the start.

Build the mobile editing experience sooner. Most of my users check their profile on mobile. Some want to make quick edits on mobile. The Studio is desktop-first right now and that's a gap.

Try It

If any of this was interesting, you can try Introlo at introlo.com. It's free to start. Bootstrapped and actively looking for feedback — especially from devs who have opinions about how profile builders should work.

If you're building something similar with dnd-kit or Supabase, happy to answer questions in the comments.

Top comments (0)