Building a Progressive Multi-Step Form with HTMX and Effect-TS · The Sashka
case-studies

Building a Progressive Multi-Step Form with HTMX and Effect-TS

How we built a 7-step client discovery form with zero JavaScript frameworks, session-based resumability, and type-safe backend error handling in 8 hours.

Alex Raihelgaus
Building a Progressive Multi-Step Form with HTMX and Effect-TS

Building a Progressive Multi-Step Form with HTMX and Effect-TS

TL;DR: We built a complete 7-step client discovery form in ~8 hours using HTMX for the frontend and Effect-TS for the backend. The result? A ~34 KB bundle (vs 150 KB for a React SPA), sub-1-second time to interactive, and session-based resumability that works across devices. No client-side state management required.


The Problem: Pre-Qualifying Leads Without Friction

Sashka needed a better way to qualify prospective clients before consultation calls. The old approach was inefficient:

  • Email back-and-forth wasted time
  • Phone calls without context meant repeating questions
  • No pre-qualification led to unqualified leads

What we needed: A way for prospects to share detailed business information across 7 categories—contact info, business details, pain points, project goals, technical context, scope, and logistics—without overwhelming them with a massive form.

The catch: It had to be frictionless. No account creation, no CAPTCHA prompts, and it had to work on mobile. Users should be able to start, leave, and come back days later from a different device to continue where they left off.


Why Not Just Use React?

The knee-jerk reaction for most developers would be: “Multi-step form? That’s a job for React with state management.”

But here’s the thing: progressive forms are a terrible use case for heavy client-side frameworks.

Think about it:

  • Forms are inherently server-side (you’re submitting data anyway)
  • SEO matters for marketing pages
  • Mobile users don’t want to download 150 KB of JavaScript
  • State management for form flows is overkill

We needed something simpler, faster, and more maintainable.


The Architecture: HTMX + Effect-TS

Frontend: HTMX for Progressive Enhancement

HTMX lets you build dynamic, modern web apps using plain HTML and server-side rendering. No client-side state, no virtual DOM, no build complexity.

Here’s how it works for our multi-step form:

  1. User fills out Step 1 (Contact Info)
  2. HTMX intercepts the form submission
  3. Posts data to backend with Accept: text/html header
  4. Backend saves data, generates Step 2 HTML
  5. HTMX swaps the response into the page
  6. Smooth transition—no page reload

Zero JavaScript state management. The server knows what step you’re on because it’s stored in the database.

<!-- Step 1 form -->
<form 
  hx-post="/api/v1/clients/discovery/start"
  hx-target="#form-container"
  hx-swap="innerHTML">
  <!-- Form fields here -->
  <button type="submit">Next →</button>
</form>

That’s it. No useState, no useReducer, no Redux. Just HTML doing what HTML does best.

Skipping the SPA Data Transformation Dance

Here’s where HTMX really shines. With a traditional SPA, you have this exhausting cycle:

  1. SPA loads, shows loading spinner
  2. Fetches data from backend (GET /api/step/2)
  3. Gets JSON response
  4. Transforms JSON into state management (Redux, Zustand, whatever)
  5. State management feeds the UI framework
  6. UI framework renders components
  7. User interacts
  8. Repeat for every single step

With HTMX? We skip all that. The backend returns ready-to-display HTML. No transformation, no state management, no framework rendering pipeline. Just swap the HTML into the DOM. Done.

Truly stateless server. Each request is independent. The server doesn’t care about your client-side state because there isn’t any.

Backend: Effect-TS for Type-Safe Error Handling

Effect-TS is a game-changer for backend development. It gives you:

  • Type-safe error handling (no try/catch guessing games)
  • Dependency injection via Context/Layer
  • Composable business logic
  • Railway-oriented programming (errors are values, not exceptions)

Here’s a typical Effect-TS handler:

