<?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: Michael Yousrie</title>
    <description>The latest articles on DEV Community by Michael Yousrie (@michael-yousrie).</description>
    <link>https://dev.to/michael-yousrie</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%2F4024580%2F5113fe07-bcda-40ee-bb54-f2d285440322.jpg</url>
      <title>DEV Community: Michael Yousrie</title>
      <link>https://dev.to/michael-yousrie</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/michael-yousrie"/>
    <language>en</language>
    <item>
      <title>UTM conventions that stop your analytics turning to mush</title>
      <dc:creator>Michael Yousrie</dc:creator>
      <pubDate>Fri, 31 Jul 2026 10:42:05 +0000</pubDate>
      <link>https://dev.to/michael-yousrie/utm-conventions-that-stop-your-analytics-turning-to-mush-332h</link>
      <guid>https://dev.to/michael-yousrie/utm-conventions-that-stop-your-analytics-turning-to-mush-332h</guid>
      <description>&lt;p&gt;Most broken analytics I have seen were not broken by a bug. They were broken by three people tagging links slightly differently for six months.&lt;/p&gt;

&lt;p&gt;UTM parameters have no schema. Nothing validates them. Your analytics tool takes whatever string you put in &lt;code&gt;utm_source&lt;/code&gt; and treats it as gospel, which means the discipline has to live in you, not the tool. Here is a convention that takes ten minutes to adopt and saves you from a report you cannot trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five parameters, and what each is actually for
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;utm_source&lt;/code&gt;: &lt;strong&gt;where the click came from&lt;/strong&gt;. A single named place: &lt;code&gt;newsletter&lt;/code&gt;, &lt;code&gt;instagram&lt;/code&gt;, &lt;code&gt;partner-acme&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;utm_medium&lt;/code&gt;: &lt;strong&gt;what kind of channel it is&lt;/strong&gt;: &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;social&lt;/code&gt;, &lt;code&gt;cpc&lt;/code&gt;, &lt;code&gt;qr&lt;/code&gt;, &lt;code&gt;referral&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;utm_campaign&lt;/code&gt;: &lt;strong&gt;which initiative it belongs to&lt;/strong&gt;: &lt;code&gt;spring-launch&lt;/code&gt;, &lt;code&gt;black-friday-2026&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;utm_content&lt;/code&gt;: &lt;strong&gt;which specific link&lt;/strong&gt;, when one page has several: &lt;code&gt;header-cta&lt;/code&gt;, &lt;code&gt;footer-link&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;utm_term&lt;/code&gt;: paid keyword. Mostly vestigial unless you are running search ads.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The distinction people get wrong is source versus medium. Source is the &lt;em&gt;place&lt;/em&gt;, medium is the &lt;em&gt;type&lt;/em&gt;. Instagram is a source; social is a medium. Your newsletter is a source; email is a medium. If you find yourself putting &lt;code&gt;instagram&lt;/code&gt; in medium, the whole report will fragment later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 1: lowercase everything, always
&lt;/h2&gt;

&lt;p&gt;This is the single highest-value rule, so it goes first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;utm_source=Instagram
utm_source=instagram
utm_source=INSTAGRAM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most analytics tools treat those as &lt;strong&gt;three different sources&lt;/strong&gt;. You will not notice for a month. Then you will open a report, see Instagram split across three rows, and have no clean way to merge history.&lt;/p&gt;

&lt;p&gt;Lowercase everything. No exceptions, no judgment calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 2: hyphens, never spaces or underscores
&lt;/h2&gt;

&lt;p&gt;A space becomes &lt;code&gt;%20&lt;/code&gt; and looks broken in reports. Underscores are fine technically but mixing them with hyphens gives you the same fragmentation problem as casing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌ utm_campaign=Spring Launch
❌ utm_campaign=spring_launch
✅ utm_campaign=spring-launch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick hyphens. Write it down. Done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 3: date your campaigns
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;utm_campaign=black-friday&lt;/code&gt; seems fine until it is your third Black Friday and every year is stacked into one row.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✅ utm_campaign=black-friday-2026
✅ utm_campaign=2026-03-product-launch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Year-first works well if you want campaigns to sort chronologically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 4: never tag internal links
&lt;/h2&gt;

&lt;p&gt;This is the one that silently corrupts data.&lt;/p&gt;

&lt;p&gt;If you tag a link from your own homepage to your own pricing page, most analytics tools start a &lt;strong&gt;new session&lt;/strong&gt; at that click and overwrite the original referrer. The visitor came from a newsletter, clicked one internal link, and is now attributed to your own site.&lt;/p&gt;

&lt;p&gt;UTMs are for traffic arriving from outside. Internal navigation is what your normal page-path reporting is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 5: keep a canonical list
&lt;/h2&gt;

&lt;p&gt;Not a spreadsheet nobody opens. A short list in whatever doc your team actually reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;sources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="s"&gt;newsletter, instagram, tiktok, linkedin, youtube, partner-&amp;lt;name&amp;gt;&lt;/span&gt;
&lt;span class="na"&gt;mediums&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="s"&gt;email, social, cpc, qr, referral, print&lt;/span&gt;
&lt;span class="na"&gt;campaign&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;yyyy&amp;gt;-&amp;lt;mm&amp;gt;-&amp;lt;short-name&amp;gt;&lt;/span&gt;
&lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="s"&gt;header-cta, inline-link, footer-link, bio-link&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule is: &lt;strong&gt;if it is not on the list, it does not get used until it is added to the list.&lt;/strong&gt; That single sentence is what prevents drift, because the failure mode is never one bad decision, it is fifty small ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 6: think about what the user sees
&lt;/h2&gt;

&lt;p&gt;A URL like this in an Instagram bio is ugly, and it leaks your internal campaign naming to anyone who looks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://example.com/pricing?utm_source=instagram&amp;amp;utm_medium=social&amp;amp;utm_campaign=2026-03-launch&amp;amp;utm_content=bio-link
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where a short link earns its place. The tagged URL becomes the destination, and the thing you publish stays clean. You keep the attribution and the reader gets a link that fits.&lt;/p&gt;

