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:
- User fills out Step 1 (Contact Info)
- HTMX intercepts the form submission
- Posts data to backend with
Accept: text/htmlheader - Backend saves data, generates Step 2 HTML
- HTMX swaps the response into the page
- 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:
- SPA loads, shows loading spinner
- Fetches data from backend (
GET /api/step/2) - Gets JSON response
- Transforms JSON into state management (Redux, Zustand, whatever)
- State management feeds the UI framework
- UI framework renders components
- User interacts
- 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
datafield - Tracks
current_stepto know where they left off
Step 3: User returns days later from a different device
- Page checks
localStoragefor 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:
- User enters email to resume
- Backend sends 6-digit verification code to that email
- User enters code to prove they own the email
- 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
datacolumn: Allows field changes without migrations verification_codeandverification_expires_at: Secure resume functionalitylockedboolean: 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:
- Send form data to backend (~1 KB)
- Get JSON response (~2 KB)
- Transform JSON into UI state (client-side work)
- Run rendering pipeline (React reconciliation, virtual DOM diff)
- Update the DOM
With HTMX:
- Send form data to backend (~1 KB)
- Get HTML response (~4 KB)
- 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:
- What’s the base solution? (one long form)
- What makes it better? (progressive steps, resumability, security)
- 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: