<?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: Kamran Khan</title>
    <description>The latest articles on DEV Community by Kamran Khan (@kamrankhandev).</description>
    <link>https://dev.to/kamrankhandev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4173976%2F1de9365f-d424-41d9-b721-f53b672e8548.webp</url>
      <title>DEV Community: Kamran Khan</title>
      <link>https://dev.to/kamrankhandev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kamrankhandev"/>
    <language>en</language>
    <item>
      <title>After every save, I refetch the page I'm on — not page one</title>
      <dc:creator>Kamran Khan</dc:creator>
      <pubDate>Fri, 30 Oct 2026 04:17:09 +0000</pubDate>
      <link>https://dev.to/kamrankhandev/after-every-save-i-refetch-the-page-im-on-not-page-one-29jo</link>
      <guid>https://dev.to/kamrankhandev/after-every-save-i-refetch-the-page-im-on-not-page-one-29jo</guid>
      <description>&lt;p&gt;Here's a bug I've shipped more than once: you edit a user on page 3 of an admin table, save, and the table jumps back to page 1. The edit worked. The user just isn't visible anymore, and for a second you wonder whether it worked at all.&lt;/p&gt;

&lt;p&gt;The fix in my starter kit's Users page is one argument: after closing the modal, call fetchUsers(meta.current_page) instead of fetchUsers(1). The modal closes, and the table reloads the page the admin was already looking at, with the fresh data from the server. The edited row is right there, updated. The newly added user shows up too, since the list is ordered newest-first — and if pagination shifted because of the add, the server's meta in the response corrects the pager anyway, because fetchUsers stores res.data.meta alongside the rows.&lt;/p&gt;

&lt;p&gt;Delete follows the same rule: after client.delete('/users/' + id), call fetchUsers(meta.current_page) again. Same page. If deleting the last row on a page leaves it empty, that's a real edge case — the honest version of this code would step back a page when the response comes back empty — but the common case, which is what a starter kit should get right first, is staying put.&lt;/p&gt;

&lt;p&gt;Why refetch at all, instead of updating the local row optimistically? Because the server is the source of truth for this table, and the list endpoint is cheap. The create response returns the new user, the update response returns the updated user, but patching those into a paginated, sorted list by hand means re-implementing the server's ordering and pagination in the client. One refetch gets you the server's ordering, the correct page meta, and every other admin's concurrent changes, for the price of one GET.&lt;/p&gt;

&lt;p&gt;There's a discipline hiding in this: after a mutation, don't guess at the new state — ask for it. Optimistic updates are a performance optimisation, and they're worth it when a list is slow or a mutation is expensive. For an admin table over a local API, the refetch is fast, simple, and never wrong. I save the cleverness for places where it pays.&lt;/p&gt;

&lt;p&gt;Staying on the same page sounds trivial. But it's the difference between an admin who trusts the tool — save, see it change, move on — and one who keeps re-navigating to check their own work. Small respect for the user's attention, coded as one argument.&lt;/p&gt;

&lt;p&gt;This Users page, the paginated API behind it, and the whole admin panel ship in my Laravel + React SaaS Starter Kit — &lt;a href="https://kamranofficial.gumroad.com/l/mhekoig" rel="noopener noreferrer"&gt;https://kamranofficial.gumroad.com/l/mhekoig&lt;/a&gt; — a $19 one-time boilerplate with Sanctum auth and role-based access, so you don't rebuild this table from scratch.&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>laravel</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My sidebar footer is the whole auth UI: an initial, a role badge, and a logout button</title>
      <dc:creator>Kamran Khan</dc:creator>
      <pubDate>Fri, 30 Oct 2026 04:16:01 +0000</pubDate>
      <link>https://dev.to/kamrankhandev/my-sidebar-footer-is-the-whole-auth-ui-an-initial-a-role-badge-and-a-logout-button-34nj</link>
      <guid>https://dev.to/kamrankhandev/my-sidebar-footer-is-the-whole-auth-ui-an-initial-a-role-badge-and-a-logout-button-34nj</guid>
      <description>&lt;p&gt;I didn't set out to design an auth UI. I set out to stop answering the same three questions every time I opened the app: who am I signed in as, what can I do, and how do I get out? All three answers ended up in the same place — the footer of the sidebar, in my starter kit's Layout component.&lt;/p&gt;

&lt;p&gt;Here's what sits down there: a circle with the first letter of the user's name, uppercased, falling back to "U" if there's no name yet. Under it, the user's name, and a small badge showing user role exactly as the API returned it — "admin" or "user". And a Logout button.&lt;/p&gt;

&lt;p&gt;The initial needs no avatar upload, no image pipeline, no broken-image icon when a profile photo is missing. It's a circle with a letter in it, and it works on day one for every user, including the seeded demo admin.&lt;/p&gt;

&lt;p&gt;The role badge is the honest part. It shows the role right under the name. There's no separate "account type" page to visit, no settings screen to dig through. If you're wondering why you can't see the Users link, the badge tells you: you're not an admin. That same isAdmin check (user role === 'admin') is what hides the Users nav link above, so the badge and the navigation can never disagree.&lt;/p&gt;