&lt;p&gt;It also means the tagging lives in one editable place. Get a UTM wrong on a printed QR code and you are reprinting; get it wrong behind a short link and you fix the destination.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule 7: QR codes need &lt;code&gt;utm_medium=qr&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Scans from print are not "direct" traffic, but that is where they land if you do not tag them. Tag the URL behind the code, and if the same campaign runs on a poster and a flyer, separate them with &lt;code&gt;utm_content&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;utm_source=poster&amp;amp;utm_medium=qr&amp;amp;utm_campaign=2026-03-launch&amp;amp;utm_content=cafe-window
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now "did the flyers do anything" is a question with an answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  A worked example
&lt;/h2&gt;

&lt;p&gt;Spring launch, promoted through the newsletter, Instagram bio, and a printed flyer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# newsletter, main button
?utm_source=newsletter&amp;amp;utm_medium=email&amp;amp;utm_campaign=2026-03-spring-launch&amp;amp;utm_content=header-cta

# instagram bio link
?utm_source=instagram&amp;amp;utm_medium=social&amp;amp;utm_campaign=2026-03-spring-launch&amp;amp;utm_content=bio-link

# flyer QR code
?utm_source=flyer&amp;amp;utm_medium=qr&amp;amp;utm_campaign=2026-03-spring-launch&amp;amp;utm_content=cafe-window
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same campaign across all three, so they roll up. Different sources and mediums, so they break down. That is the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that catches drift
&lt;/h2&gt;

&lt;p&gt;Once a month, list your distinct &lt;code&gt;utm_source&lt;/code&gt; values. If you see &lt;code&gt;instagram&lt;/code&gt; and &lt;code&gt;Instagram&lt;/code&gt;, or &lt;code&gt;ig&lt;/code&gt; and &lt;code&gt;insta&lt;/code&gt;, drift has started. Fix the convention now, because merging historical rows is unpleasant and sometimes impossible.&lt;/p&gt;




&lt;p&gt;I build &lt;a href="https://swyfty.live" rel="noopener noreferrer"&gt;Swyfty&lt;/a&gt;, a short-link and analytics tool, which is why I have opinions about this. But none of the above needs my product or any product. It needs a list of allowed values and the discipline to stick to it.&lt;/p&gt;

&lt;p&gt;The tooling is never what makes attribution work. Consistency is. A boring convention applied every time beats a sophisticated analytics stack fed inconsistent strings, every single time.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>marketing</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Bitly alternatives: the limit that bites is data retention, not links</title>
      <dc:creator>Michael Yousrie</dc:creator>
      <pubDate>Fri, 31 Jul 2026 10:42:03 +0000</pubDate>
      <link>https://dev.to/michael-yousrie/bitly-alternatives-the-limit-that-bites-is-data-retention-not-links-54b</link>
      <guid>https://dev.to/michael-yousrie/bitly-alternatives-the-limit-that-bites-is-data-retention-not-links-54b</guid>
      <description>&lt;p&gt;If you are comparing link shorteners, you have probably lined them up by price per month and by how many links each tier allows. That is the comparison every pricing page invites you to make, and it hides the limit that actually bites.&lt;/p&gt;

&lt;p&gt;For click analytics, the limit that matters is &lt;strong&gt;how long your data survives&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Bitly actually meters
&lt;/h2&gt;

&lt;p&gt;Bitly's published pricing, as of July 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Links/month&lt;/th&gt;
&lt;th&gt;Click data history&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;(not offered)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Core&lt;/td&gt;
&lt;td&gt;$10/mo, annual only ($120/yr)&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30 days&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Growth&lt;/td&gt;
&lt;td&gt;$29/mo annual, or $35/mo&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4 months&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Premium&lt;/td&gt;
&lt;td&gt;$199/mo annual, or $300/mo&lt;/td&gt;
&lt;td&gt;3,000&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1 year&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;custom&lt;/td&gt;
&lt;td&gt;custom&lt;/td&gt;
&lt;td&gt;2 years&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Clicks themselves are unlimited on every tier. That sounds generous, and it is the number everyone quotes. But look at the right-hand column.&lt;/p&gt;

&lt;p&gt;On Core, your click history is &lt;strong&gt;30 days&lt;/strong&gt;. Run a campaign in March, come back in May to see how it did, and there is nothing to see. The clicks happened. The record of them expired.&lt;/p&gt;

&lt;p&gt;The second thing worth noticing: &lt;strong&gt;city-level and device tracking sit on Premium&lt;/strong&gt;. That is $199/month billed annually, or $300 month to month. If "I just want to know where my clicks come from and what they are opening it on" is your entire requirement, Bitly's answer is its top self-serve tier.&lt;/p&gt;

&lt;p&gt;None of this makes Bitly a bad product. It is a mature platform with a real enterprise business, and the pricing is shaped for that buyer. But if you are one person with some links and a question about them, you are shopping in the wrong aisle.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question to ask every shortener
&lt;/h2&gt;

&lt;p&gt;Before you compare prices, ask this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"In six months, can I still see what happened to a link I made today?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It reframes the whole evaluation. A $10/month plan that forgets everything after 30 days is not cheaper than a $7/month plan that does not. It is a different product that happens to have a similar-looking price.&lt;/p&gt;

&lt;p&gt;Then two follow-ups:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Is the granularity I need on the tier I can afford?"&lt;/strong&gt; Country-level is common on cheap plans. City and device often are not. Decide which you actually need before you pay for a tier that gives you neither.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"What happens to my links if I stop paying?"&lt;/strong&gt; This is the one people find out about late. A short link is a promise printed on things you no longer control: business cards, packaging, an old tweet, someone else's blog post. If those links go dead or start showing a parked page when you downgrade, you have handed your reputation to a billing relationship. Ask before you print.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist for comparing link tools
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Data retention window.&lt;/strong&gt; The single most under-compared spec. Get the number in months.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Granularity per tier.&lt;/strong&gt; Country, city, device, browser, referrer, unique versus repeat. Which of those are on the plan you would actually buy?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unique versus total clicks.&lt;/strong&gt; If a tool only reports total clicks, one enthusiastic person and a preview bot can look like a successful campaign. You want both numbers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What happens on downgrade or cancellation.&lt;/strong&gt; Do existing links keep resolving?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Link creation limits.&lt;/strong&gt; Monthly caps are common. If you generate links programmatically, a 100/month cap disappears fast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom domain cost.&lt;/strong&gt; Often the real jump between tiers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is there an API on the plan you can afford?&lt;/strong&gt; If links need to come out of your own tooling rather than a dashboard, check this early. It is frequently gated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export.&lt;/strong&gt; Can you get a CSV out? If the answer is no, the data was never really yours.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That list is the useful part of this post. Run it against any tool, including mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Swyfty fits, and where it does not
&lt;/h2&gt;