const createSession = (contactInfo: ContactInfo) =>
  Effect.gen(function* () {
    // Check for existing session
    const existing = yield* sessionService.findByEmail(email, "in_progress");
    if (existing) {
      return { sessionId: existing.id, resumed: true };
    }

    // Create new session
    const session = yield* sessionService.create(email, contactInfo);
    return { sessionId: session.id, resumed: false };
  });

The beauty? TypeScript knows exactly what errors can occur. No hidden exceptions, no runtime surprises.


The Base Solution (And Why It Wasn’t Enough)

The straightforward approach? A single long form—20+ questions, one page, one submit button, done. Technically, it would work.

But we didn’t stop there. We thought about the user experience and the edge cases. Here’s what we added to make it genuinely effective:


Thoughtful Enhancements: Making It Better

1. Multi-Step Progressive Disclosure

What: Break the form into 7 steps instead of one long page

Why: Staring at 20+ questions is overwhelming. Users see a massive form and bail immediately. Progressive disclosure shows 3-4 questions at a time, with a clear sense of progress.

How: Each step posts to the backend, which returns the next step’s HTML via HTMX

Impact: Lower abandonment rate, users feel less overwhelmed, better completion rate

Without this: Users would see a giant form, feel intimidated, and close the tab before starting.


2. Session-Based Resumability

What: Store form progress in the database so users can continue later from any device

Why: Users get interrupted—phone calls, meetings, need to gather information from colleagues, dog starts barking, kid needs help. If they lose their progress, they’re not coming back to start over.

How: PostgreSQL sessions with JSONB data storage, indexed by email. Each step updates the session. When they return, we fetch the session and generate the next step HTML with prefilled data.

Impact: Users can return days later from a different device and pick up exactly where they left off. No frustration, no lost work, higher completion rate.

Without this: Getting interrupted = losing all progress = never completing the form.

How it works:

Step 1: User starts the form

  • Backend creates a session in PostgreSQL
  • Stores contact info in JSONB column
  • Returns Step 2 HTML with a script to save email to localStorage

Step 2: User completes a few steps, then leaves

  • Each step updates the session via PUT /api/v1/clients/discovery/continue
  • Backend merges new data into the session’s JSONB data field
  • Tracks current_step to know where they left off

Step 3: User returns days later from a different device

  • Page checks localStorage for saved email
  • If email exists, initiate resume flow
  • Backend finds the in-progress session
  • Returns HTML for the next incomplete step with prefilled data
  • HTMX swaps it into the page

3. Email Verification for Resume (Security)

What: Send a verification code before showing stored data when resuming

Why: Without verification, anyone could enter someone else’s email and read their submission. That’s a phishing risk—attackers could harvest business information, contact details, technical context, budget expectations. All sensitive data.

How:

  1. User enters email to resume
  2. Backend sends 6-digit verification code to that email
  3. User enters code to prove they own the email
  4. Only then do we show their stored data

Impact: Protects against unauthorized access without requiring full authentication (login, password, session management). Simple, secure, low friction.

Without this: Competitor enters your email → reads your entire submission → knows your pain points, budget, and timeline. Not acceptable.

Technical implementation:

// Generate verification code
const code = crypto.randomInt(100000, 999999).toString();
const expiresAt = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes

// Store in session
await db.update(session.id, { 
  verificationCode: code,
  verificationExpiresAt: expiresAt 
});

// Send email
await emailService.sendVerificationCode(email, code);

// Later, verify the code
if (inputCode !== session.verificationCode || now > session.verificationExpiresAt) {
  return Effect.fail(new InvalidVerificationCodeError());
}

4. JSONB for Schema Flexibility

What: Store form data in JSONB instead of rigid columns

Why: Requirements change during development. We added a “budget range” question mid-way, removed a “company size” question, changed “industry” from text to dropdown. With traditional columns, each change requires a migration.

How: Single data JSONB column stores all form fields as nested JSON. Update the form, merge the data, done.

Impact: We iterated 5+ times on form fields without a single schema migration. Faster development, no downtime for migrations.

Without this: Every field change = write migration, test migration, deploy migration, deal with data conflicts, slow down iteration.


5. Honeypot Spam Protection

What: Hidden field that bots fill but real users don’t

Why: Public forms attract spam bots. CAPTCHA works but annoys real users—solving puzzles, clicking crosswalks, “I’m not a robot” friction.

How: Add an invisible field (company_website_url hidden off-screen). Bots auto-fill all fields, including this one. If the honeypot is filled, silently return a fake success response. No database record created.

Impact: Blocks most bots with zero user friction. No CAPTCHA prompts, no accessibility issues, no privacy concerns from third-party CAPTCHA services.

Without this: Spam submissions pollute the database, waste Sashka’s time reviewing garbage, potentially create email delivery issues from spammy contact info.

<!-- Hidden field, bots will fill it -->
<input
  type="text"
  name="company_website_url"
  tabindex="-1"
  autocomplete="off"
  style="position: absolute; left: -9999px;"
  aria-hidden="true"
/>

6. Effect Schema for Type-Safe Validation

What: Validate all inputs using Effect Schema (integrated with Effect-TS)

Why: HTML5 validation alone doesn’t catch business rules—email format might be valid, but is it a real domain? Phone number might have 10 digits, but are they all zeros? We need server-side validation with proper error messages.

How: Effect Schema provides composable, type-safe validation with built-in error handling that integrates with our Effect-TS backend.

Impact: Prevents invalid data from entering the database, provides clear error messages, TypeScript knows exactly what’s valid.

Without this: Garbage data in the database, unclear validation errors, runtime surprises from unexpected input.


Database Schema: Built for Flexibility and Security

CREATE TABLE discovery_sessions (
  id UUID PRIMARY KEY,
  email TEXT NOT NULL,
  status TEXT NOT NULL, -- 'in_progress' | 'completed'
  current_step INTEGER,
  data JSONB,  -- Flexible storage for all form fields
  locked BOOLEAN,  -- Prevents modification after submission
  verification_code TEXT,  -- For email verification on resume
  verification_expires_at TIMESTAMP,  -- Code expiry
  created_at TIMESTAMP,
  updated_at TIMESTAMP,
  completed_at TIMESTAMP
);

CREATE INDEX idx_discovery_sessions_email ON discovery_sessions(email);
CREATE INDEX idx_discovery_sessions_status ON discovery_sessions(status);

Key design decisions:

  • JSONB data column: Allows field changes without migrations
  • verification_code and verification_expires_at: Secure resume functionality
  • locked boolean: Prevents modification after submission (data integrity)
  • Email index: Fast lookups for resume functionality

Content Negotiation: One API, Two Interfaces

The same endpoints serve both HTML (for HTMX) and JSON (for future API clients).

// Curried respond function for flexibility
const respond = (acceptHeader?: string) => <T>(payload: T) => {
  const acceptsHtml = acceptHeader?.includes("text/html");

  if (acceptsHtml) {
    // Payload is HTML string for HTMX clients
    return HttpServerResponse.text(payload as string, { 
      contentType: "text/html" 
    });
  }

  // Return JSON for API clients
  return HttpServerResponse.json(payload);
};

// Usage: create a specialized responder
const respondWithHtml = respond(request.headers["accept"]);

// Then use it
return respondWithHtml(htmlString);

Why this matters:

  • HTMX sends Accept: text/html → Gets rendered HTML
  • Future mobile app sends Accept: application/json → Gets structured data
  • One codebase, zero duplication
  • Curried function makes it easy to create specialized responders

Performance: 3-4x Smaller Than React SPAs

Let’s talk numbers.

Initial Load

  • HTML: 12 KB (gzipped)
  • CSS: 8 KB (Tailwind, purged)
  • JavaScript: 14 KB (HTMX only)
  • Total: ~34 KB
  • Time to Interactive: < 1 second

