<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Uriel Bitton</title>
    <description>The latest articles on DEV Community by Uriel Bitton (@urielbitton).</description>
    <link>https://dev.to/urielbitton</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F300278%2Fa944b133-8483-4d2d-8f90-d764334343ea.jpg</url>
      <title>DEV Community: Uriel Bitton</title>
      <link>https://dev.to/urielbitton</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/urielbitton"/>
    <language>en</language>
    <item>
      <title>SaaS Team Invitations: Show Who Gets Access to What</title>
      <dc:creator>Uriel Bitton</dc:creator>
      <pubDate>Wed, 30 Sep 2026 15:19:55 +0000</pubDate>
      <link>https://dev.to/urielbitton/saas-team-invitations-show-who-gets-access-to-what-4h90</link>
      <guid>https://dev.to/urielbitton/saas-team-invitations-show-who-gets-access-to-what-4h90</guid>
      <description>&lt;p&gt;A SaaS team invitation should make four things clear: who is invited, which workspace they will join, what they can do, and whether access has actually started. Keep a pending invite separate from an active member. Then make the server enforce the same rules the interface describes.&lt;/p&gt;

&lt;p&gt;An invite touches two people and often two different account states. A smooth email screen is only one part of the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the workspace and role beside the email
&lt;/h2&gt;

&lt;p&gt;Show the workspace name, recipient address, and selected role before sending. Use a small set of understandable role names and explain the actions each one allows. “Member” is too vague if it can include billing changes or deleting a project.&lt;/p&gt;

&lt;p&gt;Choose a limited default role that matches the intended task. &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP’s authorization guidance&lt;/a&gt; recommends least privilege, denying access by default, and checking permissions on every request. Apply that reasoning to who can send an invite and which role they can assign.&lt;/p&gt;

&lt;p&gt;A disabled admin option in the browser is not an access check. The server should reject an unauthorized role or a workspace the sender cannot manage, even if someone changes the request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give pending invitations their own state
&lt;/h2&gt;

&lt;p&gt;After the request succeeds, show “Invitation sent” and list the invite as pending. Keep the address visible so the sender can catch a typo. Make expiry, resend, and revoke actions easy to find where your product supports them.&lt;/p&gt;

&lt;p&gt;Auth0’s &lt;a href="https://auth0.com/docs/api/management/v2/organizations/post-invitations" rel="noopener noreferrer"&gt;organization invitation model&lt;/a&gt; includes a recipient, organization, expiry, and optional roles. It is one concrete example of the details an invitation system needs to track. Its defaults are product-specific; they are not a universal rule for your app.&lt;/p&gt;

&lt;p&gt;If the server has queued the email but has no delivery confirmation, say that the invitation was sent or queued. Avoid claiming that the recipient received it. If sending fails, preserve the form and explain the next action.&lt;/p&gt;

&lt;h2&gt;
  
  
  Explain acceptance before granting access
&lt;/h2&gt;

&lt;p&gt;The receiving page should identify the inviter, workspace, and role. Give the person a clear choice to accept. If they are signed into a different account, show which identity is active and offer a safe way to switch.&lt;/p&gt;

&lt;p&gt;Check the recipient rules your authentication system supports. The server needs to bind acceptance to the intended identity and workspace, validate the invitation, and reject expired or revoked invitations. An invitation link should not become a way to choose arbitrary access.&lt;/p&gt;

&lt;p&gt;After acceptance, show the workspace and the first useful task. If the role permits only viewing, the interface should reflect that without hiding a server failure behind an endless loading state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make repeat actions predictable
&lt;/h2&gt;

&lt;p&gt;Decide how your product handles a second invitation to the same address. Perhaps it resends the pending invitation. Perhaps it asks the sender to change the role explicitly. State that behavior in the interface and keep it consistent.&lt;/p&gt;

&lt;p&gt;An acceptance request may be retried after a slow response. Handle that retry without creating duplicate memberships or producing a misleading error. A revoked invitation should remain invalid after a resend or an old browser session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the handoff between the two people
&lt;/h2&gt;

&lt;p&gt;Use a small set of cases before release:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A permitted sender invites the right address with a limited role.&lt;/li&gt;
&lt;li&gt;A sender attempts to assign a role they cannot grant.&lt;/li&gt;
&lt;li&gt;The recipient opens the link while signed into a different account.&lt;/li&gt;
&lt;li&gt;The link is expired, revoked, or already accepted.&lt;/li&gt;
&lt;li&gt;The same request is submitted twice.&lt;/li&gt;
&lt;li&gt;A member’s role changes after they have already loaded a page.&lt;/li&gt;
&lt;li&gt;A person tries to access another workspace by changing an identifier.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check both the visible message and the server result. These cases help you find mismatches; they do not replace a security review.&lt;/p&gt;

&lt;p&gt;The invitation is complete when the intended person joins the intended workspace with the intended access. Use that as the definition of success, then make every intermediate state tell the truth.&lt;/p&gt;

&lt;p&gt;Hey I'm Uriel Bitton. I write about building in public strategies and growing startups.&lt;/p&gt;

&lt;p&gt;Subscribe for more stories on growing your audience by building in public.&lt;/p&gt;

&lt;p&gt;Join us on &lt;a href="https://buildside.app/" rel="noopener noreferrer"&gt;Buildside&lt;/a&gt;: the social network for founders building in public.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP: Authorization Cheat Sheet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://auth0.com/docs/api/management/v2/organizations/post-invitations" rel="noopener noreferrer"&gt;Auth0: Organization invitation fields and expiry&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>ux</category>
      <category>saas</category>
    </item>
    <item>
      <title>Password Reset UX: Make Recovery Clear and Safe</title>
      <dc:creator>Uriel Bitton</dc:creator>
      <pubDate>Tue, 29 Sep 2026 18:41:44 +0000</pubDate>
      <link>https://dev.to/urielbitton/password-reset-ux-make-recovery-clear-and-safe-4i1b</link>
      <guid>https://dev.to/urielbitton/password-reset-ux-make-recovery-clear-and-safe-4i1b</guid>
      <description>&lt;p&gt;A good password reset flow helps a person regain access without revealing whether an account exists. It gives clear next steps, uses a short-lived reset link or code, and explains what happened after a new password is set.&lt;/p&gt;

&lt;p&gt;This is a small product flow with a large trust cost when it fails. The copy, security checks, and failure states need to agree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with one clear request screen
&lt;/h2&gt;

&lt;p&gt;Ask for the email address or account name your product uses. Label the field plainly and allow password managers and paste. Put the reset action where a person expects it from sign-in.&lt;/p&gt;

&lt;p&gt;After submission, show a message such as: “If an account matches, we'll send a reset link. Check your inbox and spam folder.” Give a route back to sign-in and help for someone who no longer has access to that inbox.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP Forgot Password Cheat Sheet&lt;/a&gt; recommends a consistent message for existing and non-existing accounts and similar response times. An error that says “No account found” can help someone probe for registered users.&lt;/p&gt;

&lt;p&gt;A neutral message does not require vague help. Tell the person what to look for, how long to wait before retrying, and where to get support. Make those times reflect your actual service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat the link as a limited key
&lt;/h2&gt;

&lt;p&gt;A reset link or code should be single-use, expire after an appropriate period, and be generated and stored securely. OWASP describes those controls in its recovery guidance. The email should send the person to your own service, not ask them to reply with a password.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://design-system.service.gov.uk/patterns/passwords/" rel="noopener noreferrer"&gt;GOV.UK password pattern&lt;/a&gt; also advises using a time-limited reset link or code and notifying the person when a password reset has happened. Do not email the new password.&lt;/p&gt;

&lt;p&gt;Write the expired-link screen before it happens. “This link has expired. Request a new one” is more useful than a generic “Invalid token.” If a newer request replaced an older link, explain the next safe action without exposing account details.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the new-password screen usable
&lt;/h2&gt;

&lt;p&gt;Tell people the actual password rules before they submit. Let them paste from a password manager, and provide a way to show or hide the value if that fits the product. Keep focus order clear and identify errors next to the affected field.&lt;/p&gt;

&lt;p&gt;Do not force a surprising new rule only after someone has typed and submitted a password. If confirmation is required, explain mismatches without clearing both fields unnecessarily.&lt;/p&gt;

&lt;p&gt;The reset should not silently sign someone into a session unless your security design specifically supports that. OWASP recommends the normal sign-in path after reset and considering existing-session handling. Show a success state with a clear “Sign in” link and send a separate account notification.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the cases that usually get missed
&lt;/h2&gt;

&lt;p&gt;Use test accounts and disposable tokens. Check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A real and an unknown email get the same public response.&lt;/li&gt;
&lt;li&gt;A valid link works once; a second use is rejected clearly.&lt;/li&gt;
&lt;li&gt;An expired link offers a safe restart.&lt;/li&gt;
&lt;li&gt;A new password that fails the policy gets a useful error.&lt;/li&gt;
&lt;li&gt;A network failure does not claim the reset succeeded.&lt;/li&gt;
&lt;li&gt;The notification goes to the right address and contains no password.&lt;/li&gt;
&lt;li&gt;Keyboard and screen-reader users can finish the whole flow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This test list is a starting point, not a security audit. Review rate limits, logs, token handling, and session rules with the people responsible for authentication.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep support connected to the flow
&lt;/h2&gt;

&lt;p&gt;Some people have lost access to the email or phone on the account. Give them a visible support path with a careful identity-check process. Do not ask for secrets in an open form or promise recovery the team cannot provide.&lt;/p&gt;

&lt;p&gt;The final check is simple: Can a real user recover access, and does every visible state tell the truth about what happened? Fix any mismatch before polishing the illustration on the request page.&lt;/p&gt;

&lt;p&gt;Hey I'm Uriel Bitton. I write about building in public strategies and growing startups.&lt;/p&gt;

&lt;p&gt;Subscribe for more stories on growing your audience by building in public.&lt;/p&gt;

&lt;p&gt;Join us on &lt;a href="https://buildside.app/" rel="noopener noreferrer"&gt;Buildside&lt;/a&gt;: the social network for founders building in public.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP: Forgot Password Cheat Sheet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://design-system.service.gov.uk/patterns/passwords/" rel="noopener noreferrer"&gt;GOV.UK Design System: Passwords&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>ux</category>
      <category>saas</category>
    </item>
    <item>
      <title>SaaS Delete Dialogs: Say Exactly What Will Be Lost</title>
      <dc:creator>Uriel Bitton</dc:creator>
      <pubDate>Mon, 28 Sep 2026 17:27:54 +0000</pubDate>
      <link>https://dev.to/urielbitton/saas-delete-dialogs-say-exactly-what-will-be-lost-5h3m</link>
      <guid>https://dev.to/urielbitton/saas-delete-dialogs-say-exactly-what-will-be-lost-5h3m</guid>
      <description>&lt;p&gt;A useful delete confirmation dialog names the item, explains what else will disappear, and gives the user a clear way to stop. For a SaaS product, the copy and the keyboard behavior should support the same decision.&lt;/p&gt;

&lt;p&gt;A red button alone does not explain the scope. “Delete project” might remove an empty folder, shared reports, or work that other people still need. Make that difference visible before the final action.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write the consequence before styling the dialog
&lt;/h2&gt;

&lt;p&gt;Start with three questions: What is being deleted? Who or what else is affected? Can the user restore it?&lt;/p&gt;

&lt;p&gt;Here is a hypothetical dialog for a project tool:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Delete “October client report”?&lt;/p&gt;

&lt;p&gt;This removes the project and its 12 saved reports for everyone in this workspace. You cannot restore them from this app.&lt;/p&gt;

&lt;p&gt;Cancel · Delete project&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Use those claims only if the product behaves that way. If the action moves the project to a recoverable trash folder, state the real recovery window. If attachments stay elsewhere, say so when that would change the choice.&lt;/p&gt;

&lt;p&gt;Avoid “Are you sure?” as the entire message. The person needs enough detail to know what they are confirming.&lt;/p&gt;

&lt;h2&gt;
  
  
  Match the interruption to the action
&lt;/h2&gt;

&lt;p&gt;Use a confirmation step where a mistaken action would be costly. For frequent, recoverable actions, consider a clear undo flow instead. That is a design choice to test with your users, not a rule that every product must follow.&lt;/p&gt;

&lt;p&gt;Typing a project name can add a deliberate pause for a serious action, but it does not replace an explanation. A person can copy a name without understanding what will disappear.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/" rel="noopener noreferrer"&gt;W3C alert-dialog pattern&lt;/a&gt; is intended for an important message that interrupts a task and needs a response. It describes a labelled dialog with its alert message connected for assistive technology. Do not make every routine notice an alert dialog.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep keyboard focus inside the decision
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/" rel="noopener noreferrer"&gt;W3C modal-dialog pattern&lt;/a&gt; describes moving focus into a modal, keeping Tab and Shift+Tab within it, and closing it with Escape. For a difficult-to-reverse final action, it suggests considering initial focus on the least destructive choice.&lt;/p&gt;

&lt;p&gt;For the example above, Cancel is a sensible starting point. A keyboard user should not land on Delete merely because it is visually prominent.&lt;/p&gt;

&lt;p&gt;When the dialog closes, return focus to the control that opened it when that control still exists. If the deleted row is gone, choose a logical place in the remaining workflow. A modal label alone does not implement any of this behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design the failed request too
&lt;/h2&gt;

&lt;p&gt;Write down what the person should see while deletion is running, when it succeeds, and when it fails.&lt;/p&gt;

&lt;p&gt;For example, keep the selected project name visible while the request runs. If the server rejects the request, retain the project and explain that deletion did not finish. Let the person retry or cancel. Do not show a success message just because the button was clicked.&lt;/p&gt;

&lt;p&gt;Also check a stale page: what should happen when a teammate has already removed the item? The interface should resolve the current state without trapping the user in an endless retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review the flow with a small test list
&lt;/h2&gt;

&lt;p&gt;Use a disposable test project, then check these cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open the dialog using only the keyboard and read its title and consequence.&lt;/li&gt;
&lt;li&gt;Move forward and backward through the controls; check Escape and Cancel.&lt;/li&gt;
&lt;li&gt;Confirm one deletion and check where focus goes afterward.&lt;/li&gt;
&lt;li&gt;Simulate a failed request and verify that the item still exists.&lt;/li&gt;
&lt;li&gt;Check whether another user's view and any recovery screen match the promise in the dialog.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These checks do not replace a full accessibility review. They do give the deletion flow a concrete promise to meet: the user understands the choice, can operate it, and sees what actually happened.&lt;/p&gt;

&lt;p&gt;Hey I'm Uriel Bitton. I write about building in public strategies and growing startups.&lt;/p&gt;

&lt;p&gt;Subscribe for more stories on growing your audience by building in public.&lt;/p&gt;

&lt;p&gt;Join us on &lt;a href="https://buildside.app/" rel="noopener noreferrer"&gt;Buildside&lt;/a&gt;: the social network for founders building in public.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/" rel="noopener noreferrer"&gt;W3C: Alert and Message Dialogs Pattern&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/" rel="noopener noreferrer"&gt;W3C: Dialog (Modal) Pattern&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>a11y</category>
      <category>ux</category>
      <category>saas</category>
    </item>
    <item>
      <title>SaaS File Uploads: Make Selection, Errors, and Success Clear</title>
      <dc:creator>Uriel Bitton</dc:creator>
      <pubDate>Fri, 25 Sep 2026 16:18:59 +0000</pubDate>
      <link>https://dev.to/urielbitton/saas-file-uploads-make-selection-errors-and-success-clear-3i9m</link>
      <guid>https://dev.to/urielbitton/saas-file-uploads-make-selection-errors-and-success-clear-3i9m</guid>
      <description>&lt;p&gt;A useful file upload tells people what they can choose, what the system is doing, and whether their file is ready to use. Build those three parts together. A drop zone alone does not finish the job.&lt;/p&gt;

&lt;p&gt;For a small SaaS, an upload may be the first time someone brings their own work into the product. That makes the details worth checking before adding a more elaborate interface.&lt;/p&gt;

&lt;h2&gt;
  
  
  State the rules before someone chooses a file
&lt;/h2&gt;

&lt;p&gt;Put the supported formats, size limit, and file count beside the input. Use the same rules your server enforces. Avoid making people discover a limit only after a long transfer.&lt;/p&gt;

&lt;p&gt;A hypothetical invoice tool might say: “Upload one PDF, up to 10 MB.” If a user needs several files, explain whether they can select them together or must upload them one at a time.&lt;/p&gt;

&lt;p&gt;Use a visible label that names the task, such as “Upload an invoice.” Keep a keyboard-accessible file picker even if you also support dragging files onto the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat file selection as the start
&lt;/h2&gt;

&lt;p&gt;Selecting a file does not necessarily mean it has been sent. Show the selected filename and make the next action clear. If uploading starts immediately, say so before selection. If it needs a button, use a label such as “Upload invoice.”&lt;/p&gt;

&lt;p&gt;The HTML file input can guide selection with its accept attribute. &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/file" rel="noopener noreferrer"&gt;MDN explains&lt;/a&gt; that this is a browser hint, not validation. The server still needs to validate the upload. A filename extension or a browser filter is not enough to establish that a file is safe or usable.&lt;/p&gt;

&lt;p&gt;Let the user replace a mistaken selection before committing it where the workflow allows. Keep the filename visible so they can check their choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write errors around the next action
&lt;/h2&gt;

&lt;p&gt;“Upload failed” leaves several possible problems unresolved. Did the connection drop? Was the file too large? Was its format unsupported?&lt;/p&gt;

&lt;p&gt;For the hypothetical invoice tool, useful messages could be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“This file is larger than 10 MB. Choose a smaller PDF.”&lt;/li&gt;
&lt;li&gt;“We couldn't upload the file. Check your connection and try again.”&lt;/li&gt;
&lt;li&gt;“We uploaded the PDF, but couldn't read it. Try a clearer copy.”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These messages describe different states. Only show the last one when the file really reached the service and the processing step failed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.w3.org/WAI/tutorials/forms/notifications/" rel="noopener noreferrer"&gt;W3C's form notification guidance&lt;/a&gt; recommends clear feedback with instructions for fixing errors. Keep the message near the input, associate it with the control, and make dynamic feedback available to assistive technology. Color alone should not carry the meaning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate transfer from processing
&lt;/h2&gt;

&lt;p&gt;If the file needs scanning, parsing, or conversion after transfer, give that work its own status. “Uploaded. Checking the invoice…” is more useful than leaving a progress bar at 100% while the page remains unusable.&lt;/p&gt;

&lt;p&gt;Only show a percentage when you can measure the relevant progress. Otherwise, a short status message is an honest choice. Avoid announcing every small progress change to a screen reader; meaningful state changes are easier to follow.&lt;/p&gt;

&lt;p&gt;Finish with the result and the next action: “Invoice ready. Review the extracted details.” If processing can continue after the user leaves, explain where they can find its status later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the uncomfortable paths
&lt;/h2&gt;

&lt;p&gt;Use this review list before calling the flow finished:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Choose the wrong format, an oversized file, and an empty file.&lt;/li&gt;
&lt;li&gt;Cancel the picker and choose the same file again.&lt;/li&gt;
&lt;li&gt;Interrupt the connection, then try again.&lt;/li&gt;
&lt;li&gt;Complete the flow with a keyboard and with a screen reader.&lt;/li&gt;
&lt;li&gt;Check what a second click does while an upload is already running.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are suggested checks, not reported test results. Your product may need more, including checks for its file-processing and security requirements.&lt;/p&gt;

&lt;p&gt;For every path, ask: can the user tell what happened, keep their bearings, and take the next step? That is the standard a useful upload should meet.&lt;/p&gt;

&lt;p&gt;Hey I'm Uriel Bitton. I write about building in public strategies and growing startups.&lt;/p&gt;

&lt;p&gt;Subscribe for more stories on growing your audience by building in public.&lt;/p&gt;

&lt;p&gt;Join us on &lt;a href="https://buildside.app/" rel="noopener noreferrer"&gt;Buildside&lt;/a&gt;: the social network for founders building in public.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/file" rel="noopener noreferrer"&gt;MDN: HTML file input&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.w3.org/WAI/tutorials/forms/notifications/" rel="noopener noreferrer"&gt;W3C WAI: User Notification in Forms&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>a11y</category>
      <category>ux</category>
      <category>saas</category>
    </item>
    <item>
      <title>SaaS Pricing Page: Build Plan Selection With Real Radio Buttons</title>
      <dc:creator>Uriel Bitton</dc:creator>
      <pubDate>Thu, 24 Sep 2026 18:53:00 +0000</pubDate>
      <link>https://dev.to/urielbitton/saas-pricing-page-build-plan-selection-with-real-radio-buttons-13fh</link>
      <guid>https://dev.to/urielbitton/saas-pricing-page-build-plan-selection-with-real-radio-buttons-13fh</guid>
      <description>&lt;p&gt;A SaaS pricing page should let a visitor compare plans, choose one with a keyboard, and submit the choice through a normal form. Use native radio buttons inside a &lt;code&gt;fieldset&lt;/code&gt;, keep visible labels, and treat the styled pricing cards as an enhancement of that form.&lt;/p&gt;

&lt;p&gt;This gives you browser behavior, keyboard support, and a value that can be validated on the server without rebuilding a selection control from generic &lt;code&gt;div&lt;/code&gt; elements.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the form, not the cards
&lt;/h2&gt;

&lt;p&gt;Each plan is one option in a single choice. That maps directly to radio buttons that share the same &lt;code&gt;name&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"/signup"&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"post"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"pricing-form"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;fieldset&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"plans"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;legend&amp;gt;&lt;/span&gt;Choose the plan that fits your team&lt;span class="nt"&gt;&amp;lt;/legend&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;label&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"plan-card"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"radio"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"plan"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"starter"&lt;/span&gt; &lt;span class="na"&gt;checked&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"plan-name"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Starter&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"plan-price"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;$19 per month&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;span&amp;gt;&lt;/span&gt;For one person testing a repeatable workflow.&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;label&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"plan-card"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"radio"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"plan"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"team"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"plan-name"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Team&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"plan-price"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;$59 per month&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;span&amp;gt;&lt;/span&gt;For a small team sharing work and permissions.&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;label&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"plan-card"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"radio"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"plan"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"business"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"plan-name"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Business&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"plan-price"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;$149 per month&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;span&amp;gt;&lt;/span&gt;For teams that need controls and priority support.&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/fieldset&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Continue with selected plan&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MDN recommends grouping a set of radio buttons with &lt;code&gt;fieldset&lt;/code&gt;; the nested &lt;code&gt;legend&lt;/code&gt; gives the group a caption. The shared &lt;code&gt;name="plan"&lt;/code&gt; also means the browser submits one selected value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep every plan label clickable
&lt;/h2&gt;

&lt;p&gt;Wrapping the radio input inside its label makes the whole card part of the control. A visitor can click the text, price, or empty space inside the label.&lt;/p&gt;

&lt;p&gt;Keep the input available to assistive technology. Do not use &lt;code&gt;display: none&lt;/code&gt; on it. You can place it visually and style the card from its state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.plans&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auto-fit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;minmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;15rem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.plans&lt;/span&gt; &lt;span class="nt"&gt;legend&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.25rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;font-weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;700&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;margin-bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.plan-card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;relative&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.25rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#d6d6d6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.75rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;pointer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.plan-card&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt;&lt;span class="nd"&gt;:checked&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;border-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#5b45e0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#f6f4ff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.plan-card&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt;&lt;span class="nd"&gt;:focus-visible&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;outline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#1a73e8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;outline-offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.plan-name&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
&lt;span class="nc"&gt;.plan-price&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;font-weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;700&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not make color the only selected-state signal. Keep the radio control visible, add a border change, and make the keyboard focus easy to see.&lt;/p&gt;