&lt;p&gt;Full disclosure: I build &lt;a href="https://swyfty.live" rel="noopener noreferrer"&gt;Swyfty&lt;/a&gt;, so treat this section as the biased part and check it yourself.&lt;/p&gt;

&lt;p&gt;It exists because of the gap above. Short links, click analytics, QR codes and a link-in-bio page behind one login, with a REST API. Analytics include country and city, device, browser, referrer, and unique versus repeat visitors, and they are on the $7/month plan rather than a $199 one. There is CSV export, so the data leaves with you. Free covers 3 links, 500 visits each, and one bio page, with a short interstitial before each redirect; Pro is $7/month or $60/year and removes the interstitial, adds custom slugs, QR codes and hourly checks that your destination URLs are still alive.&lt;/p&gt;

&lt;p&gt;Now the honest part, because a comparison post that only lists strengths is an ad. Swyfty does &lt;strong&gt;not&lt;/strong&gt; have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Team accounts or multiple seats&lt;/li&gt;
&lt;li&gt;White-label custom domains&lt;/li&gt;
&lt;li&gt;Retargeting pixels&lt;/li&gt;
&lt;li&gt;A/B testing of links&lt;/li&gt;
&lt;li&gt;Scheduled link expiry&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you need branded custom domains at scale, or several people working in the same account with permissions, &lt;strong&gt;Bitly or Rebrandly is genuinely the better buy&lt;/strong&gt; and I would rather you went there than churned out of mine in a month. If all you want is a bio page and nothing else, Linktree has a much larger template ecosystem.&lt;/p&gt;

&lt;p&gt;It is also early. There is no traction to point at, no testimonials, and I am not going to invent any. What I can tell you is what the product does and what it costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision rule
&lt;/h2&gt;

&lt;p&gt;Are you buying link infrastructure for a team, or answers about your own links?&lt;/p&gt;

&lt;p&gt;If it is a team, buy the platform. Seats, permissions, branded domains and long retention are what those prices are for, and they are worth it at that size.&lt;/p&gt;

&lt;p&gt;If it is answers, then retention and granularity are the only specs that matter, and you should not have to reach an enterprise tier to keep six months of your own click history.&lt;/p&gt;

&lt;p&gt;Either way: compare the retention column first. It is the one nobody puts in the comparison table, and it is the one you notice in six months when you go looking for a number that is no longer there.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>analytics</category>
      <category>marketing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Looking for a Beamer alternative? Check the pricing model before the feature list</title>
      <dc:creator>Michael Yousrie</dc:creator>
      <pubDate>Thu, 30 Jul 2026 18:24:01 +0000</pubDate>
      <link>https://dev.to/michael-yousrie/looking-for-a-beamer-alternative-check-the-pricing-model-before-the-feature-list-d2n</link>
      <guid>https://dev.to/michael-yousrie/looking-for-a-beamer-alternative-check-the-pricing-model-before-the-feature-list-d2n</guid>
      <description>&lt;p&gt;If you are shopping for a changelog tool, you have probably noticed that the feature lists all converge. Everyone has a widget, a hosted page, some analytics, and a screenshot of a tidy "what's new" panel. Feature tables are where these tools look identical.&lt;/p&gt;

&lt;p&gt;Pricing models are where they stop looking identical, and that is the part that decides what you pay in a year.&lt;/p&gt;

&lt;p&gt;Here is what to actually check, using Beamer as the reference point since it is the tool most people compare against first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Beamer costs
&lt;/h2&gt;

&lt;p&gt;Beamer's published pricing, as of July 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Monthly&lt;/th&gt;
&lt;th&gt;Annual&lt;/th&gt;
&lt;th&gt;MAU cap&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;1,000 (1 teammate)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Starter&lt;/td&gt;
&lt;td&gt;$59&lt;/td&gt;
&lt;td&gt;$49/mo&lt;/td&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;td&gt;$119&lt;/td&gt;
&lt;td&gt;$99/mo&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scale&lt;/td&gt;
&lt;td&gt;$299&lt;/td&gt;
&lt;td&gt;$249/mo&lt;/td&gt;
&lt;td&gt;50,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Extra users run $50/month per additional 5,000 MAU. The Feedback module (roadmap, idea voting) and the NPS module are separate add-ons at $99/month each.&lt;/p&gt;

&lt;p&gt;None of that is a criticism. Beamer is a capable product engagement platform, and if you want announcements, segmentation, surveys, and a feedback board in one dashboard, the price is reasonable for what it does. The problem is that a lot of people buying it only ever wanted the changelog.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pricing axis matters more than the feature list
&lt;/h2&gt;

&lt;p&gt;Notice what Beamer bills on: &lt;strong&gt;monthly active users&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Think about what that means over time. Your changelog workload is basically constant. You ship something, you write a paragraph about it, you publish. That work is the same whether 500 people use your app or 50,000.&lt;/p&gt;

&lt;p&gt;Your bill is not constant. It scales with your app's success. Cross 10,000 MAU and you are on a $119/month tier for the same paragraph you were writing at $0. Cross 50,000 and it is $299. You did not use more of the tool. You just grew, and the tool is priced to capture that growth.&lt;/p&gt;

&lt;p&gt;This is the single biggest thing to check when comparing changelog tools, and it is the thing feature tables never show you. Ask one question of every tool on your list:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If my traffic goes up 10x and my publishing habits do not change at all, what happens to this bill?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the answer is "it goes up a lot," you are not buying a changelog. You are buying a percentage of your own growth.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist for comparing changelog tools
&lt;/h2&gt;

&lt;p&gt;Beyond the pricing axis, these are worth checking before you commit, roughly in order of how often they bite people later:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is the widget CSS-isolated?&lt;/strong&gt; If it renders into your page's normal DOM, it inherits your global styles and your resets fight its layout. Shadow DOM isolation is the fix. Ask, or open devtools on their demo and look for a &lt;code&gt;#shadow-root&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the widget fail silently?&lt;/strong&gt; A marketing widget must never break the host app. If their CDN has a bad day, your product should not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What happens when you stop paying?&lt;/strong&gt; Does the hosted page stay up, redirect, or 404? Your changelog URLs may be linked from release emails and docs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can you get your content out?&lt;/strong&gt; Look for an export or an RSS feed. Release notes are writing you own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the free tier carry a badge?&lt;/strong&gt; Many do, including mine. That is a fair trade for free, but you should know before you embed it, not after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How much of the product is add-ons?&lt;/strong&gt; A cheap base tier plus two mandatory $99 modules is not a cheap tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do you actually need segmentation?&lt;/strong&gt; It is the feature most often used to justify the jump to a higher tier, and the one solo products least often need. If everyone sees the same feed, do not pay for targeting.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That list is the whole point of this post. Run it against any tool, including the one I build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Patchlog fits, and where it does not
&lt;/h2&gt;