For Comparison (Gzipped)

  • React 18 SPA: ~150 KB (React 18 + ReactDOM + app code)
  • React 19 SPA: ~120 KB (React 19 + ReactDOM + app code)
  • Vue SPA: ~100 KB (Vue + app code)

We’re 3-4x smaller. That means faster load times on mobile, lower data usage, better Core Web Vitals.

But Wait, What About the Response Size?

Fair question. Each HTMX response returns HTML, which might be larger than a JSON response. A typical step might be:

  • JSON response: ~1-2 KB
  • HTML response: ~3-5 KB

So yes, we’re sending 2-3x more bytes per step. But here’s the thing: we’re still going to the backend anyway to figure out what the next step is. With a SPA, you’d:

  1. Send form data to backend (~1 KB)
  2. Get JSON response (~2 KB)
  3. Transform JSON into UI state (client-side work)
  4. Run rendering pipeline (React reconciliation, virtual DOM diff)
  5. Update the DOM

With HTMX:

  1. Send form data to backend (~1 KB)
  2. Get HTML response (~4 KB)
  3. Swap HTML into DOM (done)

Even though the HTML is bigger, we skip the entire transformation phase. No state management, no rendering pipeline, no framework overhead. Just swap and show. Truly stateless server - each request is independent, no client state to manage.

Runtime Performance

  • Form submission: ~170ms end-to-end
    • Network: ~50ms
    • Backend processing: ~100ms
    • HTML generation: ~20ms
  • Database queries: 15-30ms (thanks to indexed lookups)
  • Email delivery: ~500ms (parallel sends via Resend)

The Result: More Than Just a Working Form

What started as “we need to collect client information” became a showcase of thoughtful engineering:

✅ User Experience Wins

1. Progressive Disclosure

  • Users complete one section at a time, not overwhelmed by 20+ questions
  • Clear progress indication (Step 3 of 7)
  • Mobile-friendly interface

2. Interruption-Proof

  • Started at office, finished on phone at home
  • Three days between Step 2 and Step 3? No problem
  • Email verification ensures security without authentication complexity

3. Zero Friction

  • No account creation, no passwords, no CAPTCHA puzzles
  • Honeypot handles spam invisibly
  • Works perfectly on mobile (tested on iOS Safari, Chrome mobile)

✅ Technical Wins

1. HTMX Architecture

  • 34 KB bundle vs 150 KB React SPA (3-4x smaller)
  • Sub-1-second time to interactive
  • Truly stateless server—each request is independent
  • No client-side state management nightmare

2. Effect-TS Backend

  • Type-safe error handling prevented countless bugs
  • Composable business logic
  • Clear separation of concerns (Controller → Service → Repository)

3. JSONB Flexibility

  • Changed form fields 5+ times without migrations
  • Iterates faster, ships faster
  • Perfect for evolving requirements

4. Security Built-In

  • Email verification prevents phishing attacks
  • Honeypot blocks bots without annoying users
  • Effect Schema validates all inputs server-side
  • Session locking prevents modification after submission

Key Lessons: Think Beyond the Obvious Solution

1. Start with the Base, Then Make It Better

Don’t just solve the problem—think about the user experience and edge cases.

The pattern:

  1. What’s the base solution? (one long form)
  2. What makes it better? (progressive steps, resumability, security)
  3. What problems does each enhancement solve?

This thinking separates adequate solutions from excellent ones.

2. Anticipate Interruptions

Users don’t complete forms in one sitting. They get distracted, need to gather information, switch devices. Plan for resumability from day one.

  • Session-based storage (not localStorage alone)
  • Cross-device support
  • Prefilled data on resume
  • Clear indication of progress

3. Security Isn’t an Afterthought

Question to always ask: “How could this be abused?”

In our case: Anyone with an email could read someone else’s submission. Solution: Email verification before showing data. Simple, effective, low friction.