&lt;p&gt;And the logout button does the full job, not half of it. Its handler awaits logout() from AuthContext and then navigates to /login. The logout posts to /auth/logout — which revokes the current token on the server — and removes saas_token from localStorage. Only then does the sidebar navigate to /login. Logging out is not "clear the token and hope"; it's revoke server-side, clear client-side, then move. If the API call fails, the client still clears the token, so a dead session can't linger — but the normal path revokes properly.&lt;/p&gt;

&lt;p&gt;The same initial shows up again in the top header, top-right. Same letter, same user, two places. That's not decoration; it's a cheap consistency check. If the header and the footer ever disagreed, something would be wrong with the auth state, and you'd spot it instantly.&lt;/p&gt;

&lt;p&gt;None of this is clever. That's the point. A starter kit's auth UI shouldn't need a design system or a profile service. It needs to answer three questions at a glance, in one place you always see. An initial, a role badge, and a logout button that actually logs you out — that's the whole thing, and it's enough.&lt;/p&gt;

&lt;p&gt;The full Layout, AuthContext, and token handling ship in my Laravel + React SaaS Starter Kit — &lt;a href="https://kamranofficial.gumroad.com/l/mhekoig" rel="noopener noreferrer"&gt;https://kamranofficial.gumroad.com/l/mhekoig&lt;/a&gt; — a $19 one-time boilerplate, so your next project starts with this already working instead of half-built.&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>saas</category>
    </item>
    <item>
      <title>My create endpoint returns 201 and a full user, not a bare id</title>
      <dc:creator>Kamran Khan</dc:creator>
      <pubDate>Fri, 30 Oct 2026 04:14:46 +0000</pubDate>
      <link>https://dev.to/kamrankhandev/my-create-endpoint-returns-201-and-a-full-user-not-a-bare-id-2c87</link>
      <guid>https://dev.to/kamrankhandev/my-create-endpoint-returns-201-and-a-full-user-not-a-bare-id-2c87</guid>
      <description>&lt;p&gt;Most create endpoints I see in client codebases return one of two lazy things: a 200 with the new id, or a 200 with a success message and nothing else. Then the frontend has to make a second request to show the thing it just created, or it guesses at the shape and gets it wrong.&lt;/p&gt;

&lt;p&gt;In my starter kit, creating a user returns this, every time:&lt;/p&gt;