&lt;p&gt;Full disclosure: I build &lt;a href="https://patchlog.io" rel="noopener noreferrer"&gt;Patchlog&lt;/a&gt;, so treat this section as the biased part and check it yourself.&lt;/p&gt;

&lt;p&gt;It exists because of the pricing axis above. It is a drop-in changelog widget plus a hosted page, priced flat at $7/month (or $5/month billed yearly) rather than per monthly active user. Traffic growth does not change the bill. The free tier gives you one project and 25 updates, and it shows a small "Powered by Patchlog" badge, which is how the free tier pays for itself.&lt;/p&gt;

&lt;p&gt;The install is one tag, no SDK and no npm install:&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;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://cdn.patchlog.io/widget.js"&lt;/span&gt;
        &lt;span class="na"&gt;data-project=&lt;/span&gt;&lt;span class="s"&gt;"your-project-id"&lt;/span&gt;
        &lt;span class="na"&gt;data-position=&lt;/span&gt;&lt;span class="s"&gt;"bottom-right"&lt;/span&gt;
        &lt;span class="na"&gt;async&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It renders in a Shadow DOM, so it does not collide with your CSS, and it works the same in React, Vue, Rails, or plain HTML.&lt;/p&gt;

&lt;p&gt;Now the honest part, because a comparison post that only lists strengths is an ad. Patchlog does &lt;strong&gt;not&lt;/strong&gt; have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;User segmentation or targeting. Everyone sees the same feed.&lt;/li&gt;
&lt;li&gt;NPS surveys, roadmaps, or feedback boards.&lt;/li&gt;
&lt;li&gt;Email notifications or digests.&lt;/li&gt;
&lt;li&gt;A public API.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are real gaps, not roadmap teases. If you need to announce a feature to enterprise accounts only, run NPS surveys, or collect and vote on feature requests, Patchlog is the wrong tool and Beamer or Canny is genuinely the better buy. Pay them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision rule
&lt;/h2&gt;

&lt;p&gt;It comes down to one question: are you buying a changelog, or a product engagement platform?&lt;/p&gt;

&lt;p&gt;If you need segmentation, surveys, feedback boards, and a team dashboard, buy the platform. Those tools are priced the way they are because they do a lot, and the MAU model is the standard way that category charges.&lt;/p&gt;

&lt;p&gt;If what you actually want is for your users to notice you shipped something, you want a widget, a hosted page, and a flat bill. Anything that meters your changelog against your own growth is charging you for the wrong variable.&lt;/p&gt;

&lt;p&gt;Either way, check the pricing axis before the feature table. The feature table is where these tools look the same. The pricing axis is where they differ, and it is the one you live with.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>changelog</category>
      <category>startup</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Do people actually read your changelog? Here is how to measure it</title>
      <dc:creator>Michael Yousrie</dc:creator>
      <pubDate>Thu, 30 Jul 2026 18:13:11 +0000</pubDate>
      <link>https://dev.to/michael-yousrie/do-people-actually-read-your-changelog-here-is-how-to-measure-it-22ni</link>
      <guid>https://dev.to/michael-yousrie/do-people-actually-read-your-changelog-here-is-how-to-measure-it-22ni</guid>
      <description>&lt;p&gt;You ship updates. Maybe a changelog widget, maybe a "what's new" page, maybe just a post that says "we shipped X." And then you have no idea if a single person read it.&lt;/p&gt;

&lt;p&gt;That is the uncomfortable part of shipping in public. You put the work in, you announce it, and the announcement disappears into the void. Views are vanity. What you actually want to know is whether anyone cared enough to click through and look.&lt;/p&gt;

&lt;p&gt;You can measure that. Here is the setup I use.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with raw view counts
&lt;/h2&gt;

&lt;p&gt;Most changelog tools and update feeds will happily tell you how many times a page was loaded. That number lies to you in two directions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bots, prefetchers, and your own reloads inflate it.&lt;/li&gt;
&lt;li&gt;A "view" tells you a page rendered, not that a human read a word of it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The signal you actually want is intent. Someone read an update, and it mattered enough that they took the next step and clicked the link in it. That click is worth a hundred passive views, because a human chose to make it happen.&lt;/p&gt;

&lt;p&gt;So the trick is simple. Make every link you put in an update a measured link.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap your update links in tracked short links
&lt;/h2&gt;

&lt;p&gt;Whenever an update contains a link (to the new feature, to a doc, to a demo), do not paste the raw URL. Run it through a short-link tool that records analytics on the redirect. Now every time someone follows that link, you capture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Referrer&lt;/strong&gt;, so you know whether the click came from the in-app widget, the hosted page, an email, or somewhere you did not expect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Device and browser&lt;/strong&gt;, so you know if the feature you built for developers is actually being opened on phones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Country&lt;/strong&gt;, which feeds timing and localization decisions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unique vs repeat&lt;/strong&gt;, so you can tell ten curious people apart from one person clicking ten times.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that requires a heavy analytics stack or a cookie banner. It is just a redirect that counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the data (this is the part that changes what you do)
&lt;/h2&gt;

&lt;p&gt;A tracked link turns a vague feeling into a decision. A few patterns that show up in real data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Clicks cluster on the hosted page, not the in-app widget.&lt;/strong&gt; Your in-app placement is too quiet. Move it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High clicks, but all repeat visitors.&lt;/strong&gt; Your existing users care, but the update is not reaching anyone new. That is a distribution problem, not a product one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A feature you were proud of gets almost no clicks.&lt;/strong&gt; Before you assume nobody wants it, check whether the announcement itself was clear. Often the feature is fine and the release note was the noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One channel massively outperforms the rest.&lt;/strong&gt; Now you know where to spend your next announcement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point is not the dashboard. The point is that "did anyone read it" stops being a shrug and becomes something you can answer and act on.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I wire this up
&lt;/h2&gt;

&lt;p&gt;Full disclosure, both tools here are mine, which is exactly why I built this loop for myself.&lt;/p&gt;

&lt;p&gt;I keep my product's release notes in a changelog widget. I build &lt;a href="https://patchlog.io" rel="noopener noreferrer"&gt;Patchlog&lt;/a&gt;, a drop-in changelog widget, so that part is easy for me. Every link that goes into an update, I route through a short link with analytics. I also build &lt;a href="https://swyfty.live" rel="noopener noreferrer"&gt;Swyfty&lt;/a&gt;, so I use that. The result is that when I ship something, I am not guessing whether it landed. I can watch the clicks come in, see where they came from, and see whether it was the same five people or someone genuinely new.&lt;/p&gt;

&lt;p&gt;You do not need my tools to do this. Any changelog surface plus any short-link tool that exposes referrer and device data will get you there. The habit is the valuable part. Never ship an update with a naked link in it, and read the clicks a week later like a scoreboard.&lt;/p&gt;

&lt;p&gt;Shipping is only half the job. Knowing whether the shipping was heard is the other half, and it turns out to be cheap to measure once you set it up.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>analytics</category>
      <category>webdev</category>
      <category>startup</category>
    </item>
    <item>
      <title>How to write release notes people actually read</title>
      <dc:creator>Michael Yousrie</dc:creator>
      <pubDate>Wed, 15 Jul 2026 04:05:15 +0000</pubDate>
      <link>https://dev.to/michael-yousrie/how-to-write-release-notes-people-actually-read-28pn</link>
      <guid>https://dev.to/michael-yousrie/how-to-write-release-notes-people-actually-read-28pn</guid>
      <description>&lt;p&gt;Most release notes are written for the person who wrote them. That is why nobody reads them.&lt;/p&gt;

&lt;p&gt;You finish a sprint, you scroll your merged PRs, and you turn them into bullet points. The result is technically accurate and completely useless to the person reading it, because it answers "what did we do" when the reader is asking "does any of this affect me?"&lt;/p&gt;

&lt;p&gt;I build Patchlog, a changelog widget, which means I end up reading a lot of other people's release notes. The good ones are rare and they all do roughly the same things. Here is what separates them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lead with what the user can now do
&lt;/h2&gt;

&lt;p&gt;The most common failure is describing the change instead of the outcome.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Refactored the export pipeline to use a queued job.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Nobody outside your team can act on that. What actually changed for them?&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Exports no longer time out on large accounts. Big exports now run in the background and email you when they're done.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Same commit. The second version tells a user who gave up on exports three months ago that it is worth trying again. That is the entire job of a release note.&lt;/p&gt;

&lt;p&gt;A quick test: read your entry and ask "so what?" If the answer is not in the text, rewrite it.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Fixed a bug" is not a release note
&lt;/h2&gt;

&lt;p&gt;Vague fix entries are worse than no entry, because they take up space and train people to skim past your changelog.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Fixed a bug in bulk edit.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Compare:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Bulk edit no longer drops your selection when you paginate.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The specific version does something the vague one cannot: it reaches the exact person who hit that bug, and tells them it is safe to come back. That person is often one bad experience away from churning. Name the broken thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Group by impact, not by ticket
&lt;/h2&gt;

&lt;p&gt;Your issue tracker is not an outline. Users do not care that PROJ-412 and PROJ-419 were separate tickets, or that one was a bug and one was a chore. They care that search got faster.&lt;/p&gt;

&lt;p&gt;Group related work into one entry that describes the user-visible result, even if it spanned six PRs. Split one PR into two entries if it changed two unrelated things. The unit of a changelog is the change a person notices, not the unit of work you tracked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cut ruthlessly, then lead with the one that matters
&lt;/h2&gt;

&lt;p&gt;Nobody reads fourteen bullet points. The instinct is to list everything, because you did all of it and it all felt significant while you were doing it. But readers scan for the single line that affects them, and every dependency bump you include makes that line harder to find.&lt;/p&gt;

&lt;p&gt;Two rules that help:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If a change is invisible to users, it does not need an entry. Your internal refactor is real work, but a changelog is not a performance review.&lt;/li&gt;
&lt;li&gt;Put the one thing most people will care about first. Not chronological, not grouped by type. Most important, first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Signal beats completeness. A three-line changelog people read beats a twenty-line one they scroll past.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use their words, not your internal names
&lt;/h2&gt;

&lt;p&gt;Every team accumulates internal vocabulary. The feature is called "Workspaces" in the UI but everyone on the team still calls it "orgs" because that was the table name. Then it hits the changelog as "orgs" and readers have no idea what shipped.&lt;/p&gt;

&lt;p&gt;Write the noun the user sees in the interface. If you renamed something, say both names once so people can bridge it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Say why when it is not obvious
&lt;/h2&gt;

&lt;p&gt;Some changes look like downgrades until you explain them. Lower limits, a removed option, a changed default.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Changed the default session length to 7 days.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Reads as arbitrary, maybe hostile. Add one clause:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Sessions now last 7 days instead of 30, so a forgotten open tab on a shared computer stops being a security problem. You can raise it in Settings.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You do not owe an essay. One clause of reasoning turns a complaint into a shrug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick a cadence and let the small stuff batch
&lt;/h2&gt;

&lt;p&gt;Two failure modes. Publish every merge and your changelog becomes a commit log nobody follows. Publish twice a year and each post is an unreadable wall of everything.&lt;/p&gt;

&lt;p&gt;What works for most small products: batch the small fixes into a regular post on a predictable rhythm, and give a genuinely big feature its own entry when it lands. Predictability matters more than frequency. A changelog that updates every other Friday reads as a product with a pulse. One that goes quiet for five months reads as abandoned, even when you are shipping the whole time.&lt;/p&gt;

&lt;p&gt;If writing them up is what keeps slipping, write several ahead and schedule them out. Drafting is the part that fails, not publishing.&lt;/p&gt;

&lt;h2&gt;
  
  
  A before and after
&lt;/h2&gt;

&lt;p&gt;Before:&lt;/p&gt;

&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Fixed a bug&lt;/li&gt;
&lt;li&gt;Updated dependencies&lt;/li&gt;
&lt;li&gt;Refactored the importer for performance&lt;/li&gt;
&lt;li&gt;Added new field to API response&lt;/li&gt;
&lt;li&gt;Misc improvements&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

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

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;CSV imports are about 4x faster.&lt;/strong&gt; A 50,000 row file that used to take two minutes now finishes in under thirty seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Imports no longer fail silently on a bad row.&lt;/strong&gt; You get a list of the rows that failed and why, instead of a generic error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;/orders&lt;/code&gt; API response now includes &lt;code&gt;fulfilled_at&lt;/code&gt;.&lt;/strong&gt; Existing fields are unchanged.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Shorter in spirit, longer on the page, and every line answers "so what?"&lt;/p&gt;

&lt;h2&gt;
  
  
  Where they go matters as much as how they read
&lt;/h2&gt;

&lt;p&gt;Worth saying plainly: the best-written release notes still fail if they live somewhere nobody visits. A Notion doc, a &lt;code&gt;#releases&lt;/code&gt; Slack channel, a &lt;code&gt;/changelog&lt;/code&gt; route linked from the footer. All are places users go zero times a month.&lt;/p&gt;

&lt;p&gt;That gap is the reason Patchlog exists, so treat this paragraph as the biased one. But the underlying point holds regardless of what you use, including a plain page you build yourself: put the notes where people already are, in the product, rather than hoping they come looking.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Lead with the outcome, not the change.&lt;/li&gt;
&lt;li&gt;Be specific about fixes. Name what was broken.&lt;/li&gt;
&lt;li&gt;Group by what the user notices, not by ticket.&lt;/li&gt;
&lt;li&gt;Cut anything invisible, and put the important line first.&lt;/li&gt;
&lt;li&gt;Use the words in your UI.&lt;/li&gt;
&lt;li&gt;One clause of "why" for anything that looks like a downgrade.&lt;/li&gt;
&lt;li&gt;Predictable cadence beats frequent or exhaustive.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this takes longer than what you are doing now. It mostly takes writing the entry after you have stopped thinking like the person who wrote the code.&lt;/p&gt;

</description>
      <category>writing</category>
      <category>saas</category>
      <category>startup</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I built a PHP microframework where the method name is the route</title>
      <dc:creator>Michael Yousrie</dc:creator>
      <pubDate>Sun, 12 Jul 2026 19:26:25 +0000</pubDate>
      <link>https://dev.to/michael-yousrie/i-built-a-php-microframework-where-the-method-name-is-the-route-126k</link>
      <guid>https://dev.to/michael-yousrie/i-built-a-php-microframework-where-the-method-name-is-the-route-126k</guid>
      <description>&lt;p&gt;I kept starting the same kind of small PHP project: a quick JSON API, a webhook receiver, a few endpoints behind a static site. Every time, Laravel or Symfony felt like a lot of scaffolding for something that only needed a handful of handlers. So I built a microframework for exactly that middle ground, and I have been building it in public with Claude Code on YouTube.&lt;/p&gt;

&lt;p&gt;It is called Batframe, and the whole idea fits in one sentence: your method name is the route.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extend one class, get endpoints
&lt;/h2&gt;

&lt;p&gt;You extend a base class and write public methods. Each verb-prefixed method becomes an HTTP endpoint whose route is inferred from its name. There is no route file to author and nothing to register.&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="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Batframe\Batframe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Batframe\Http\Request&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Batframe\Http\Response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;App&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Batframe&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                     &lt;span class="c1"&gt;// GET /&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;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'home'&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;'World'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getUsers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                  &lt;span class="c1"&gt;// GET /users&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;'users'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'ada'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'linus'&lt;/span&gt;&lt;span class="p"&gt;]];&lt;/span&gt;   &lt;span class="c1"&gt;// arrays become JSON automatically&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;// GET /user/{id}&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;$id&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;postUsers&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="c1"&gt;// POST /users&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="o"&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;'created'&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;input&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="mi"&gt;201&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;App&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;run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a working app. No config, no route table, no annotations.&lt;/p&gt;

&lt;h2&gt;
  
  
  The routing convention
&lt;/h2&gt;

&lt;p&gt;The route comes entirely from the method name and its typed parameters.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Route&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;index()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET /&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;getUsers()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET /users&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;getUser(int $id)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET /user/{id}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;postUsers()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;POST /users&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;putUser(int $id)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PUT /user/{id}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;deleteUser(int $id)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DELETE /user/{id}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rules are small enough to keep in your head:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The method must start with an HTTP verb (&lt;code&gt;get&lt;/code&gt;, &lt;code&gt;post&lt;/code&gt;, &lt;code&gt;put&lt;/code&gt;, &lt;code&gt;patch&lt;/code&gt;, &lt;code&gt;delete&lt;/code&gt;, &lt;code&gt;head&lt;/code&gt;, &lt;code&gt;options&lt;/code&gt;). That sets the HTTP method.&lt;/li&gt;
&lt;li&gt;The remaining PascalCase words become lowercased, slash-separated path segments. There is no auto-pluralization, so the path is literal: &lt;code&gt;getUser()&lt;/code&gt; is &lt;code&gt;/user&lt;/code&gt;, &lt;code&gt;getUsers()&lt;/code&gt; is &lt;code&gt;/users&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Typed scalar params (&lt;code&gt;int&lt;/code&gt;, &lt;code&gt;string&lt;/code&gt;, &lt;code&gt;float&lt;/code&gt;, &lt;code&gt;bool&lt;/code&gt;) become &lt;code&gt;{placeholder}&lt;/code&gt; segments in order. &lt;code&gt;int&lt;/code&gt; and &lt;code&gt;float&lt;/code&gt; also constrain the match, so &lt;code&gt;/user/abc&lt;/code&gt; will not hit &lt;code&gt;getUser(int $id)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;Request&lt;/code&gt;-typed param is injected and can sit in any position.&lt;/li&gt;
&lt;li&gt;A public method that does not start with a verb is treated as an internal helper and is not routed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the app class starts to fill up, related endpoints can move into traits. A verb-prefixed method composed in from a trait registers exactly like an inline one, so you can group routes by feature and keep the app class tiny.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ergonomics without a framework tax
&lt;/h2&gt;

&lt;p&gt;Return values are turned into responses for you: an array or scalar becomes JSON, a string becomes HTML, &lt;code&gt;null&lt;/code&gt; becomes a 204, and a &lt;code&gt;Response&lt;/code&gt; is sent as-is. When you want control, the &lt;code&gt;Response&lt;/code&gt; helpers are fluent:&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;Response&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$data&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;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'X-Trace'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'abc'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Requests read the way you would expect, with &lt;code&gt;input()&lt;/code&gt; looking through the JSON body, then the form body, then the query string:&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;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;input&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="nv"&gt;$default&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;bearerToken&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;wantsJson&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is also a small file-based session helper and a file-based cache with per-item TTL, both of which start lazily and need no setup. Views run on BladeOne, so you get familiar Blade syntax without pulling in the rest of a framework. Drop a Blade or HTML file in the &lt;code&gt;pages/&lt;/code&gt; directory and it is served automatically, which makes small static sites trivial.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is not
&lt;/h2&gt;

&lt;p&gt;Being honest about scope matters more than a feature list. Batframe intentionally leaves out a middleware pipeline, a DI container, CLI scaffolding, a PSR-7 bridge, and an attribute-based route layer. Those are on the shelf, not shipped, and the routing resolver is kept as a clean seam so an attribute layer could be added later without touching dispatch. If your project needs those things today, reach for Laravel or Symfony. Batframe is for the project that does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Built in public with Claude Code
&lt;/h2&gt;

&lt;p&gt;The reason I am sharing it now: I am building Batframe out in the open, one feature per episode, pairing with Claude Code on camera. The sessions and caching helpers, for example, got their own episode. If you want to see how the framework is designed and tested rather than just read the finished code, the series is here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.youtube.com/playlist?list=PLSohpKg3v4pE" rel="noopener noreferrer"&gt;https://www.youtube.com/playlist?list=PLSohpKg3v4pE&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The fastest way in is the skeleton, which scaffolds a ready-to-run app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer create-project michaelyousrie/batframe-skeleton my-app
&lt;span class="nb"&gt;cd &lt;/span&gt;my-app
composer serve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or add it to an existing project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require michaelyousrie/batframe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PHP 8.1+, MIT licensed. Code and docs are on GitHub: &lt;a href="https://github.com/michaelyousrie/batframe" rel="noopener noreferrer"&gt;https://github.com/michaelyousrie/batframe&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you build something small with it, or you have opinions about the routing convention, I would genuinely like to hear them.&lt;/p&gt;

</description>
      <category>php</category>
      <category>opensource</category>
      <category>webdev</category>
      <category>framework</category>
    </item>
    <item>
      <title>How to embed a widget on any site without CSS collisions (Shadow DOM)</title>
      <dc:creator>Michael Yousrie</dc:creator>
      <pubDate>Sat, 11 Jul 2026 17:56:38 +0000</pubDate>
      <link>https://dev.to/michael-yousrie/how-to-embed-a-widget-on-any-site-without-css-collisions-shadow-dom-3ca6</link>
      <guid>https://dev.to/michael-yousrie/how-to-embed-a-widget-on-any-site-without-css-collisions-shadow-dom-3ca6</guid>
      <description>&lt;p&gt;If you have ever dropped a third-party widget onto a page and watched it either inherit the host site's CSS or quietly break the host's own layout, you have run into the oldest problem in embeddable widgets: the DOM is shared, so styles leak in both directions.&lt;/p&gt;

&lt;p&gt;The host page has a global &lt;code&gt;a { color: red }&lt;/code&gt; and suddenly every link in your widget is red. Your widget ships a &lt;code&gt;.button&lt;/code&gt; class and now the host's own buttons look wrong. Iframes solve isolation but bring their own pain: fixed sizing, awkward communication, and they feel heavy for something as small as a "what's new" panel.&lt;/p&gt;

&lt;p&gt;There is a better tool built into every modern browser: the Shadow DOM.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Shadow DOM actually does
&lt;/h2&gt;

&lt;p&gt;A shadow root is a separate DOM tree attached to a host element. Styles defined inside it do not leak out, and styles from the outer page do not leak in. It is the same mechanism the browser uses internally for elements like &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;input type="range"&amp;gt;&lt;/code&gt;, where you can see the control but not style its internals.&lt;/p&gt;

&lt;p&gt;Here is the whole idea in a few lines:&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;// 1. Put a host element on the page&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;host&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;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;div&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;changelog-widget&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nb"&gt;document&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="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Attach a shadow root to it&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;shadow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attachShadow&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;open&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// 3. Everything you render inside is isolated&lt;/span&gt;
&lt;span class="nx"&gt;shadow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;innerHTML&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
  &amp;lt;style&amp;gt;
    .panel {
      font: 14px/1.5 system-ui, sans-serif;
      color: #111;
      background: #fff;
      padding: 16px;
      border-radius: 8px;
    }
    a { color: #2563eb; }
  &amp;lt;/style&amp;gt;
  &amp;lt;div class="panel"&amp;gt;
    &amp;lt;strong&amp;gt;What's new&amp;lt;/strong&amp;gt;
    &amp;lt;p&amp;gt;We just shipped dark mode.&amp;lt;/p&amp;gt;
    &amp;lt;a href="#"&amp;gt;See all updates&amp;lt;/a&amp;gt;
  &amp;lt;/div&amp;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 &lt;code&gt;a { color: #2563eb }&lt;/code&gt; rule only applies inside the shadow root. The host page's global link color cannot reach it, and your &lt;code&gt;.panel&lt;/code&gt; class will never collide with a &lt;code&gt;.panel&lt;/code&gt; on the host site. No prefixing, no BEM gymnastics, no &lt;code&gt;!important&lt;/code&gt; wars.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boundary is not a wall for everything
&lt;/h2&gt;

&lt;p&gt;A few things cross the shadow boundary on purpose, and knowing which ones saves you hours:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CSS custom properties inherit through.&lt;/strong&gt; If the host page sets &lt;code&gt;--brand-color&lt;/code&gt;, you can read &lt;code&gt;var(--brand-color)&lt;/code&gt; inside the shadow root. This is the clean way to let a host theme your widget without exposing your internals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fonts loaded on the page are usable inside.&lt;/strong&gt; &lt;code&gt;@font-face&lt;/code&gt; and font files loaded by the host are available, so your widget can match the site if you want it to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Events retarget.&lt;/strong&gt; A click inside the shadow tree still bubbles to the host, but &lt;code&gt;event.target&lt;/code&gt; is rewritten to the host element so you do not leak your internal structure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;:host&lt;/code&gt; styles the host element itself&lt;/strong&gt;, which is handy for positioning the whole widget from inside.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:host&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;fixed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;24px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;24px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;z-index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2147483000&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;h2&gt;
  
  
  Open vs closed
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;attachShadow({ mode: 'open' })&lt;/code&gt; lets outside JavaScript reach the tree via &lt;code&gt;host.shadowRoot&lt;/code&gt;. &lt;code&gt;mode: 'closed'&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt; there. Closed feels safer but mostly just makes debugging harder for you without stopping a determined host page, so open is the pragmatic default for a widget.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters for embeddable products
&lt;/h2&gt;

&lt;p&gt;The reason this technique is worth reaching for is distribution. If your widget is fully isolated, your install instructions collapse to a single line:&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;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://cdn.example.com/widget.js"&lt;/span&gt; &lt;span class="na"&gt;async&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script creates its host element, attaches a shadow root, and renders. It works the same on a React app, a Rails site, a WordPress theme, or a hand-written HTML page, because it never touches the host's styles and the host never touches its own.&lt;/p&gt;

&lt;p&gt;This is exactly how I built the changelog widget for &lt;a href="https://patchlog.io" rel="noopener noreferrer"&gt;Patchlog&lt;/a&gt;. It is a drop-in "what's new" widget for SaaS products: one script tag, rendered inside a Shadow DOM so it never collides with the host site's CSS, with light, dark, and auto theming that reads the host's CSS variables when you want it to. There is a free tier if you want to see the technique in a shipped product rather than a blog snippet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Reach for the Shadow DOM when you need real style isolation without the weight of an iframe.&lt;/li&gt;
&lt;li&gt;Remember the intentional exceptions: CSS variables, fonts, events, and &lt;code&gt;:host&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;mode: 'open'&lt;/code&gt; unless you have a specific reason not to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Style isolation used to be the hardest part of shipping an embeddable widget. The platform quietly solved it years ago, and most of us just have not switched over yet.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>css</category>
      <category>webcomponents</category>
    </item>
    <item>
      <title>How to add a changelog to any web app with one script tag</title>
      <dc:creator>Michael Yousrie</dc:creator>
      <pubDate>Sat, 11 Jul 2026 00:05:39 +0000</pubDate>
      <link>https://dev.to/michael-yousrie/how-to-add-a-changelog-to-any-web-app-with-one-script-tag-43b5</link>
      <guid>https://dev.to/michael-yousrie/how-to-add-a-changelog-to-any-web-app-with-one-script-tag-43b5</guid>
      <description>&lt;p&gt;You ship all the time. A fix here, a new setting there, a feature you spent a whole weekend on. And your users mostly don't notice.&lt;/p&gt;

&lt;p&gt;That gap is expensive. When people can't &lt;em&gt;see&lt;/em&gt; a product moving, it feels abandoned, even when you're shipping every week. They churn a little faster, they email asking for things you built a month ago, and all the momentum you're actually creating stays invisible.&lt;/p&gt;

&lt;p&gt;The fix is boring and old: a changelog. But not a changelog rotting in a Notion doc nobody opens. One that shows up &lt;strong&gt;inside your app&lt;/strong&gt;, where users already are.&lt;/p&gt;

&lt;p&gt;Here's the approach I settled on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: a widget, not just a page
&lt;/h2&gt;

&lt;p&gt;A "what's new" widget is a small button or badge in your UI. Click it, and a panel slides out with your latest updates. Users see it in the flow of using your product, not on some &lt;code&gt;/changelog&lt;/code&gt; page they'd never visit.&lt;/p&gt;

&lt;p&gt;You really want three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An in-app widget users actually see.&lt;/li&gt;
&lt;li&gt;A public page and RSS feed you can link from emails and docs.&lt;/li&gt;
&lt;li&gt;A way to write updates in plain language and publish in a click.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The one-tag version
&lt;/h2&gt;

&lt;p&gt;I ended up building a tool for this (honest disclosure below), but the &lt;em&gt;integration&lt;/em&gt; is the part worth showing, because it's the pattern any changelog widget should follow:&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="c"&gt;&amp;lt;!-- Paste before &amp;lt;/body&amp;gt; --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://cdn.patchlog.io/widget.js"&lt;/span&gt;
        &lt;span class="na"&gt;data-project=&lt;/span&gt;&lt;span class="s"&gt;"your-project-id"&lt;/span&gt;
        &lt;span class="na"&gt;data-position=&lt;/span&gt;&lt;span class="s"&gt;"bottom-right"&lt;/span&gt;
        &lt;span class="na"&gt;async&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One script tag. No SDK, no npm install, no framework coupling. It behaves the same in React, Vue, Rails, or a plain HTML page.&lt;/p&gt;

&lt;p&gt;Two implementation details matter, whether you build one of these yourself or evaluate an existing one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Render it in a Shadow DOM.&lt;/strong&gt; A changelog widget should not inherit or leak styles. If it uses the host page's global CSS, it will look broken on half the sites it lands on. Shadow DOM isolates it completely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail silently.&lt;/strong&gt; A marketing widget must never break the host app. If the network call fails, it should quietly do nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to actually write in it
&lt;/h2&gt;

&lt;p&gt;The tool is the easy part. The discipline is writing updates people care about. A few things that have worked for me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tag each entry&lt;/strong&gt; as New, Fixed, or Improved. People scan, so give them anchors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write like a person.&lt;/strong&gt; "CSV exports were timing out on big accounts, now fixed" beats "Resolved asynchronous export latency regression."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship small and often.&lt;/strong&gt; Ten small updates read as momentum. One giant quarterly post reads as a company that went quiet for three months.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schedule them.&lt;/strong&gt; Write the note when you ship the code, set it to publish later, and your changelog stays alive even in a busy week.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest disclosure
&lt;/h2&gt;

&lt;p&gt;I build &lt;a href="https://patchlog.io" rel="noopener noreferrer"&gt;Patchlog&lt;/a&gt;, so the snippet above is my own tool. I'm not going to pretend otherwise. It's early: the free tier is one project and 25 updates, which is genuinely what I run on my own projects. I'm sharing the &lt;em&gt;approach&lt;/em&gt; because it's helped me, and if it saves you the afternoon it cost me to think through, great.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual point
&lt;/h2&gt;

&lt;p&gt;Whatever you use, whether you build it, use mine, or use a competitor, close the gap between &lt;em&gt;shipping&lt;/em&gt; and &lt;em&gt;your users noticing you shipped&lt;/em&gt;. That gap is quietly costing you retention, and it's one of the cheapest things in your entire product to fix.&lt;/p&gt;

&lt;p&gt;What do you use for changelogs today? Always curious how other people handle this.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>saas</category>
      <category>startup</category>
      <category>changelog</category>
    </item>
  </channel>
</rss>