4. Flexibility Enables Iteration

Requirements change. Accept it, plan for it.

JSONB vs. rigid columns: We changed form fields 5+ times. No migrations, no downtime, no pain. Worth the tradeoff.

5. Choose Tools for the Job, Not the Resume

HTMX + server-side rendering beat React for this use case:

  • 3-4x smaller bundle
  • Simpler mental model
  • No client state management
  • Perfect for forms

React isn’t wrong—it’s just the wrong tool for this job.

6. Invisible UX Improvements Matter

Users don’t notice good spam protection—they just don’t see CAPTCHA prompts. They don’t notice JSONB flexibility—they just see a form that works.

That’s the point. Good engineering removes friction invisibly.

7. Database Cleanup Is Simple (And Necessary)

Won’t your database get polluted?

Not really. Here’s the strategy:

  • Completed → client: Create client entity, optionally delete session
  • Completed → no client: Flag in admin UI, archive/delete
  • Abandoned sessions: Weekly cleanup for sessions >30 days old

PostgreSQL handles hundreds/thousands easily. Not a problem until it is—then optimize.


The Result: Production-Ready in 8 Hours

What we built:

  • ✅ 7-step progressive form
  • ✅ Session-based resumability (works across devices)
  • ✅ Spam protection (honeypot)
  • ✅ Email notifications (Sashka + user)
  • ✅ Mobile-responsive (Tailwind CSS)
  • ✅ Accessible (semantic HTML, keyboard nav)
  • ✅ Type-safe (TypeScript + Effect-TS)

Time investment: ~8 hours

  • Planning & architecture: 1 hour
  • Backend (Effect-TS, sessions, validation): 3 hours
  • Frontend (Astro, HTMX, 7 steps): 2 hours
  • Security (email verification, honeypot): 1 hour
  • Testing & polish: 1 hour

Metrics:

  • Bundle size: 34 KB (vs 150 KB React 18 SPA, 120 KB React 19 SPA)
  • Time to interactive: < 1 second
  • Lines of code: ~4,000
  • Field iterations: 5+ changes with 0 migrations
  • Security: Email verification + honeypot spam protection

Try It Yourself

Want to see it in action? Visit thesashka.com/clients/discovery (launching soon).

Curious about the tech stack? We used:

  • Effect-TS - Backend framework
  • Astro - Frontend framework (static site)
  • HTMX - Progressive enhancement
  • Tailwind CSS v4 - Styling
  • Supabase - PostgreSQL database
  • Resend - Email delivery
  • Bun - JavaScript runtime

Final Thoughts: Beyond Just Solving the Problem

We didn’t just build a form—we built a user-friendly, secure, maintainable system that thinks about edge cases.

The difference:

  • Base solution: One long form, one endpoint → Works, but users abandon it
  • Our solution: Progressive steps + resumability + verification + spam protection + flexible schema → Actually gets completed

Each enhancement addresses a real problem:

  • Progressive steps → Reduces overwhelm
  • Session resumability → Handles interruptions
  • Email verification → Prevents phishing
  • JSONB storage → Enables fast iteration
  • Honeypot → Blocks bots invisibly
  • Effect Schema → Validates properly

The result? A form that users actually complete, that protects their data, that we can iterate on without migrations, that blocks spam without friction.

That’s thoughtful engineering. Not just making it work—making it work well.

Would we build it the same way again? Absolutely. The architecture decisions paid off, the code is maintainable, and most importantly: it solves the real problem while thinking about the user experience and edge cases.

That’s what separates adequate solutions from excellent ones.


Want to build something similar for your business? .


Technologies Mentioned:

  • Effect-TS - Type-safe backend framework
  • HTMX - High power tools for HTML
  • Astro - Static site generator
  • Supabase - PostgreSQL database
  • Resend - Modern email API
  • Bun - Fast JavaScript runtime

Share this article