&lt;p&gt;public function store(StoreUserRequest $request): JsonResponse&lt;br&gt;
{&lt;br&gt;
    $user = User::create($request-&amp;gt;validated());&lt;br&gt;
    return response()-&amp;gt;json(['user' =&amp;gt; new UserResource($user)], 201);&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;Two decisions are packed into those three lines, and both are deliberate.&lt;/p&gt;

&lt;p&gt;First, 201, not 200. A 200 says "fine, done." A 201 says "a resource now exists that didn't before." That distinction matters the day you write a frontend that branches on status, or a test that asserts creation actually happened. My register endpoint does the same — 201 with the user and a token — because registration is also a creation, not a generic success.&lt;/p&gt;

&lt;p&gt;Second, the full resource, not a bare id. The frontend gets the exact same UserResource shape it gets from the list endpoint and the show endpoint: id, name, email, role, timestamps. No second fetch, no special-case shape for the create response, no moment where the table shows a row with missing fields while it waits for a refetch.&lt;/p&gt;

&lt;p&gt;Consistency is the real point. Look at the rest of the controller and the pattern holds: show returns ['user' =&amp;gt; new UserResource($user)], update returns the same envelope after $user-&amp;gt;update($request-&amp;gt;validated()). One envelope, one resource, everywhere. The only endpoint that breaks the pattern is destroy, and it does so honestly — 204 with no body, because there is nothing left to return.&lt;/p&gt;

&lt;p&gt;There's a temptation to return more on create: the created record plus a message plus a redirect URL. I resist it. The frontend already knows what it just submitted; what it needs back is the server's version of the record — the id, the stored role, the timestamps — in the shape it already knows how to render.&lt;/p&gt;

&lt;p&gt;If you take one rule from this: never make your frontend fetch what your backend already has in its hands. Return the resource you just created, in the shape you always return it, with the status code that says it now exists.&lt;/p&gt;

&lt;p&gt;That pattern, along with the FormRequest validation and the role middleware that guard this endpoint, ships in my Laravel + React SaaS Starter Kit — &lt;a href="https://kamranofficial.gumroad.com/l/mhekoig" rel="noopener noreferrer"&gt;https://kamranofficial.gumroad.com/l/mhekoig&lt;/a&gt; — a $19 one-time boilerplate with Sanctum auth, role-based access, and a working admin panel, so you start from these decisions instead of re-arguing them.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>api</category>
      <category>saas</category>
    </item>
    <item>
      <title>My dashboard's "recent users" section is the admin table with the buttons turned off</title>
      <dc:creator>Kamran Khan</dc:creator>
      <pubDate>Fri, 23 Oct 2026 04:42:40 +0000</pubDate>
      <link>https://dev.to/kamrankhandev/my-dashboards-recent-users-section-is-the-admin-table-with-the-buttons-turned-off-1i3b</link>
      <guid>https://dev.to/kamrankhandev/my-dashboards-recent-users-section-is-the-admin-table-with-the-buttons-turned-off-1i3b</guid>
      <description>&lt;p&gt;On my dashboard, admins see a "Recent users" section under the stat cards. It is not a new component. It is the same &lt;code&gt;UsersTable&lt;/code&gt; the admin Users page uses, rendered with dead handlers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UsersTable&lt;/span&gt; &lt;span class="na"&gt;users&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;recentUsers&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onEdit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onDelete&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;UsersTable&lt;/code&gt; is a dumb component: it takes a list and two callbacks. Hand it real callbacks and you get the admin table. Hand it no-ops and the edit/delete buttons fire into the void, so it renders as a read-only list. Same markup, same columns, zero extra code.&lt;/p&gt;

&lt;p&gt;The data comes from the same endpoint too:&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="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/users&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;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;per_page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&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;One honest note: my backend &lt;code&gt;index()&lt;/code&gt; is &lt;code&gt;User::query()-&amp;gt;latest()-&amp;gt;paginate(15)&lt;/code&gt;, so it currently ignores &lt;code&gt;per_page&lt;/code&gt; and returns the first page of 15, newest first. The param is there for when I wire it up server-side. What matters is that the newest-first ordering makes page one the recent list for free — the "recent users" section is literally the same data the admin page shows, just displayed on the dashboard. And only admins fetch it: the whole section sits behind the &lt;code&gt;isAdmin&lt;/code&gt; gate, while regular users get a getting-started card instead.&lt;/p&gt;

&lt;p&gt;The point is boring on purpose. The dashboard did not need a second table component, a second fetch shape, or a second set of columns. It needed one dumb component used twice, with the buttons switched off the second time.&lt;/p&gt;

&lt;p&gt;This is one of the reuse patterns I bake into my Laravel + React SaaS starter kit — small components with honest props instead of near-duplicate ones: &lt;a href="https://kamranofficial.gumroad.com/l/mhekoig" rel="noopener noreferrer"&gt;https://kamranofficial.gumroad.com/l/mhekoig&lt;/a&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>laravel</category>
      <category>webdev</category>
    </item>
    <item>
      <title>One modal, two modes: my add-user and edit-user forms are the same form</title>
      <dc:creator>Kamran Khan</dc:creator>
      <pubDate>Fri, 23 Oct 2026 04:41:50 +0000</pubDate>
      <link>https://dev.to/kamrankhandev/one-modal-two-modes-my-add-user-and-edit-user-forms-are-the-same-form-l3j</link>
      <guid>https://dev.to/kamrankhandev/one-modal-two-modes-my-add-user-and-edit-user-forms-are-the-same-form-l3j</guid>
      <description>&lt;p&gt;My admin Users page has one user form, not two. It is the same modal, driven by a single state value:&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;modal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setModal&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 'add' | 'edit' | null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;openAdd&lt;/code&gt; resets the form to blanks and sets mode &lt;code&gt;'add'&lt;/code&gt;. &lt;code&gt;openEdit&lt;/code&gt; pre-fills name, email and role from the row, deliberately blanks the password, and sets mode &lt;code&gt;'edit'&lt;/code&gt;. Everything else — the inputs, the error rendering, the buttons — is shared.&lt;/p&gt;

&lt;p&gt;The mode shows up in exactly the places where add and edit genuinely differ:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"password"&lt;/span&gt;
  &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;modal&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;add&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="err"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Password is required when creating, optional when editing, with a small "(leave blank to keep current)" note in edit mode. And on submit:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;modal&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;add&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/users&lt;/span&gt;&lt;span class="dl"&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="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;payload&lt;/span&gt; &lt;span class="o"&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="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;email&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="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;role&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="nx"&gt;role&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="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;password&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;password&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/users/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;editing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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="nx"&gt;payload&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 edit payload strips the password unless the admin actually typed one. This mirrors the backend exactly: the create request requires the password, the update request says &lt;code&gt;sometimes&lt;/code&gt;. The frontend and the backend agree on which fields are mandatory in which mode, and that agreement lives in exactly one place on each side.&lt;/p&gt;

&lt;p&gt;Two more small things. The submit button reads "Create User" or "Save Changes" from the mode. And &lt;code&gt;closeModal()&lt;/code&gt; always resets the form to the empty shape, so opening Add right after an Edit never leaks the previous user's email into the blank form.&lt;/p&gt;

&lt;p&gt;I keep reaching for this pattern because the alternative — two near-identical forms — is where add and edit quietly drift apart. One form, one mode flag, and the two modes can only disagree where they are supposed to.&lt;/p&gt;

&lt;p&gt;This modal ships as-is in my Laravel + React SaaS starter kit, with the matching backend rules, auth and roles around it: &lt;a href="https://kamranofficial.gumroad.com/l/mhekoig" rel="noopener noreferrer"&gt;https://kamranofficial.gumroad.com/l/mhekoig&lt;/a&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>saas</category>
    </item>
    <item>
      <title>I never hash passwords in my controllers — the `hashed` cast does it</title>
      <dc:creator>Kamran Khan</dc:creator>
      <pubDate>Fri, 23 Oct 2026 04:40:50 +0000</pubDate>
      <link>https://dev.to/kamrankhandev/i-never-hash-passwords-in-my-controllers-the-hashed-cast-does-it-3aj9</link>
      <guid>https://dev.to/kamrankhandev/i-never-hash-passwords-in-my-controllers-the-hashed-cast-does-it-3aj9</guid>
      <description>&lt;p&gt;Every password write in my starter kit flows through one line on the User model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;casts&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'email_verified_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'datetime'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'hashed'&lt;/span&gt;&lt;span class="p"&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;Search my whole backend for &lt;code&gt;Hash::make&lt;/code&gt; and you will find nothing. The only Hash call is &lt;code&gt;Hash::check&lt;/code&gt; on login, which only reads. Nobody ever writes a hash by hand.&lt;/p&gt;

&lt;p&gt;So the register endpoint does this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'password'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'role'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'user'&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 controller passes the raw password straight through and the model hashes it before it touches the database. Same story in the admin create-user flow, and same in the seeder — &lt;code&gt;DatabaseSeeder&lt;/code&gt; passes &lt;code&gt;'password' =&amp;gt; 'password'&lt;/code&gt; in plain text and it lands hashed. The seeder does not know or care about bcrypt.&lt;/p&gt;

&lt;p&gt;Two things I like about this. First, hashing is a decision that can never be forgotten. There is no new controller where someone (me, three months from now) forgets to hash before saving. The wrong thing is impossible. Second, the cast does not double-hash: if the value is already hashed it leaves it alone, so the factory's pre-hashed &lt;code&gt;'password'&lt;/code&gt; stays exactly as it is.&lt;/p&gt;

&lt;p&gt;The login side never touches storage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&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="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nc"&gt;Hash&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;validated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'password'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;password&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="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'message'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Invalid credentials'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;401&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;Check on the way in, cast on the way out. One job each.&lt;/p&gt;

&lt;p&gt;I have stopped treating password hashing as something each controller does and started treating it as something the model guarantees. That is the whole pattern.&lt;/p&gt;

&lt;p&gt;Patterns like this one — auth, roles, admin CRUD, dashboard — are what my Laravel + React SaaS starter kit is made of, so every project starts from them instead of rediscovering them: &lt;a href="https://kamranofficial.gumroad.com/l/mhekoig" rel="noopener noreferrer"&gt;https://kamranofficial.gumroad.com/l/mhekoig&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>security</category>
      <category>saas</category>
    </item>
    <item>
      <title>My Dashboard fetches two things at once — and cancels both on unmount</title>
      <dc:creator>Kamran Khan</dc:creator>
      <pubDate>Thu, 22 Oct 2026 05:19:51 +0000</pubDate>
      <link>https://dev.to/kamrankhandev/my-dashboard-fetches-two-things-at-once-and-cancels-both-on-unmount-2ikn</link>
      <guid>https://dev.to/kamrankhandev/my-dashboard-fetches-two-things-at-once-and-cancels-both-on-unmount-2ikn</guid>
      <description>&lt;p&gt;My Dashboard page needs two things: the stats for the cards, and the five most recent users for the table underneath. Here's exactly how it loads them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cancelled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;statsRes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;usersRes&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/dashboard/stats&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nx"&gt;isAdmin&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/users&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;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;per_page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&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="nx"&gt;cancelled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nf"&gt;setStats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;statsRes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&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="nx"&gt;usersRes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;setRecentUsers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;usersRes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&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;cancelled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Failed to load dashboard data.&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="k"&gt;finally&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;cancelled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;setLoading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;cancelled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&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;isAdmin&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three decisions in here that I reuse on every page of the kit:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One: parallel fetches with &lt;code&gt;Promise.all&lt;/code&gt;.&lt;/strong&gt; The stats call and the users call don't depend on each other, so they go out together. The recent-users fetch only runs for admins — everyone else resolves &lt;code&gt;null&lt;/code&gt; and the table section never renders. No separate loading states to juggle, one &lt;code&gt;loading&lt;/code&gt; flag for the whole page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two: the &lt;code&gt;cancelled&lt;/code&gt; flag.&lt;/strong&gt; Every &lt;code&gt;setState&lt;/code&gt; after an &lt;code&gt;await&lt;/code&gt; is guarded. If the user navigates away mid-fetch, the cleanup flips the flag and the late responses are dropped silently. No "state update on an unmounted component" warnings, no stale data landing on the wrong page. It's five extra lines and it makes every page feel solid.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three: honest loading and error states.&lt;/strong&gt; Loading is a centred spinner (&lt;code&gt;animate-spin&lt;/code&gt; with an indigo top border). Failure is a rose-coloured box with the API's own message, or a fallback string. No skeletons pretending the data is almost here, no silent empty tables.&lt;/p&gt;

&lt;p&gt;And one small human touch: &lt;code&gt;Welcome back, {user?.name}&lt;/code&gt; at the top. Dashboards are cold by default; a name costs nothing.&lt;/p&gt;

&lt;p&gt;This same shape — &lt;code&gt;Promise.all&lt;/code&gt;, cancelled flag, spinner, error box — is copy-pasted into my Users page too. A starter kit teaches by repeating one good pattern until it's muscle memory, not by showing three clever ones.&lt;/p&gt;




&lt;p&gt;This Dashboard page ships in my Laravel + React SaaS Starter Kit — the React frontend wired to a real Laravel + Sanctum API: &lt;a href="https://kamranofficial.gumroad.com/l/mhekoig" rel="noopener noreferrer"&gt;https://kamranofficial.gumroad.com/l/mhekoig&lt;/a&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>laravel</category>
    </item>
    <item>
      <title>My whole design system is one 30-line CSS file</title>
      <dc:creator>Kamran Khan</dc:creator>
      <pubDate>Thu, 22 Oct 2026 05:18:41 +0000</pubDate>
      <link>https://dev.to/kamrankhandev/my-whole-design-system-is-one-30-line-css-file-bhl</link>
      <guid>https://dev.to/kamrankhandev/my-whole-design-system-is-one-30-line-css-file-bhl</guid>
      <description>&lt;p&gt;Here's my entire global stylesheet, in full:&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="k"&gt;@tailwind&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;@tailwind&lt;/span&gt; &lt;span class="n"&gt;components&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;@tailwind&lt;/span&gt; &lt;span class="n"&gt;utilities&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="err"&gt;@apply&lt;/span&gt; &lt;span class="err"&gt;bg-slate-100&lt;/span&gt; &lt;span class="err"&gt;text-slate-800&lt;/span&gt; &lt;span class="err"&gt;antialiased;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Custom scrollbar */&lt;/span&gt;
&lt;span class="nd"&gt;::-webkit-scrollbar&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;10px&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="m"&gt;10px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;::-webkit-scrollbar-track&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;#f1f5f9&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;::-webkit-scrollbar-thumb&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;#94a3b8&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;8px&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;#f1f5f9&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;::-webkit-scrollbar-thumb:hover&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;#64748b&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;That's it. Three Tailwind directives, one body rule, and a styled scrollbar.&lt;/p&gt;

&lt;p&gt;The kit's "look" doesn't come from this file. It comes from repeating the same utility combinations everywhere — &lt;code&gt;rounded-xl bg-white p-6 shadow-sm&lt;/code&gt; is my card, &lt;code&gt;text-sm font-medium text-slate-500&lt;/code&gt; is my label. My &lt;code&gt;StatCard&lt;/code&gt; component is literally that combo with four accent colour choices. The design system is a habit, not a file.&lt;/p&gt;

&lt;p&gt;So why the scrollbar? Because the users table scrolls, and a default browser scrollbar next to an otherwise polished admin panel is the kind of thing that makes a buyer think "unfinished". Ten lines of CSS, one considered detail. Slate track, rounded thumb, darker on hover — it matches the palette instead of fighting it.&lt;/p&gt;

&lt;p&gt;I deliberately did not add a component library, a theme file, CSS variables, or dark mode. Not because they're bad — because a starter kit should only ship what it actually uses. Architecture theatre is the fastest way to make a kit feel heavy. Thirty honest lines beats three hundred speculative ones.&lt;/p&gt;

&lt;p&gt;The body rule does the real work anyway: &lt;code&gt;bg-slate-100&lt;/code&gt; for the app background, &lt;code&gt;antialiased&lt;/code&gt; because un-anti-aliased text on a dashboard looks cheap. Two tokens, applied once, inherited everywhere.&lt;/p&gt;

&lt;p&gt;If you're shipping a kit: resist the theme file. Repeat one good combo until it becomes your design system, and spend your CSS budget on the ten lines people actually notice.&lt;/p&gt;




&lt;p&gt;This stylesheet ships in my Laravel + React SaaS Starter Kit — the whole frontend (React dashboard, auth pages, admin panel) included: &lt;a href="https://kamranofficial.gumroad.com/l/mhekoig" rel="noopener noreferrer"&gt;https://kamranofficial.gumroad.com/l/mhekoig&lt;/a&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>css</category>
      <category>webdev</category>
      <category>tailwindcss</category>
    </item>
    <item>
      <title>My login page shows the demo credentials — a starter kit has to be runnable in 60 seconds</title>
      <dc:creator>Kamran Khan</dc:creator>
      <pubDate>Thu, 22 Oct 2026 05:17:32 +0000</pubDate>
      <link>https://dev.to/kamrankhandev/my-login-page-shows-the-demo-credentials-a-starter-kit-has-to-be-runnable-in-60-seconds-4gbn</link>
      <guid>https://dev.to/kamrankhandev/my-login-page-shows-the-demo-credentials-a-starter-kit-has-to-be-runnable-in-60-seconds-4gbn</guid>
      <description>&lt;p&gt;Every starter kit I've bought died in the same place: the login screen. Not because the auth was broken — because I couldn't find a single valid credential to try it with. Buried in the README, step 9, between the queue worker and the mail config. I gave up and closed the tab.&lt;/p&gt;

&lt;p&gt;So when I built my own kit, I made one rule: the login page ships the demo credentials in the open.&lt;/p&gt;

&lt;p&gt;Here's the relevant part of my &lt;code&gt;Login.jsx&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VITE_DEMO_MODE&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"mt-3 rounded-lg bg-indigo-50 px-4 py-2 text-xs text-indigo-700"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    Interactive preview — sign in with the demo credentials below. Data is simulated.
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;)}&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* ... the form ... */&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"mt-6 rounded-lg bg-slate-50 px-4 py-3 text-xs text-slate-600"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"font-semibold text-slate-700"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Demo credentials&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"mt-1"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    Email: &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"font-mono"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;admin@demo.io&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    Password: &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"font-mono"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;password&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two pieces. The box under the form is always there: email &lt;code&gt;admin@demo.io&lt;/code&gt;, password &lt;code&gt;password&lt;/code&gt;. The banner above the form only renders when the build sets &lt;code&gt;VITE_DEMO_MODE=true&lt;/code&gt;, because it makes a claim that's only true in the public preview — "data is simulated", since the demo build runs against an in-memory mock axios client instead of Laravel.&lt;/p&gt;