&lt;p&gt;If you need to support browsers without &lt;code&gt;:has()&lt;/code&gt;, add a small progressive enhancement that toggles a class. The radio buttons should still work without it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put price details next to the choice
&lt;/h2&gt;

&lt;p&gt;The visitor should not have to remember what a plan includes while choosing it.&lt;/p&gt;

&lt;p&gt;Each card should answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What does it cost, including the billing period?&lt;/li&gt;
&lt;li&gt;Who is it for?&lt;/li&gt;
&lt;li&gt;What limit changes between plans?&lt;/li&gt;
&lt;li&gt;Which important feature is included or excluded?&lt;/li&gt;
&lt;li&gt;What happens after the visitor continues?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use the same order for each card. Avoid putting the critical difference in a tooltip. Tooltips are easy to miss on touch screens and during keyboard use.&lt;/p&gt;

&lt;p&gt;If monthly and annual billing change the displayed price, keep the billing period in the visible text and in the submitted data. A number without its period can create a false comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use JavaScript for feedback, not basic selection
&lt;/h2&gt;

&lt;p&gt;You may want the button to repeat the selected plan. Read the checked radio instead of creating a second source of truth.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.pricing-form&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;button&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button[type="submit"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateButton&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;selected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;elements&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;selected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Continue with &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;updateButton&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;updateButton&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The form remains usable if this script fails. JavaScript changes the feedback, while HTML keeps the selection and submission working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate the plan on the server
&lt;/h2&gt;

&lt;p&gt;The browser sends a string chosen by the client. Treat it as untrusted input.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allowedPlans&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;starter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;team&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;business&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;allowedPlans&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Choose a valid plan.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look up the current price and entitlements on the server. Do not accept a price from a hidden field or query string. A visitor can edit client-side values before submitting the form.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the complete pricing path
&lt;/h2&gt;

&lt;p&gt;Before saving the page, test these cases:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Tab to the selected option and move through the group with arrow keys.&lt;/li&gt;
&lt;li&gt;Zoom the page and confirm the cards reflow without hiding content.&lt;/li&gt;
&lt;li&gt;Click every part of each card.&lt;/li&gt;
&lt;li&gt;Submit each plan and confirm the server receives the expected value.&lt;/li&gt;
&lt;li&gt;Disable JavaScript and confirm selection and submission still work.&lt;/li&gt;
&lt;li&gt;Use a screen reader to check that the group legend and plan labels are announced clearly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A pricing page is a decision form. Native controls give that decision a reliable base, while the visual cards help people compare the options.&lt;/p&gt;

&lt;p&gt;Hey I'm Uriel Bitton. I write about building in public strategies and growing startups.&lt;/p&gt;

&lt;p&gt;Subscribe for more stories on growing your audience by building in public.&lt;/p&gt;

&lt;p&gt;Join us on &lt;a href="https://buildside.app/" rel="noopener noreferrer"&gt;Buildside&lt;/a&gt;: the social network for founders building in public.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/fieldset" rel="noopener noreferrer"&gt;MDN: The fieldset element&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/How_to_structure_a_web_form" rel="noopener noreferrer"&gt;MDN: How to structure a web form&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/radio" rel="noopener noreferrer"&gt;MDN: Radio input&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>html</category>
      <category>saas</category>
      <category>a11y</category>
    </item>
    <item>
      <title>Core Web Vitals for SaaS Landing Pages: Fix the First Visit</title>
      <dc:creator>Uriel Bitton</dc:creator>
      <pubDate>Wed, 23 Sep 2026 17:40:54 +0000</pubDate>
      <link>https://dev.to/urielbitton/core-web-vitals-for-saas-landing-pages-fix-the-first-visit-5bc1</link>
      <guid>https://dev.to/urielbitton/core-web-vitals-for-saas-landing-pages-fix-the-first-visit-5bc1</guid>
      <description>&lt;p&gt;A SaaS landing page should load its main message quickly, respond without delay, and stay still while a visitor reads or clicks. Measure the real page first, find which Core Web Vital is failing, and fix that path before running a general “speed optimization” project.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are the three Core Web Vitals?
&lt;/h2&gt;

&lt;p&gt;Google's current &lt;a href="https://web.dev/articles/vitals" rel="noopener noreferrer"&gt;Web Vitals guidance&lt;/a&gt; defines three Core Web Vitals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Largest Contentful Paint (LCP)&lt;/strong&gt; measures loading performance. A good result is 2.5 seconds or less.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interaction to Next Paint (INP)&lt;/strong&gt; measures responsiveness. A good result is 200 milliseconds or less.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cumulative Layout Shift (CLS)&lt;/strong&gt; measures visual stability. A good result is 0.1 or less.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Evaluate the 75th percentile of page visits, split by mobile and desktop. A fast laptop test is useful for debugging, but it does not replace field data from real visits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with field data
&lt;/h2&gt;

&lt;p&gt;Open the deployed landing page in PageSpeed Insights. Check the “Discover what your real users are experiencing” section before the lab score.&lt;/p&gt;

&lt;p&gt;Google's &lt;a href="https://web.dev/articles/vitals-tools" rel="noopener noreferrer"&gt;Core Web Vitals tools workflow&lt;/a&gt; recommends using field data to find the pages and metrics that need attention, then using Lighthouse and browser performance tools to diagnose the cause.&lt;/p&gt;

&lt;p&gt;Ask three questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is the failing data for this exact URL or the whole origin?&lt;/li&gt;
&lt;li&gt;Does the problem affect mobile, desktop, or both?&lt;/li&gt;
&lt;li&gt;Which one of LCP, INP, or CLS is outside the good range?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This keeps the work tied to a user problem. It also prevents a team from shrinking every icon while the real delay comes from the server or a large hero image.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix LCP: make the main content easy to find
&lt;/h2&gt;

&lt;p&gt;On a SaaS landing page, the LCP element is often a hero heading or image. Inspect the element reported by PageSpeed Insights or Chrome DevTools instead of guessing.&lt;/p&gt;

&lt;p&gt;If the hero image is the LCP element:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;include it in the initial HTML;&lt;/li&gt;
&lt;li&gt;avoid lazy-loading it;&lt;/li&gt;
&lt;li&gt;serve an image close to its displayed size;&lt;/li&gt;
&lt;li&gt;use a modern compressed format where it works;&lt;/li&gt;
&lt;li&gt;give it high fetch priority when it is clearly the main visual.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt;
  &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/dashboard-hero.avif"&lt;/span&gt;
  &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"1200"&lt;/span&gt;
  &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"675"&lt;/span&gt;
  &lt;span class="na"&gt;fetchpriority=&lt;/span&gt;&lt;span class="s"&gt;"high"&lt;/span&gt;
  &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"Dashboard showing a completed weekly report"&lt;/span&gt;
&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not preload every image. That makes resources compete with each other. Google's &lt;a href="https://web.dev/articles/optimize-lcp" rel="noopener noreferrer"&gt;LCP optimization guide&lt;/a&gt; recommends making the LCP resource discoverable in the HTML and prioritizing it without taking bandwidth away from other critical files.&lt;/p&gt;

&lt;p&gt;If the LCP element is text, inspect server response time, blocking stylesheets, and web fonts. A fast image cannot repair a slow HTML response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix INP: reduce work after a click
&lt;/h2&gt;

&lt;p&gt;INP covers interactions across the visit. On a landing page, common problems include a pricing toggle, signup modal, chat widget, or menu that waits behind a long JavaScript task.&lt;/p&gt;

&lt;p&gt;Start by recording a slow interaction in the browser Performance panel. Look for a long task that begins near the click or key press.&lt;/p&gt;

&lt;p&gt;Then reduce the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;load optional widgets after the main page is ready;&lt;/li&gt;
&lt;li&gt;split large tasks so the browser can update between them;&lt;/li&gt;
&lt;li&gt;remove JavaScript shipped for components the page does not use;&lt;/li&gt;
&lt;li&gt;keep event handlers small;&lt;/li&gt;
&lt;li&gt;avoid rebuilding a large part of the page for one simple interaction.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A delayed pricing toggle is not only a technical score problem. It makes the visitor wonder whether the control worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix CLS: reserve space before content arrives
&lt;/h2&gt;

&lt;p&gt;CLS problems often appear when a hero image, embedded demo, cookie banner, or web font changes the layout after the visitor starts reading.&lt;/p&gt;

&lt;p&gt;Give images and videos an intrinsic size or an aspect ratio:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.hero-demo&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;aspect-ratio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="m"&gt;9&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reserve space for banners and embedded tools when their size is known. Avoid inserting a signup bar above content that is already visible. Google's &lt;a href="https://web.dev/articles/optimize-cls" rel="noopener noreferrer"&gt;CLS guide&lt;/a&gt; lists images without dimensions and dynamically injected content among the common causes of layout shifts.&lt;/p&gt;

&lt;p&gt;Test the page beyond its initial load. Some shifts happen only after a visitor accepts a cookie notice, opens a menu, or waits for a widget.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retest the same path
&lt;/h2&gt;

&lt;p&gt;After each change:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run a local performance trace to confirm the intended cause changed.&lt;/li&gt;
&lt;li&gt;Deploy the fix.&lt;/li&gt;
&lt;li&gt;Check the live page with PageSpeed Insights.&lt;/li&gt;
&lt;li&gt;Monitor field data as new visits are collected.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Keep a short note with the page, metric, suspected cause, change, and result. This gives the next developer evidence instead of a vague instruction to “make the site faster.”&lt;/p&gt;

&lt;p&gt;Core Web Vitals are useful because each metric points to a different part of the first visit. Fix the failed experience the metric describes, and keep the landing page's main promise visible throughout the work.&lt;/p&gt;

&lt;p&gt;Hey I'm Uriel Bitton. I write about building in public strategies and growing startups.&lt;/p&gt;

&lt;p&gt;Subscribe for more stories on growing your audience by building in public.&lt;/p&gt;

&lt;p&gt;Join us on &lt;a href="https://buildside.app/" rel="noopener noreferrer"&gt;Buildside&lt;/a&gt;: the social network for founders building in public.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://web.dev/articles/vitals" rel="noopener noreferrer"&gt;web.dev: Web Vitals&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://web.dev/articles/vitals-tools" rel="noopener noreferrer"&gt;web.dev: Core Web Vitals workflows with Google tools&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://web.dev/articles/optimize-lcp" rel="noopener noreferrer"&gt;web.dev: Optimize Largest Contentful Paint&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://web.dev/articles/optimize-cls" rel="noopener noreferrer"&gt;web.dev: Optimize Cumulative Layout Shift&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>performance</category>
      <category>webdev</category>
      <category>saas</category>
      <category>css</category>
    </item>
    <item>
      <title>SaaS Loading States: Tell Users When Work Is Really Finished</title>
      <dc:creator>Uriel Bitton</dc:creator>
      <pubDate>Sun, 20 Sep 2026 02:19:49 +0000</pubDate>
      <link>https://dev.to/urielbitton/saas-loading-states-tell-users-when-work-is-really-finished-2neo</link>
      <guid>https://dev.to/urielbitton/saas-loading-states-tell-users-when-work-is-really-finished-2neo</guid>
      <description>&lt;p&gt;A useful SaaS loading state tells people what is happening, whether they can leave, and what they can do next. Show success only when the promised result is ready. Accepting a request and finishing the work are different events.&lt;/p&gt;

&lt;p&gt;Consider a fictional app that imports a contact list. The browser can finish uploading the file while the server is still checking rows. A cheerful “Done” at that point makes the screen describe a result the app hasn't produced yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Name the stage the user is waiting for
&lt;/h2&gt;

&lt;p&gt;Give each stage a plain label. For this import, that might be “Uploading file,” “Checking rows,” and “Adding contacts.” Keep the label close to the action or result it describes.&lt;/p&gt;

&lt;p&gt;Avoid showing a percentage unless it represents measured work. If the app knows that 80 of 200 rows have been checked, it can report that count. If it only knows that a job is waiting to start, “Waiting to start” is more honest than a slowly moving bar.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/progressbar_role" rel="noopener noreferrer"&gt;MDN's progressbar reference&lt;/a&gt; explains that an indeterminate progress bar has no current numeric value. It also recommends native HTML progress elements where suitable. Match the accessible value to the progress you can actually measure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define what finished means
&lt;/h2&gt;

&lt;p&gt;Before choosing an animation, write the success condition in a sentence. For our example: “The accepted contacts are saved and available in the contact list.”&lt;/p&gt;

&lt;p&gt;Then decide how the screen describes a partial result. If 190 rows were added and 10 need changes, show both counts and a way to inspect the rejected rows. Calling the entire job successful hides work the person still needs to do.&lt;/p&gt;

&lt;p&gt;Treat these as separate states in the product design:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Waiting for work to begin.&lt;/li&gt;
&lt;li&gt;Work in progress.&lt;/li&gt;
&lt;li&gt;Finished with the full result.&lt;/li&gt;
&lt;li&gt;Finished with items that need attention.&lt;/li&gt;
&lt;li&gt;Failed, with a clear next action.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The exact states depend on the task. A payment, file upload, and report build should each describe their own real outcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  Explain whether leaving is safe
&lt;/h2&gt;

&lt;p&gt;Only say “You can close this page” if the job will continue and the person has a reliable way to find the result later. In the import example, that could be an import history page with the same job and its latest status.&lt;/p&gt;

&lt;p&gt;If closing the page cancels an upload, explain that while the upload is running. Once the server has accepted the file and taken over the job, update the message to reflect that change.&lt;/p&gt;

&lt;p&gt;A reload should restore the actual state when the product supports background work. Replacing an active job with an empty screen gives the user no useful account of what happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make status changes available to screen readers
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html" rel="noopener noreferrer"&gt;W3C's guidance on status messages&lt;/a&gt; covers changes such as waiting, progress, success, and errors that do not move focus. These messages need to be available to assistive technology through appropriate roles or properties.&lt;/p&gt;

&lt;p&gt;Announce meaningful changes and test the result with a screen reader. Avoid moving keyboard focus merely to point at an ordinary progress update. Also avoid turning every tiny progress change into a stream of announcements.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the awkward endings
&lt;/h2&gt;

&lt;p&gt;Try a slow connection, a failed request, a reload during processing, and a job with some invalid rows. Check what happens when someone clicks again because the first click appeared to do nothing.&lt;/p&gt;

&lt;p&gt;For each case, ask whether the screen tells the truth and offers a safe next step. If a retry could repeat completed work, fix that behavior before presenting retry as the answer.&lt;/p&gt;

&lt;p&gt;The final message should name the result: “190 contacts added. Review 10 rows.” Now the person knows where the work stands and what remains.&lt;/p&gt;

&lt;p&gt;Hey I'm Uriel Bitton. I write about building in public strategies and growing startups.&lt;/p&gt;

&lt;p&gt;Subscribe for more stories on growing your audience by building in public.&lt;/p&gt;

&lt;p&gt;Join us on &lt;a href="https://buildside.app/" rel="noopener noreferrer"&gt;Buildside&lt;/a&gt;: the social network for founders building in public.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html" rel="noopener noreferrer"&gt;W3C: Understanding Status Messages, WCAG 2.2&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/progressbar_role" rel="noopener noreferrer"&gt;MDN: ARIA progressbar role&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>a11y</category>
      <category>saas</category>
    </item>
    <item>
      <title>SaaS CSV Exports: Give Users a File They Can Actually Use</title>
      <dc:creator>Uriel Bitton</dc:creator>
      <pubDate>Fri, 18 Sep 2026 16:57:06 +0000</pubDate>
      <link>https://dev.to/urielbitton/saas-csv-exports-give-users-a-file-they-can-actually-use-5e9n</link>
      <guid>https://dev.to/urielbitton/saas-csv-exports-give-users-a-file-they-can-actually-use-5e9n</guid>
      <description>&lt;p&gt;A useful SaaS CSV export lets a customer finish a job outside your app without guessing what the rows mean. Define the job, include clear labels and stable record IDs, and test the downloaded file in the tool the customer will use next. The download button is only the start of that handoff.&lt;/p&gt;

&lt;p&gt;For a small product, one reliable export can be more useful than a menu of formats nobody has checked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the next task
&lt;/h2&gt;

&lt;p&gt;Ask what happens after the file is downloaded. Does someone review overdue work in a spreadsheet, move records to another app, or keep a copy for later?&lt;/p&gt;

&lt;p&gt;Imagine a fictional project tracker. Its customer wants to review open tasks with a client. An export of every database field would include more than that conversation needs. A focused file could contain task ID, title, status, owner label, and due date.&lt;/p&gt;

&lt;p&gt;Make the scope visible before export: this project, open tasks, and the selected date range. A customer should not have to count rows to discover that only one screen of results was included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give every column a clear meaning
&lt;/h2&gt;

&lt;p&gt;Choose headers that make sense without the app beside them. If a date includes a time, explain its timezone. If a value is an amount, name the currency or provide a currency column. Define whether a blank means unknown, not set, or not applicable.&lt;/p&gt;

&lt;p&gt;Keep a stable record ID when people may need to match the file back to the product. A task title can change; a matching process needs a more dependable reference.&lt;/p&gt;

&lt;p&gt;Write a short field guide beside the export control or in help content. For the project tracker, explain whether the due date is a calendar date and which statuses count as open. These are product choices you should make explicitly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use a CSV writer and test awkward values
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc4180" rel="noopener noreferrer"&gt;RFC 4180&lt;/a&gt; documents common CSV conventions, including consistent field counts and quoting fields that contain commas, quotes, or line breaks. It is an informational document, not a guarantee that every spreadsheet program behaves identically.&lt;/p&gt;

&lt;p&gt;Use an established CSV library for your runtime instead of joining values with commas by hand. Then check a small sample with a comma in a title, a quotation mark, a line break, a blank field, and non-English text.&lt;/p&gt;

&lt;p&gt;Open the result in the destination your customers actually use. Confirm that one task remains one row and the values stay in the intended columns. Keep those samples as regression checks when the export changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat spreadsheet formulas as a separate risk
&lt;/h2&gt;

&lt;p&gt;Correct CSV quoting does not settle every spreadsheet risk. &lt;a href="https://community.owasp.org/attacks/CSV_Injection" rel="noopener noreferrer"&gt;OWASP describes CSV injection&lt;/a&gt;, where untrusted values can be interpreted as formulas when a spreadsheet opens the file. It also notes that there is no single sanitization strategy that suits every spreadsheet and downstream use.&lt;/p&gt;

&lt;p&gt;Choose protections for the destination and test them, including saving and reopening the file. Document any transformation that changes the underlying value. A file meant for human review and a file meant for another program may need different handling.&lt;/p&gt;

&lt;p&gt;Keep exports subject to the same access rules as the records on screen. Test with an account that has limited access as well as an owner account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the handoff from a fresh download
&lt;/h2&gt;

&lt;p&gt;Run the export as a normal user. Check the filename, scope, row count, headers, and sample values. Repeat with no matching records and enough records to cross the app's page limit.&lt;/p&gt;

&lt;p&gt;For a long export, explain whether it is still running, finished, or failed. Give the user a clear way to retry without mistaking an old download for a new result.&lt;/p&gt;

&lt;p&gt;The acceptance check is concrete: can the customer open this exact file, understand what it contains, and complete the task they came for?&lt;/p&gt;

&lt;p&gt;Hey I'm Uriel Bitton. I write about building in public strategies and growing startups.&lt;/p&gt;

&lt;p&gt;Subscribe for more stories on growing your audience by building in public.&lt;/p&gt;

&lt;p&gt;Join us on &lt;a href="https://buildside.app/" rel="noopener noreferrer"&gt;Buildside&lt;/a&gt;: the social network for founders building in public.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc4180" rel="noopener noreferrer"&gt;RFC 4180: Common Format and MIME Type for CSV Files&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://community.owasp.org/attacks/CSV_Injection" rel="noopener noreferrer"&gt;OWASP: CSV Injection&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>saas</category>
      <category>testing</category>
    </item>
    <item>
      <title>SaaS Empty States: Help a New User Finish the First Task</title>
      <dc:creator>Uriel Bitton</dc:creator>
      <pubDate>Thu, 17 Sep 2026 13:27:30 +0000</pubDate>
      <link>https://dev.to/urielbitton/saas-empty-states-help-a-new-user-finish-the-first-task-3fkb</link>
      <guid>https://dev.to/urielbitton/saas-empty-states-help-a-new-user-finish-the-first-task-3fkb</guid>
      <description>&lt;p&gt;A useful SaaS empty state explains what belongs on the screen and gives the user one clear next step. First check why the screen is empty. A new account, a search with no matches, and a failed request need different messages and actions.&lt;/p&gt;

&lt;p&gt;Treat the empty state as part of the first task. Its job is to help someone create a real result they can come back to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Name the state before writing the message
&lt;/h2&gt;

&lt;p&gt;Imagine a small app for collecting customer interview notes. A blank list might mean the account has no interviews yet. It might also mean a filter hides every saved note, or that the app could not load the list.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://carbondesignsystem.com/patterns/empty-states-pattern/" rel="noopener noreferrer"&gt;Carbon's empty-state guidance&lt;/a&gt; separates first-use, user-action, and error situations. Use that distinction in your rendering logic before polishing the copy.&lt;/p&gt;

&lt;p&gt;Keep the loading state separate too. Showing “No interviews yet” while a request is still running tells the user something you have not established.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give the first screen one useful action
&lt;/h2&gt;

&lt;p&gt;For a new account, try a heading such as “Your interview notes will appear here.” Add a sentence explaining the result: “Create an interview to keep its notes and follow-up questions together.”&lt;/p&gt;

&lt;p&gt;The primary action could be “Create an interview.” That label tells the user what will happen. “Get started” leaves more guessing to do.&lt;/p&gt;

&lt;p&gt;Do not put five setup tasks into this one panel. If creating an interview needs a project first, make the next action create the project and explain that dependency. The right button is the first step a new user can actually complete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make each recovery action fit the problem
&lt;/h2&gt;

&lt;p&gt;When a filter returns no matches, preserve the user's search and offer a way to clear the filter. Sending them to the create form would answer the wrong question.&lt;/p&gt;

&lt;p&gt;When loading fails, say that the notes could not be loaded. Offer a retry if retrying is supported. Keep any data the user already entered while they recover.&lt;/p&gt;

&lt;p&gt;For a permission problem, explain who can grant access if your product has that information. Do not show a create button that the current role cannot use.&lt;/p&gt;

&lt;p&gt;A short set of named states can make these choices easy to review:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Loading: wait for the current request.&lt;/li&gt;
&lt;li&gt;Load failed: retry the same request.&lt;/li&gt;
&lt;li&gt;No saved interviews: create the first interview.&lt;/li&gt;
&lt;li&gt;No matching interviews: adjust the current filter.&lt;/li&gt;
&lt;li&gt;Access unavailable: use the supported access process.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are example product states. Adapt them to your app's real behavior instead of adding branches that cannot occur.&lt;/p&gt;

&lt;h2&gt;
  
  
  Continue the task after the click
&lt;/h2&gt;

&lt;p&gt;Test the whole path with a new account. Click the action, complete the smallest valid form, save, and return to the list. Check that the new item appears and can be opened.&lt;/p&gt;

&lt;p&gt;Then cancel halfway through. The app should return to a state that still makes sense. Try a failed save as well; a friendly first screen cannot compensate for losing the user's first set of notes.&lt;/p&gt;

&lt;p&gt;Use a sample item only when it teaches something that the empty screen cannot. Label it as sample content, and make the difference from saved customer work clear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review the screen without its illustration
&lt;/h2&gt;

&lt;p&gt;Temporarily ignore the artwork and read only the heading, explanation, and action. You should still understand what is missing and what to do next.&lt;/p&gt;

&lt;p&gt;Check the screen with keyboard navigation, long button labels, a narrow window, and the roles your product supports. Record whether the user completed the first task, rather than only whether they clicked the button.&lt;/p&gt;

&lt;p&gt;The useful test is simple: can a new user turn this empty space into one saved result, and find that result again?&lt;/p&gt;

&lt;p&gt;Hey I'm Uriel Bitton. I write about building in public strategies and growing startups.&lt;/p&gt;

&lt;p&gt;Subscribe for more stories on growing your audience by building in public.&lt;/p&gt;

&lt;p&gt;Join us on &lt;a href="https://buildside.app/" rel="noopener noreferrer"&gt;Buildside&lt;/a&gt;: the social network for founders building in public.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://carbondesignsystem.com/patterns/empty-states-pattern/" rel="noopener noreferrer"&gt;Carbon Design System: Empty states&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>ux</category>
      <category>saas</category>
    </item>
    <item>
      <title>SaaS Signup Forms: Make Errors Easy to Fix</title>
      <dc:creator>Uriel Bitton</dc:creator>
      <pubDate>Wed, 16 Sep 2026 17:12:32 +0000</pubDate>
      <link>https://dev.to/urielbitton/saas-signup-forms-make-errors-easy-to-fix-3pn7</link>
      <guid>https://dev.to/urielbitton/saas-signup-forms-make-errors-easy-to-fix-3pn7</guid>
      <description>&lt;p&gt;Make a signup error useful by naming the field, explaining the problem, and showing the next action. Keep valid entries in place and test the path from a failed submission to a successful one. The recovery path deserves its own review before you send more traffic to the page.&lt;/p&gt;

&lt;p&gt;A form can look finished when every test uses correct input. The more revealing test starts with a mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write the correction before styling the error
&lt;/h2&gt;

&lt;p&gt;For a fictional workspace signup, imagine that the visitor enters a workspace name with spaces, but the product needs a short URL segment. A red border alone does not explain the rule.&lt;/p&gt;

&lt;p&gt;A useful message could say: “Use letters, numbers, and hyphens for your workspace address.” Only use that wording if it matches your actual rule. Show the rule before submission too, so the visitor has a chance to get it right.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.w3.org/WAI/tutorials/forms/notifications/" rel="noopener noreferrer"&gt;W3C's form notification guidance&lt;/a&gt; recommends clear error descriptions with instructions for correction. It also describes linking an error summary to the affected controls.&lt;/p&gt;

&lt;p&gt;Use the same field name in the label and message. A visitor should not have to work out that “tenant identifier” means the field labelled “Workspace address.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Connect the message to the field
&lt;/h2&gt;

&lt;p&gt;The error should be available to people using assistive technology. W3C shows how &lt;code&gt;aria-describedby&lt;/code&gt; can associate a field with its error message. Here is a small markup example for an error that has already been found:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;label&lt;/span&gt; &lt;span class="na"&gt;for=&lt;/span&gt;&lt;span class="s"&gt;"workspace"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Workspace address&lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt;
  &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"workspace"&lt;/span&gt;
  &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"workspace"&lt;/span&gt;
  &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"my team"&lt;/span&gt;
  &lt;span class="na"&gt;aria-describedby=&lt;/span&gt;&lt;span class="s"&gt;"workspace-error"&lt;/span&gt;
&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"workspace-error"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  Use letters, numbers, and hyphens for your workspace address.
&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This snippet shows the relationship between a field and a message. It does not validate input or implement the full submission flow. When you change the message, keep that relationship intact and test what is announced in the finished interface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let the browser help, then check the server
&lt;/h2&gt;

&lt;p&gt;Start with HTML controls and constraints that match the data you need. For example, &lt;code&gt;required&lt;/code&gt; marks a mandatory field, and &lt;code&gt;type="email"&lt;/code&gt; provides an email-format check. &lt;a href="https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/Form_validation" rel="noopener noreferrer"&gt;MDN's form validation guide&lt;/a&gt; explains these built-in features and when JavaScript can add custom behavior.&lt;/p&gt;

&lt;p&gt;An email-format check does not establish that someone owns an address. Also, browser checks can be bypassed. MDN recommends validating submitted data on the server as well.&lt;/p&gt;

&lt;p&gt;Keep the two layers consistent. If the browser accepts a workspace address that the server rejects, show the server's reason in language the visitor can act on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test recovery as a complete task
&lt;/h2&gt;

&lt;p&gt;Use a few deliberate scenarios in your review:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Leave a required field empty, submit, and correct it.&lt;/li&gt;
&lt;li&gt;Enter an address that fails the product's real format rule.&lt;/li&gt;
&lt;li&gt;Submit a valid form while the service is temporarily unavailable.&lt;/li&gt;
&lt;li&gt;Correct one field and check that the other valid entries remain.&lt;/li&gt;
&lt;li&gt;Complete the same task using the keyboard.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For each scenario, record the starting input, the response, and the next action. Do not record real passwords or customer data in a shared test note.&lt;/p&gt;

&lt;p&gt;A service failure needs a different message from invalid input. If the server cannot respond, telling the visitor to “check your details” sends them toward the wrong repair.&lt;/p&gt;

&lt;h2&gt;
  
  
  Confirm the actual next state
&lt;/h2&gt;

&lt;p&gt;After a successful submission, explain what happened. If an account exists, say so. If the visitor still needs to confirm an email address, explain that step. W3C includes success feedback alongside error feedback because both tell the user whether the task is complete.&lt;/p&gt;

&lt;p&gt;Review the words against the product's real behavior. A clear success message is the last part of the same recovery path: the visitor should know when they can stop fixing the form and move on.&lt;/p&gt;

&lt;p&gt;Hey I'm Uriel Bitton. I write about building in public strategies and growing startups.&lt;/p&gt;

&lt;p&gt;Subscribe for more stories on growing your audience by building in public.&lt;/p&gt;

&lt;p&gt;Join us on &lt;a href="https://buildside.app/" rel="noopener noreferrer"&gt;Buildside&lt;/a&gt;: the social network for founders building in public.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.w3.org/WAI/tutorials/forms/notifications/" rel="noopener noreferrer"&gt;W3C Web Accessibility Initiative: User Notification&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/Form_validation" rel="noopener noreferrer"&gt;MDN: Client-side form validation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>a11y</category>
      <category>ux</category>
    </item>
    <item>
      <title>SaaS Demo Data: Show One Complete Workflow</title>
      <dc:creator>Uriel Bitton</dc:creator>
      <pubDate>Tue, 15 Sep 2026 14:41:37 +0000</pubDate>
      <link>https://dev.to/urielbitton/saas-demo-data-show-one-complete-workflow-6m0</link>
      <guid>https://dev.to/urielbitton/saas-demo-data-show-one-complete-workflow-6m0</guid>
      <description>&lt;p&gt;A useful SaaS demo gives visitors a small, believable task they can finish with sample data. Start with one coherent scenario, keep its records consistent across screens, and make it easy to return to the starting state. A crowded dashboard can show features while leaving the product's purpose unclear.&lt;/p&gt;

&lt;p&gt;For an early product, demo data is part of the explanation. It tells the visitor who they are, what needs attention, and what a successful result looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choose the task before generating records
&lt;/h2&gt;

&lt;p&gt;Write the task in one sentence. For a fictional client-review tool, it might be: find the design waiting for feedback, leave a comment, and mark the review complete.&lt;/p&gt;

&lt;p&gt;That sentence tells you which data you need. You need a project, a reviewable file, a pending status, and a place for feedback. You probably do not need fifty customers or a year's worth of activity.&lt;/p&gt;

&lt;p&gt;Use the same task to check the demo later. If the visitor cannot reach its result, more realistic names and avatars will not repair the missing path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the sample story consistent
&lt;/h2&gt;

&lt;p&gt;Create a small set of records you control. The following JavaScript object is an illustrative fixture, meaning a repeatable set of sample data. It is not a complete application or a production data model.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;demo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;project-demo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Sample studio website&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;review&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;review-demo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;project-demo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;fileName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Homepage draft&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;waiting_for_feedback&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The names and IDs are fictional. The useful part is the relationship: the review belongs to the project, and its status gives the visitor a reason to act.&lt;/p&gt;

&lt;p&gt;Use those same records on the project list, review screen, and activity view. If one screen says the review is waiting while another calls it complete, the visitor has to solve a data puzzle before understanding the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Include the state that explains the value
&lt;/h2&gt;

&lt;p&gt;Perfectly finished data leaves nothing to do. Give the scenario one clear open task and enough surrounding context to make it believable.&lt;/p&gt;

&lt;p&gt;For the sample review tool, include a prior comment and a design that still needs a decision. After the visitor completes the task, show what changed. The new status and comment should be visible where a person would look for confirmation.&lt;/p&gt;

&lt;p&gt;Keep an empty state available as a separate example when it matters. A visitor should also be able to understand what starting a new project would involve. Do not mix unrelated states into the same story merely to fill every component.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the demo easy to reset
&lt;/h2&gt;

&lt;p&gt;Choose a reset approach that fits the implementation. A local interactive demo might rebuild its state from the fixture. A hosted demo could create a separate sample workspace for each session. Treat those as design options, not interchangeable security solutions.&lt;/p&gt;

&lt;p&gt;Define what the reset restores: records, comments, statuses, and any sample uploads. Then try the whole task twice. A second visitor should not inherit a confusing half-finished review from the first.&lt;/p&gt;

&lt;p&gt;Keep demo actions separate from real customer operations. If a button would normally send an email or charge a card, explain what it does in the demo and prevent it from triggering the real operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Label the limits of what people are seeing
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://www.gov.uk/service-manual/design/making-prototypes" rel="noopener noreferrer"&gt;GOV.UK guide to making prototypes&lt;/a&gt; distinguishes realistic interactions from production-ready code. Apply that distinction clearly: a convincing sample screen is not evidence that every underlying service is ready.&lt;/p&gt;

&lt;p&gt;Tell visitors that the workspace uses sample data. Label simulated actions and show which parts they can actually try. Avoid using real customer exports as a shortcut to a busy-looking screen.&lt;/p&gt;

&lt;p&gt;Before sharing, open a fresh session and complete the task without your usual explanation. Check the links, reset behavior, and final state. The demo is ready when the sample story still makes sense after someone interacts with it.&lt;/p&gt;

&lt;p&gt;Hey I'm Uriel Bitton. I write about building in public strategies and growing startups.&lt;/p&gt;

&lt;p&gt;Subscribe for more stories on growing your audience by building in public.&lt;/p&gt;

&lt;p&gt;Join us on &lt;a href="https://buildside.app/" rel="noopener noreferrer"&gt;Buildside&lt;/a&gt;: the social network for founders building in public.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.gov.uk/service-manual/design/making-prototypes" rel="noopener noreferrer"&gt;GOV.UK Service Manual: Making prototypes&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>saas</category>
      <category>ux</category>
    </item>
    <item>
      <title>Track SaaS Signups in GA4 Without Counting Every Click</title>
      <dc:creator>Uriel Bitton</dc:creator>
      <pubDate>Mon, 14 Sep 2026 16:38:10 +0000</pubDate>
      <link>https://dev.to/urielbitton/track-saas-signups-in-ga4-without-counting-every-click-138h</link>
      <guid>https://dev.to/urielbitton/track-saas-signups-in-ga4-without-counting-every-click-138h</guid>
      <description>&lt;p&gt;Track a SaaS signup when a new account has been created successfully. Keep the signup button click, the completed account, and the first useful product action as separate events. They answer different questions, and combining them can hide where people stop.&lt;/p&gt;

&lt;p&gt;Before adding tracking code, write down the exact state that counts as success. A dashboard is easier to trust when its event names describe real changes in the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the completed signup in plain words
&lt;/h2&gt;

&lt;p&gt;For a sample app, the definition might be: a new account exists, the creation request succeeded, and the user can continue into setup. If your product requires verified email before the account is usable, decide which stage you need to measure and name it clearly.&lt;/p&gt;

&lt;p&gt;Google's &lt;a href="https://developers.google.com/analytics/devguides/collection/ga4/reference/events?authuser=1&amp;amp;hl=en" rel="noopener noreferrer"&gt;recommended event reference&lt;/a&gt; defines &lt;code&gt;sign_up&lt;/code&gt; for account signup and allows a &lt;code&gt;method&lt;/code&gt; parameter. Use that event for a completed signup, with a simple value such as Email or Google to describe the method.&lt;/p&gt;

&lt;p&gt;Do not send it from the button's click handler. A person can click and then fail validation, hit a network error, or discover that the account already exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trigger the event from a confirmed result
&lt;/h2&gt;

&lt;p&gt;Put the tracking call in the part of your app that handles successful creation. The following small example shows the analytics call only; it assumes the Google tag is already configured and any required consent is in place.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;event&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sign_up&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Google's &lt;a href="https://developers.google.com/analytics/devguides/collection/ga4/events" rel="noopener noreferrer"&gt;event setup guide&lt;/a&gt; explains how these calls fit into a tagged website. Connect this call to your app's confirmed result rather than copying it into a generic page-load effect.&lt;/p&gt;

&lt;p&gt;Choose one place that owns the event. If both the signup component and the welcome page send it, one account may produce two events. If an authentication callback runs again, it should not automatically mean a second account was created.&lt;/p&gt;

&lt;p&gt;Keep your application's account records as the source for whether creation succeeded. An analytics event is a report about that outcome, not the operation that creates the account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep first value separate from account creation
&lt;/h2&gt;

&lt;p&gt;A new account does not tell you whether the person reached the result your landing page promised. Pick a second event for the first useful task.&lt;/p&gt;

&lt;p&gt;For a sample file-checklist app, that might be a checklist created with at least one requested file. Write the definition before choosing the name. Explain whether the event means the first occurrence per account or every completed checklist.&lt;/p&gt;

&lt;p&gt;Use an existing recommended event when its meaning fits. Otherwise choose a clear custom event and document it. Do not force every product action into an onboarding event merely because that event already exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the paths that create misleading counts
&lt;/h2&gt;

&lt;p&gt;Run a small set of cases before using the report to judge marketing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A valid signup creates one account and sends one signup event.&lt;/li&gt;
&lt;li&gt;Invalid input sends no completed-signup event.&lt;/li&gt;
&lt;li&gt;An existing user signing in does not count as a new signup.&lt;/li&gt;
&lt;li&gt;A failed request followed by a successful retry does not count twice.&lt;/li&gt;
&lt;li&gt;Refreshing the welcome page does not create another signup event.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Inspect the events and parameters in GA4's Realtime or DebugView tools, as described in the setup guide. Compare what appears there with the outcome you observed in the app. This is a check of the tracking path, not a guarantee that every future event will arrive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep private information out of event fields
&lt;/h2&gt;

&lt;p&gt;Use a short list of approved parameter values. An account email, a person's name, or a support message does not belong in a signup method field. Google's &lt;a href="https://support.google.com/analytics/answer/6366371?hl=en" rel="noopener noreferrer"&gt;guidance on avoiding personally identifiable information&lt;/a&gt; is the relevant starting point when reviewing what your implementation sends.&lt;/p&gt;

&lt;p&gt;Also inspect page addresses and query strings. A harmless event name does not make a URL containing personal details safe to collect.&lt;/p&gt;

&lt;p&gt;When you share progress publicly, label the measure precisely: completed accounts, first useful tasks, or paying customers. That small writing habit forces a useful engineering question: does the event actually prove what the sentence says?&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.google.com/analytics/devguides/collection/ga4/reference/events?authuser=1&amp;amp;hl=en" rel="noopener noreferrer"&gt;Google Analytics: Recommended events&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.google.com/analytics/devguides/collection/ga4/events" rel="noopener noreferrer"&gt;Google Analytics: Set up events&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.google.com/analytics/answer/6366371?hl=en" rel="noopener noreferrer"&gt;Google Analytics Help: Avoid sending personally identifiable information&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Hey I'm Uriel Bitton. I write about building in public strategies and growing startups.&lt;/p&gt;

&lt;p&gt;Subscribe for more stories on growing your audience by building in public.&lt;/p&gt;

&lt;p&gt;Join us on &lt;a href="https://buildside.app/" rel="noopener noreferrer"&gt;Buildside&lt;/a&gt;: the social network for founders building in public.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>analytics</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