&lt;p&gt;The credentials box isn't gated, because in a real local run those credentials are real. My &lt;code&gt;DatabaseSeeder&lt;/code&gt; creates them with &lt;code&gt;firstOrCreate&lt;/code&gt;, idempotent, so it works whether you seeded once or five times:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;firstOrCreate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'admin@demo.io'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Admin'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'password'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'role'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'admin'&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;Is printing credentials on a login page a security smell? In a production app, yes. In a starter kit, it's documentation that can't be skipped. Nobody reads README step 4; everybody stares at the login page. The credentials box is the fastest path from &lt;code&gt;git clone&lt;/code&gt; to seeing the dashboard.&lt;/p&gt;

&lt;p&gt;Two smaller details I kept: the submit button disables and switches to "Signing in…" while the request is in flight (no double submits), and a failed login renders a rose-coloured box above the form with the API's message — deliberately generic, never saying which half was wrong.&lt;/p&gt;

&lt;p&gt;If you're building a kit: make the login page teach the login. The README is for the 10% who read it.&lt;/p&gt;




&lt;p&gt;This login page is from my Laravel + React SaaS Starter Kit — the whole auth loop (Sanctum tokens, roles, seeded demo admin) ships in it: &lt;a href="https://kamranofficial.gumroad.com/l/mhekoig" rel="noopener noreferrer"&gt;https://kamranofficial.gumroad.com/l/mhekoig&lt;/a&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>saas</category>
    </item>
    <item>
      <title>My user payload includes a field my app can't set: why email_verified_at ships anyway</title>
      <dc:creator>Kamran Khan</dc:creator>
      <pubDate>Wed, 21 Oct 2026 04:16:40 +0000</pubDate>
      <link>https://dev.to/kamrankhandev/my-user-payload-includes-a-field-my-app-cant-set-why-emailverifiedat-ships-anyway-5f48</link>
      <guid>https://dev.to/kamrankhandev/my-user-payload-includes-a-field-my-app-cant-set-why-emailverifiedat-ships-anyway-5f48</guid>
      <description>&lt;p&gt;Here is the full &lt;code&gt;UserResource&lt;/code&gt; in my starter kit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;toArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'email'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'role'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'email_verified_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;email_verified_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'created_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'updated_at'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="p"&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;Seven fields. One of them is a lie, or at least an IOU: &lt;code&gt;email_verified_at&lt;/code&gt;. My kit has no email verification. No verification routes, no signed URLs, no "check your inbox" screen. For every real user of this boilerplate, that field is &lt;code&gt;null&lt;/code&gt;, forever, until the developer adds verification themselves.&lt;/p&gt;

&lt;p&gt;So why ship it?&lt;/p&gt;

&lt;p&gt;Because API payload shapes are contracts, and this field is forward compatibility bought at the cheapest possible price.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The day you add verification, nothing downstream changes.&lt;/strong&gt; The resource already emits the field. The React side already receives it. The demo mock mirrors it. Every component that reads a user object already handles a key that happens to be null today and a timestamp tomorrow. The feature lands as backend-only work: routes, mail, a controller. No frontend migration, no mock update, no "why did the user object change shape" debugging across a codebase you did not write.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Removing a field later is the expensive direction.&lt;/strong&gt; I have seen the reverse too many times: a minimal payload ships, six months later someone adds verification, and the new field ripples through types, mocks, tests, and three components that destructure the user object. Adding a field to an API response is a breaking change for every consumer you cannot see, including consumers of a boilerplate who built on your shape. Shipping the field early means the shape was always the final shape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Null is honest.&lt;/strong&gt; This only works because the field is truthfully null, not faked. The resource reads it straight from the model; the model reads it from the column; the column is empty because no flow writes to it. A frontend that shows "unverified" for a null timestamp is correct on day one and stays correct. What I would never do is invent a value, like defaulting it to &lt;code&gt;created_at&lt;/code&gt; to make the payload look complete. A placeholder value is a lie that downstream code will eventually trust.&lt;/p&gt;

&lt;p&gt;There is a boundary to this habit, and it is worth naming: I ship the &lt;em&gt;field&lt;/em&gt;, not the &lt;em&gt;feature&lt;/em&gt;. The kit still has no verification flow, no verification email, no UI for it. Shipping the field says "this is where the data will live". Shipping a half-built feature says "this works", and then it does not. One of those is forward compatibility. The other is a bug report waiting to happen.&lt;/p&gt;

&lt;p&gt;My rule of thumb for boilerplates: if a field is on the model's natural trajectory and costs nothing to emit, emit it now. Your future self, adding the feature properly, will change one backend file instead of ten files across the stack.&lt;/p&gt;

&lt;p&gt;I deliberately left verification (and password reset, and subscriptions) out of my starter kit to keep it shippable, and I wrote about those cuts before. The payload, though, is already ready for the day verification arrives. That is the whole kit's philosophy in one field: cut the feature, keep the shape. The full starter kit is here: &lt;a href="https://kamranofficial.gumroad.com/l/mhekoig" rel="noopener noreferrer"&gt;Laravel + React SaaS Starter Kit&lt;/a&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>api</category>
      <category>saas</category>
    </item>
    <item>
      <title>Three owners of one token: how my AuthContext, my axios client, and localStorage split auth state</title>
      <dc:creator>Kamran Khan</dc:creator>
      <pubDate>Wed, 21 Oct 2026 04:15:56 +0000</pubDate>
      <link>https://dev.to/kamrankhandev/three-owners-of-one-token-how-my-authcontext-my-axios-client-and-localstorage-split-auth-state-2k9d</link>
      <guid>https://dev.to/kamrankhandev/three-owners-of-one-token-how-my-authcontext-my-axios-client-and-localstorage-split-auth-state-2k9d</guid>
      <description>&lt;p&gt;In my starter kit, the Sanctum token has exactly one name and exactly two modules that touch it. That was a deliberate decision, and it is the reason I can change my auth storage strategy in an afternoon instead of a week.&lt;/p&gt;

&lt;p&gt;The name is &lt;code&gt;saas_token&lt;/code&gt;. It lives in localStorage. Here is everyone who is allowed to know that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Owner one: the axios client.&lt;/strong&gt; Every request passes through a request interceptor that reads the key and attaches the header:&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="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;interceptors&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="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;saas_token&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="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Authorization&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;config&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;And a response interceptor owns the "token died" path. On a 401, it clears the key and sends the user to login, unless they are already on the login or register page (redirecting someone off the login page because their non-existent token is invalid would be a special kind of bug):&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="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;interceptors&lt;/span&gt;&lt;span class="p"&gt;.&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;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;status&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;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&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="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/register&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="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;saas_token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/login&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="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&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;Note this happens at the network layer, outside React. No component, no context, no state update involved. An expired token redirects you before React has an opinion about it. And the interceptor still rejects the promise, so a form that triggered the 401 can still show its error state if it wants to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Owner two: the AuthContext.&lt;/strong&gt; It holds the token in React state, initialised from the same key, and it is the only place that &lt;em&gt;writes&lt;/em&gt; it:&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setToken&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;saas_token&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;login&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/auth/login&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="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;saas_token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;setToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;setUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&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;logout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Best-effort: the session is cleared locally no matter what.&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/auth/logout&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="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Ignore — token is being discarded either way.&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;saas_token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;setToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;setUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&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;Login and register write both localStorage and state in the same breath. Logout tries the server first (revoking the token server-side is polite) but clears locally regardless. The session dies on the client even if the server call fails, because a logout that depends on a healthy network is a logout that fails exactly when the user needs it most.&lt;/p&gt;

&lt;p&gt;On mount, the context restores the session: read the key, call &lt;code&gt;GET /auth/me&lt;/code&gt;, and if that fails, remove the key and settle into logged-out. One code path for "returning visitor" and "dead token".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Owner three: nobody.&lt;/strong&gt; That is the point. No component reads &lt;code&gt;localStorage&lt;/code&gt; directly. No page checks the token itself. Every component goes through &lt;code&gt;useAuth()&lt;/code&gt;, which exposes exactly six things: &lt;code&gt;user&lt;/code&gt;, &lt;code&gt;token&lt;/code&gt;, &lt;code&gt;loading&lt;/code&gt;, &lt;code&gt;login&lt;/code&gt;, &lt;code&gt;register&lt;/code&gt;, &lt;code&gt;logout&lt;/code&gt;. And &lt;code&gt;useAuth&lt;/code&gt; throws if you call it outside the provider, so forgetting the wrapper fails loudly in development instead of silently shipping &lt;code&gt;undefined&lt;/code&gt; auth everywhere:&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useAuth&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;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AuthContext&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;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;useAuth must be used within an AuthProvider&lt;/span&gt;&lt;span class="dl"&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;ctx&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 payoff: if I ever move from localStorage to httpOnly cookies, the change surface is two files, &lt;code&gt;client.js&lt;/code&gt; and &lt;code&gt;AuthContext.jsx&lt;/code&gt;. Every consumer keeps working because they never knew where the token lived. Ownership is a contract: one name, two modules, and a public API of six values. Everything else just asks.&lt;/p&gt;

&lt;p&gt;This is the auth layer of the starter kit I maintain and sell. The full thing, with the Sanctum backend and this exact frontend, is here: &lt;a href="https://kamranofficial.gumroad.com/l/mhekoig" rel="noopener noreferrer"&gt;Laravel + React SaaS Starter Kit&lt;/a&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>laravel</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My demo build is a config I never commit: the temporary vite setup behind the public preview</title>
      <dc:creator>Kamran Khan</dc:creator>
      <pubDate>Wed, 21 Oct 2026 04:15:07 +0000</pubDate>
      <link>https://dev.to/kamrankhandev/my-demo-build-is-a-config-i-never-commit-the-temporary-vite-setup-behind-the-public-preview-3f1a</link>
      <guid>https://dev.to/kamrankhandev/my-demo-build-is-a-config-i-never-commit-the-temporary-vite-setup-behind-the-public-preview-3f1a</guid>
      <description>&lt;p&gt;My SaaS starter kit has two builds. One ships to customers. One powers the public demo. They come from the same source tree, but the demo gets its own vite config, and that config has a comment at the top that says, in effect: temporary, demo only, do not commit.&lt;/p&gt;

&lt;p&gt;Here it is, the whole thing:&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="c1"&gt;// TEMPORARY build config for the /demo/ GitHub Pages deploy ONLY.&lt;/span&gt;
&lt;span class="c1"&gt;// Externalizes vendor deps (loaded via importmap from esm.sh) so the app&lt;/span&gt;
&lt;span class="c1"&gt;// bundle stays small. NOT for the shipped product — do not commit.&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;react&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@vitejs/plugin-react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;react&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
  &lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/laravel-react-saas-starter-kit/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;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;outDir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dist-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;rollupOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="p"&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;react&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;react/jsx-runtime&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;react-dom&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;react-dom/client&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;react-router-dom&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;axios&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="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;Twenty lines. Three decisions worth stealing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decision one: the demo build externalizes vendor dependencies.&lt;/strong&gt; The demo runs on GitHub Pages with &lt;code&gt;base&lt;/code&gt; set to the &lt;code&gt;/demo/&lt;/code&gt; subpath, and react, react-dom, react-router-dom and axios are declared external so they load from an import map (esm.sh) instead of being bundled. The app bundle stays small, which matters when your demo is the first impression and the visitor is on a phone connection. The shipped product does none of this; its normal vite config bundles everything the usual way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decision two: the demo config lives next to the real one, not inside it.&lt;/strong&gt; I did not add &lt;code&gt;if (demo)&lt;/code&gt; branches to the shipped &lt;code&gt;vite.config.js&lt;/code&gt;. Demo needs are not product needs. The day a demo special-case lands in the shipped config, every future customer build pays the tax: extra conditionals to read, extra ways for a demo flag to leak into production output. Two configs, one of them labelled disposable, keeps the product's build pipeline honest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decision three: the config is honest about being disposable.&lt;/strong&gt; The comment says do not commit it. In practice that means the demo deploy is a local, manual step: build with this config, push &lt;code&gt;dist-demo&lt;/code&gt; to the &lt;code&gt;/demo/&lt;/code&gt; path, and the repo's tracked build config stays the clean one. Nobody inherits a mysterious demo hack six months later and wonders whether it is load-bearing.&lt;/p&gt;

&lt;p&gt;How does the app know it is the demo? A build-time flag. The axios client checks it:&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;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;isDemoMode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;createDemoClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;axios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;baseURL&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="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VITE_API_URL&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:8000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&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;&lt;code&gt;VITE_DEMO_MODE=true&lt;/code&gt; swaps the real Laravel API for an in-memory mock that mirrors the endpoints and response shapes, so the preview is fully clickable with no backend. The product default is the real API. One seam, one flag, and the demo config never touches a line of application code.&lt;/p&gt;

&lt;p&gt;The pattern generalises: anything you build to &lt;em&gt;sell&lt;/em&gt; the thing should be physically separated from the thing itself. Demos rot faster than products. When the demo is a labelled, temporary config rather than conditionals woven through your build, deleting it later is a one-line act of hygiene instead of archaeology.&lt;/p&gt;

&lt;p&gt;I write about real patterns from client work and the starter kit I sell. If the behind-the-scenes of a $19 boilerplate interests you, the whole thing is here: &lt;a href="https://kamranofficial.gumroad.com/l/mhekoig" rel="noopener noreferrer"&gt;Laravel + React SaaS Starter Kit&lt;/a&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
