<?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>Forem: Amaury</title>
    <description>The latest articles on Forem by Amaury (@amaury_bouchard).</description>
    <link>https://forem.com/amaury_bouchard</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3816941%2Ffe470c85-c895-4480-bca6-fc1b8654d696.png</url>
      <title>Forem: Amaury</title>
      <link>https://forem.com/amaury_bouchard</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/amaury_bouchard"/>
    <language>en</language>
    <item>
      <title>Your PHP website is a free API: how to unlock it with content negotiation</title>
      <dc:creator>Amaury</dc:creator>
      <pubDate>Wed, 08 Apr 2026 11:50:09 +0000</pubDate>
      <link>https://forem.com/amaury_bouchard/your-php-website-is-a-free-api-how-to-unlock-it-with-content-negotiation-2h0p</link>
      <guid>https://forem.com/amaury_bouchard/your-php-website-is-a-free-api-how-to-unlock-it-with-content-negotiation-2h0p</guid>
      <description>&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;You have a product catalog on your website. Your marketing team wants a mobile app. Your CTO says "we need an API." Six months later, you're maintaining two codebases that do the same thing: fetch products from the database and return them to a client.&lt;/p&gt;

&lt;p&gt;There's a better way.&lt;/p&gt;




&lt;h2&gt;
  
  
  What is content negotiation?
&lt;/h2&gt;

&lt;p&gt;Content negotiation is a mechanism built into the HTTP specification since 1996. It allows a server to return different representations of the same resource from the same URL, based on what the client declares it can handle.&lt;/p&gt;

&lt;p&gt;The most common signal is the &lt;code&gt;Accept&lt;/code&gt; header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;# A browser sends:
Accept: text/html

# A mobile app or API client sends:
Accept: application/json
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same URL. Same controller. Same business logic. Different output.&lt;/p&gt;

&lt;p&gt;Other mechanisms exist and work on the same principle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;.json&lt;/code&gt; extension appended to the URL: &lt;code&gt;/products/42.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A query parameter: &lt;code&gt;/products/42?format=json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;X-Requested-With: XMLHttpRequest&lt;/code&gt; header, common with AJAX calls&lt;/li&gt;
&lt;li&gt;A dedicated URL prefix: &lt;code&gt;/api/products/42&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  A concrete scenario
&lt;/h2&gt;

&lt;p&gt;Your e-commerce platform lists products, handles filters, sorting, and pagination. Your mobile app needs exactly the same data, structured as JSON, to render its own product screens.&lt;/p&gt;

&lt;p&gt;Instead of building a parallel API, you teach your existing endpoints to respond differently based on who is asking. The browser gets an HTML page with navigation, images, and layout. The mobile app gets a clean JSON payload with the same product data.&lt;/p&gt;




&lt;h2&gt;
  
  
  Implementation in three frameworks
&lt;/h2&gt;

&lt;p&gt;The three examples below implement the same product catalog endpoint. &lt;a href="https://symfony.com/" rel="noopener noreferrer"&gt;Symfony&lt;/a&gt; is a mature, enterprise-grade PHP framework built around reusable components. &lt;a href="https://laravel.com/" rel="noopener noreferrer"&gt;Laravel&lt;/a&gt; is the most widely adopted PHP framework, known for its expressive syntax and large ecosystem. &lt;a href="https://www.temma.net/" rel="noopener noreferrer"&gt;Temma&lt;/a&gt; is a lightweight MVC framework designed to minimize boilerplate while remaining fully functional for production use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Symfony&lt;/strong&gt;&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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Controller&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;Symfony\Bundle\FrameworkBundle\Controller\AbstractController&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;Symfony\Component\HttpFoundation\JsonResponse&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;Symfony\Component\HttpFoundation\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;Symfony\Component\Routing\Attribute\Route&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cd"&gt;/**
 * Handles product-related pages and API endpoints.
 */&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductController&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;AbstractController&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/**
     * Returns the product catalog as HTML or JSON depending on the Accept header.
     *
     * When the client sends Accept: application/json, a JSON response is returned.
     * Otherwise, an HTML page is rendered using a Twig template.
     *
     * @param  Request  $request  The incoming HTTP request.
     * @return JsonResponse|\Symfony\Component\HttpFoundation\Response
     */&lt;/span&gt;
    &lt;span class="na"&gt;#[Route('/products', requirements: ['_format' =&amp;gt; 'html|json'])]&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;catalog&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="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Fetch product data (static here, would come from a repository in production)&lt;/span&gt;
        &lt;span class="nv"&gt;$products&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="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&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;'Widget Pro'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;49.99&lt;/span&gt;&lt;span class="p"&gt;],&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="mi"&gt;2&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;'Gadget Plus'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;29.99&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;

        &lt;span class="c1"&gt;// Check the preferred format from the Accept header&lt;/span&gt;
        &lt;span class="k"&gt;if&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;getPreferredFormat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'json'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Return a JSON response for API clients&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'products'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$products&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Render the HTML page for browser clients&lt;/span&gt;
        &lt;span class="k"&gt;return&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="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'product/catalog.html.twig'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'products'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$products&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;The route attribute declares the accepted formats. The data is prepared once. Symfony's &lt;code&gt;getPreferredFormat()&lt;/code&gt; reads the &lt;code&gt;Accept&lt;/code&gt; header and returns the matching format string. The action then branches: JSON response or Twig template.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Laravel&lt;/strong&gt;&lt;br&gt;
Laravel has no route-level format declaration for this use case. The detection happens in the controller via &lt;code&gt;expectsJson()&lt;/code&gt;, which checks the &lt;code&gt;Accept&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;routes/web.php&lt;/code&gt;&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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;App\Http\Controllers\ProductController&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;Illuminate\Support\Facades\Route&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Single route serving both HTML and JSON depending on the Accept header&lt;/span&gt;
&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/products'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;ProductController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'catalog'&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;app/Http/Controllers/ProductController.php&lt;/code&gt;&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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Http\Controllers&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;Illuminate\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;Illuminate\Routing\Controller&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cd"&gt;/**
 * Handles product-related pages and API endpoints.
 */&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductController&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Controller&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/**
     * Returns the product catalog as HTML or JSON depending on the Accept header.
     *
     * When the client sends Accept: application/json, a JSON response is returned.
     * Otherwise, an HTML page is rendered using a Blade template.
     *
     * @param  Request  $request  The incoming HTTP request.
     * @return \Illuminate\Http\JsonResponse|\Illuminate\View\View
     */&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;catalog&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="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Fetch product data (static here, would come from a model in production)&lt;/span&gt;
        &lt;span class="nv"&gt;$products&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="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&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;'Widget Pro'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;49.99&lt;/span&gt;&lt;span class="p"&gt;],&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="mi"&gt;2&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;'Gadget Plus'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;29.99&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;];&lt;/span&gt;

        &lt;span class="c1"&gt;// expectsJson() returns true when the Accept header contains application/json&lt;/span&gt;
        &lt;span class="k"&gt;if&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;expectsJson&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Return a JSON response for API clients&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;'products'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$products&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Render the HTML page for browser clients&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;'product.catalog'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'products'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$products&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;The product data is built once. If the client wants JSON, &lt;code&gt;response()-&amp;gt;json()&lt;/code&gt; serializes it. Otherwise, the data flows into a Blade template.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Temma&lt;/strong&gt;&lt;br&gt;
Temma takes a different approach. The action has no awareness of what format is being requested. It just puts data where the view can find it. A single attribute on the action handles format detection and output routing automatically.&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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;\Temma\Attributes\View&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;ProductController&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;\Temma\Web\Controller&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/**
     * The View attribute enables content negotiation:
     * HTML is rendered via a Smarty template, JSON is serialized automatically.
     */&lt;/span&gt;
    &lt;span class="na"&gt;#[View(negotiation: 'html, json')]&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;catalog&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'products'&lt;/span&gt;&lt;span class="p"&gt;]&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="s1"&gt;'id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&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;'Widget Pro'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;49.99&lt;/span&gt;&lt;span class="p"&gt;],&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="mi"&gt;2&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;'Gadget Plus'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;29.99&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;When the client sends &lt;code&gt;Accept: text/html&lt;/code&gt;, Temma renders the Smarty template at &lt;code&gt;templates/product/catalog.tpl&lt;/code&gt;. When the client sends &lt;code&gt;Accept: application/json&lt;/code&gt;, Temma serializes the data as JSON. The action code is identical in both cases.&lt;/p&gt;

&lt;p&gt;The attribute can be placed on the controller instead of the action, making it apply to all actions at once:&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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;\Temma\Attributes\View&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;View&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cd"&gt;/**
 * Handles product-related pages and API endpoints.
 *
 * The View attribute enables content negotiation for all actions in this controller:
 * HTML is rendered via a Smarty template, JSON is serialized automatically.
 */&lt;/span&gt;
&lt;span class="na"&gt;#[View(negotiation: 'html, json')]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProductController&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;\Temma\Web\Controller&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/** Returns the product catalog. */&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;catalog&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'products'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="cd"&gt;/** Returns a single product by ID.. */&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;detail&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="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'product'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&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;A note on terminology: in Temma, "view" is broader than a template. It is the component responsible for generating output in any format. The Smarty view renders HTML via templates. The JSON view serializes data. The same concept applies to CSV, RSS, iCal, and custom formats. Temma also supports more granular content negotiation with specific media types:&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="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;View&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;negotiation&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'json'&lt;/span&gt;             &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'~Json'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'text/csv'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'~Csv'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'application/pdf'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'\App\View\PdfExport'&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;strong&gt;When to use it, when not to&lt;/strong&gt;&lt;br&gt;
Content negotiation is a good fit when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The HTML and JSON representations share the same underlying data&lt;/li&gt;
&lt;li&gt;A mobile app or third-party client needs the same content your website already serves&lt;/li&gt;
&lt;li&gt;You want to avoid maintaining a parallel API for simple read operations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is not a good fit when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTML and JSON representations diverge significantly in structure or content&lt;/li&gt;
&lt;li&gt;You need field selection, filtering by relation, or linked data (JSON:API, GraphQL)&lt;/li&gt;
&lt;li&gt;You need strict API versioning or stability guarantees&lt;/li&gt;
&lt;li&gt;Your API requires authentication schemes incompatible with your web session model&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Content negotiation does not replace a dedicated API layer. It eliminates the need for one in cases where that layer would be redundant.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Who does this in production?&lt;/strong&gt;&lt;br&gt;
This is not a niche pattern. &lt;a href="https://docs.discourse.org/" rel="noopener noreferrer"&gt;Discourse&lt;/a&gt;, the open-source forum platform, documents it explicitly: most endpoints serve the same content as HTML or JSON. &lt;code&gt;/categories&lt;/code&gt; returns an HTML page in a browser, or a JSON payload when called with &lt;code&gt;Accept: application/json&lt;/code&gt; or as &lt;code&gt;/categories.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.mediawiki.org/wiki/API:REST_API" rel="noopener noreferrer"&gt;MediaWiki&lt;/a&gt;, which powers Wikipedia, uses the same approach in its REST API, serving JSON or HTML from the same endpoints depending on the &lt;code&gt;Accept&lt;/code&gt; header.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The bigger picture&lt;/strong&gt;&lt;br&gt;
HTTP content negotiation is often overlooked because frameworks don't always surface it prominently. But it has been part of the web's foundation for decades, and the tooling to use it is already there.&lt;/p&gt;

&lt;p&gt;For complex API needs, dedicated solutions like API Platform (built on Symfony) provide a complete content negotiation layer with versioning, subprotocol support, and strict media type handling. Content negotiation at the controller level and a full API framework are not in competition: they solve different problems at different scales.&lt;/p&gt;

&lt;p&gt;Full docs on Temma's content negotiation: &lt;a href="https://www.temma.net/" rel="noopener noreferrer"&gt;temma.net&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>php</category>
      <category>webdev</category>
      <category>api</category>
      <category>temma</category>
    </item>
    <item>
      <title>Calling OpenAI from a PHP framework the same way you query Redis or Memcache</title>
      <dc:creator>Amaury</dc:creator>
      <pubDate>Thu, 19 Mar 2026 17:47:00 +0000</pubDate>
      <link>https://forem.com/amaury_bouchard/calling-openai-from-a-php-framework-the-same-way-you-query-a-database-4n8b</link>
      <guid>https://forem.com/amaury_bouchard/calling-openai-from-a-php-framework-the-same-way-you-query-a-database-4n8b</guid>
      <description>&lt;p&gt;&lt;a href="https://www.temma.net" rel="noopener noreferrer"&gt;Temma&lt;/a&gt; is a PHP MVC framework designed to be easy to pick up and use. It sits between micro-frameworks (too bare) and full-stack ones (too heavy), and tries to get out of your way as much as possible.&lt;/p&gt;

&lt;p&gt;One of its core concepts is the datasource: a unified way to declare and access any external connection, whether it's a database, a cache, a message queue, or an API. In Temma 2.16.0, OpenAI joins that list.&lt;/p&gt;

&lt;p&gt;Most PHP tutorials on OpenAI integration involve installing a SDK, writing a service class, injecting it manually, and wiring everything together. It works, but it's a lot of plumbing for what is ultimately a remote call.&lt;/p&gt;

&lt;p&gt;In Temma, OpenAI is a datasource. The same way you declare a Redis connection, you declare an OpenAI connection. One line in your config, and you're done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuration
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;etc/temma.php&lt;/code&gt;:&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="cp"&gt;&amp;lt;?php&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;'application'&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;'dataSources'&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;'redis'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'redis://localhost'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'openai'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'openai://chat/gpt-4/sk-proj-xxxxxxxxxxxxx'&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;That's it. OpenAI sits alongside your database, your Redis cache, your S3 bucket, your Slack connection… Same pattern, same config file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Basic usage
&lt;/h2&gt;

&lt;p&gt;In any controller, you can access data sources using an array notation, or using methods:&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="c1"&gt;// Set value in Redis&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;redis&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'key'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'value'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Get value from Redis&lt;/span&gt;
&lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&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;redis&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'key'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For OpenAI, it's the same:&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;$openai&lt;/span&gt; &lt;span class="o"&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;openai&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Simple prompt&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$openai&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'What is the capital of France?'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;// Or using the read() method&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$openai&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Translate to French: Hello world'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// With a fallback value in case of error&lt;/span&gt;
&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$openai&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'Translate to French: Hello world'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'Bonjour le monde'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  System prompt and options
&lt;/h2&gt;

&lt;p&gt;You can pass a system prompt and control temperature directly in the call:&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;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$openai&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'Summarize this article in 3 bullet points: '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$articleBody&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="s1"&gt;'system'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'You are a concise technical writer.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'temperature'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.3&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;h2&gt;
  
  
  Multi-turn conversations
&lt;/h2&gt;

&lt;p&gt;Temma's OpenAI datasource handles conversation history natively:&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;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$openai&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'And the capital of Italy?'&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="s1"&gt;'system'&lt;/span&gt;   &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'You are a geography assistant.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'messages'&lt;/span&gt; &lt;span class="o"&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="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="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'What is the capital of France?'&lt;/span&gt;&lt;span class="p"&gt;],&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;'assistant'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'content'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'The capital of France is Paris.'&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="c1"&gt;// $response contains "The capital of Italy is Rome."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A practical example: auto-tagging articles
&lt;/h2&gt;

&lt;p&gt;Here is a realistic use case: a controller action that generates tags for an article automatically.&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="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;\Temma\Attributes\View&lt;/span&gt;   &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;TμView&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;\Temma\Attributes\Method&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;TμMethod&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;TμView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'~Json'&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="c1"&gt;// use JSON view on all actions&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Article&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;\Temma\Web\Controller&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// create a DAO on the 'article' table&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$_temmaAutoDao&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="c1"&gt;// POST /article/tag/1&lt;/span&gt;
    &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;TμMethod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'POST'&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="c1"&gt;// accept POST requests only&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;tag&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="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// get the article&lt;/span&gt;
        &lt;span class="nv"&gt;$article&lt;/span&gt; &lt;span class="o"&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;_dao&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$id&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="nv"&gt;$article&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&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="nf"&gt;_httpError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="c1"&gt;// fetch the tags from OpenAI&lt;/span&gt;
        &lt;span class="nv"&gt;$tags&lt;/span&gt; &lt;span class="o"&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;openai&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'Generate 3 comma-separated tags for this article: '&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$article&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'body'&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="s1"&gt;'system'&lt;/span&gt;      &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'You are a tagging assistant. Reply with exactly 3 tags, comma-separated, lowercase, no punctuation.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'temperature'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.2&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="c1"&gt;// update the article&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;_dao&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&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="s1"&gt;'tags'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$tags&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="c1"&gt;// return a JSON stream `{"tags": "tag1,tag2,tag3"}`&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'tags'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$tags&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;No service class. No manual injection. The OpenAI connection is available in the controller exactly like the database connection, because from Temma's perspective, they are the same kind of thing: a datasource.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;The datasource abstraction is one of Temma's core ideas. Whether you're talking to MySQL, Redis, S3, Slack, or OpenAI, the connection is declared in one place and accessed the same way throughout your code. Adding AI capabilities to an existing project becomes a config change and a few lines of code, not an architectural decision.&lt;/p&gt;

&lt;p&gt;Temma is open source (MIT), has been in production since 2007, and the OpenAI datasource landed in the just-released &lt;a href="https://github.com/Digicreon/Temma/releases/tag/2.16.0" rel="noopener noreferrer"&gt;2.16.0&lt;/a&gt;. Full docs at &lt;a href="https://www.temma.net" rel="noopener noreferrer"&gt;temma.net&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>php</category>
      <category>openai</category>
      <category>webdev</category>
      <category>temma</category>
    </item>
    <item>
      <title>µJS vs Turbo: same idea, different philosophy</title>
      <dc:creator>Amaury</dc:creator>
      <pubDate>Wed, 11 Mar 2026 11:39:56 +0000</pubDate>
      <link>https://forem.com/amaury_bouchard/ujs-vs-turbo-same-idea-different-philosophy-1627</link>
      <guid>https://forem.com/amaury_bouchard/ujs-vs-turbo-same-idea-different-philosophy-1627</guid>
      <description>&lt;p&gt;Turbo (part of Hotwire) and µJS solve the same problem: make server-rendered websites feel faster without rewriting the frontend in JavaScript. Both intercept link clicks and form submissions, fetch pages via AJAX, and inject content into the DOM.&lt;/p&gt;

&lt;p&gt;The differences are in scope, weight, and how much they ask of your server.&lt;/p&gt;




&lt;h2&gt;
  
  
  Size
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Library&lt;/th&gt;
&lt;th&gt;Size (min + gzip)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;µJS&lt;/td&gt;
&lt;td&gt;~5 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Turbo&lt;/td&gt;
&lt;td&gt;~25 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Turbo is 5× heavier. For a library whose primary job is "fetch a page and swap some HTML", that's a significant gap.&lt;/p&gt;




&lt;h2&gt;
  
  
  Build step
&lt;/h2&gt;

&lt;p&gt;Turbo requires a build step — it's distributed as an npm package designed to be bundled. µJS doesn't:&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;!-- µJS: drop-in, no bundler needed --&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://unpkg.com/@digicreon/mujs/dist/mu.min.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;mu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters for projects that deliberately avoid a JavaScript build pipeline — static sites, PHP/Python/Ruby apps, or any project where adding npm + a bundler is a step backwards.&lt;/p&gt;




&lt;h2&gt;
  
  
  Server-side requirements
&lt;/h2&gt;

&lt;p&gt;This is the most important difference in practice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;µJS requires nothing from your server.&lt;/strong&gt; It sends a standard HTTP request and expects standard HTML in return. Your existing pages work as-is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Turbo has conventions.&lt;/strong&gt; Turbo Drive (basic navigation) works like µJS. But Turbo Frames require your server to return specific &lt;code&gt;&amp;lt;turbo-frame&amp;gt;&lt;/code&gt; elements. Turbo Streams — the equivalent of µJS's patch mode — requires your server to return &lt;code&gt;&amp;lt;turbo-stream&amp;gt;&lt;/code&gt; custom elements wrapping content in &lt;code&gt;&amp;lt;template&amp;gt;&lt;/code&gt; tags.&lt;/p&gt;

&lt;p&gt;This means adopting Turbo Streams changes your server-side HTML output. With Rails and the Hotwire ecosystem, helpers handle this for you. With other backends, you're on your own.&lt;/p&gt;




&lt;h2&gt;
  
  
  Multi-fragment updates: patch mode vs Turbo Streams
&lt;/h2&gt;

&lt;p&gt;Both libraries can update multiple parts of the page in a single response. The syntax tells the story.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;µJS — patch mode:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The server returns plain HTML. Each fragment carries &lt;code&gt;mu-patch-target&lt;/code&gt; and &lt;code&gt;mu-patch-mode&lt;/code&gt; attributes:&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;!-- Append new comment --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"comment"&lt;/span&gt; &lt;span class="na"&gt;mu-patch-target=&lt;/span&gt;&lt;span class="s"&gt;"#comments"&lt;/span&gt; &lt;span class="na"&gt;mu-patch-mode=&lt;/span&gt;&lt;span class="s"&gt;"append"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Great article!&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Update counter --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;mu-patch-target=&lt;/span&gt;&lt;span class="s"&gt;"#comment-count"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;14 comments&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Reset form --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"/comments"&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"post"&lt;/span&gt; &lt;span class="na"&gt;mu-patch-target=&lt;/span&gt;&lt;span class="s"&gt;"#comment-form"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;textarea&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"body"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;button&amp;gt;&lt;/span&gt;Submit&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Turbo Streams:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Each fragment must be wrapped in &lt;code&gt;&amp;lt;turbo-stream&amp;gt;&lt;/code&gt; / &lt;code&gt;&amp;lt;template&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;turbo-stream&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"append"&lt;/span&gt; &lt;span class="na"&gt;target=&lt;/span&gt;&lt;span class="s"&gt;"comments"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;template&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"comment"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Great article!&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/template&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/turbo-stream&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;turbo-stream&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"update"&lt;/span&gt; &lt;span class="na"&gt;target=&lt;/span&gt;&lt;span class="s"&gt;"comment-count"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;template&amp;gt;&lt;/span&gt;14 comments&lt;span class="nt"&gt;&amp;lt;/template&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/turbo-stream&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;turbo-stream&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"replace"&lt;/span&gt; &lt;span class="na"&gt;target=&lt;/span&gt;&lt;span class="s"&gt;"comment-form"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;template&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"/comments"&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"post"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;textarea&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"body"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;button&amp;gt;&lt;/span&gt;Submit&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/template&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/turbo-stream&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With µJS, the fragment &lt;em&gt;is&lt;/em&gt; the content. With Turbo, each fragment requires a wrapper structure. The µJS approach has less boilerplate, and the HTML is directly readable as-is.&lt;/p&gt;

&lt;p&gt;There's another practical advantage: with µJS, &lt;code&gt;mu-patch-target&lt;/code&gt; attributes are ignored on initial page load. You can use the exact same HTML fragment in your normal page template and in your patch response.&lt;/p&gt;




&lt;h2&gt;
  
  
  HTTP methods
&lt;/h2&gt;

&lt;p&gt;Turbo supports GET and POST only. µJS supports GET, POST, PUT, PATCH, and DELETE — directly on links, buttons, and forms via &lt;code&gt;mu-method&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- DELETE button --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;mu-url=&lt;/span&gt;&lt;span class="s"&gt;"/api/item/42"&lt;/span&gt; &lt;span class="na"&gt;mu-method=&lt;/span&gt;&lt;span class="s"&gt;"delete"&lt;/span&gt;
        &lt;span class="na"&gt;mu-mode=&lt;/span&gt;&lt;span class="s"&gt;"remove"&lt;/span&gt; &lt;span class="na"&gt;mu-target=&lt;/span&gt;&lt;span class="s"&gt;"#item-42"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    Delete
&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- PUT link --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/api/publish/5"&lt;/span&gt; &lt;span class="na"&gt;mu-method=&lt;/span&gt;&lt;span class="s"&gt;"put"&lt;/span&gt; &lt;span class="na"&gt;mu-mode=&lt;/span&gt;&lt;span class="s"&gt;"none"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Publish&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With Turbo, PUT/PATCH/DELETE require a workaround (hidden &lt;code&gt;_method&lt;/code&gt; field or server-side routing conventions).&lt;/p&gt;




&lt;h2&gt;
  
  
  Triggers and polling
&lt;/h2&gt;

&lt;p&gt;Turbo handles links and forms. That's it.&lt;/p&gt;

&lt;p&gt;µJS adds &lt;code&gt;mu-trigger&lt;/code&gt;, which lets any element initiate a fetch on any event:&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;!-- Live search --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;mu-trigger=&lt;/span&gt;&lt;span class="s"&gt;"change"&lt;/span&gt; &lt;span class="na"&gt;mu-debounce=&lt;/span&gt;&lt;span class="s"&gt;"300"&lt;/span&gt;
       &lt;span class="na"&gt;mu-url=&lt;/span&gt;&lt;span class="s"&gt;"/search"&lt;/span&gt; &lt;span class="na"&gt;mu-target=&lt;/span&gt;&lt;span class="s"&gt;"#results"&lt;/span&gt; &lt;span class="na"&gt;mu-mode=&lt;/span&gt;&lt;span class="s"&gt;"update"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Poll every 5 seconds --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;mu-trigger=&lt;/span&gt;&lt;span class="s"&gt;"load"&lt;/span&gt; &lt;span class="na"&gt;mu-repeat=&lt;/span&gt;&lt;span class="s"&gt;"5000"&lt;/span&gt;
     &lt;span class="na"&gt;mu-url=&lt;/span&gt;&lt;span class="s"&gt;"/notifications"&lt;/span&gt; &lt;span class="na"&gt;mu-target=&lt;/span&gt;&lt;span class="s"&gt;"#notifs"&lt;/span&gt; &lt;span class="na"&gt;mu-mode=&lt;/span&gt;&lt;span class="s"&gt;"update"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Load on focus --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;mu-trigger=&lt;/span&gt;&lt;span class="s"&gt;"focus"&lt;/span&gt;
       &lt;span class="na"&gt;mu-url=&lt;/span&gt;&lt;span class="s"&gt;"/suggestions"&lt;/span&gt; &lt;span class="na"&gt;mu-target=&lt;/span&gt;&lt;span class="s"&gt;"#suggestions"&lt;/span&gt; &lt;span class="na"&gt;mu-mode=&lt;/span&gt;&lt;span class="s"&gt;"update"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Real-time: SSE
&lt;/h2&gt;

&lt;p&gt;Both libraries support Server-Sent Events. µJS has it built-in and reuses the same patch syntax:&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;div&lt;/span&gt; &lt;span class="na"&gt;mu-trigger=&lt;/span&gt;&lt;span class="s"&gt;"load"&lt;/span&gt; &lt;span class="na"&gt;mu-url=&lt;/span&gt;&lt;span class="s"&gt;"/stream"&lt;/span&gt;
     &lt;span class="na"&gt;mu-method=&lt;/span&gt;&lt;span class="s"&gt;"sse"&lt;/span&gt; &lt;span class="na"&gt;mu-mode=&lt;/span&gt;&lt;span class="s"&gt;"patch"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server pushes standard HTML fragments with &lt;code&gt;mu-patch-target&lt;/code&gt; attributes — the same format as a regular patch response. Nothing new to learn.&lt;/p&gt;

&lt;p&gt;Turbo Streams can be delivered over SSE, but you're still working with the &lt;code&gt;&amp;lt;turbo-stream&amp;gt;&lt;/code&gt; / &lt;code&gt;&amp;lt;template&amp;gt;&lt;/code&gt; format, and the server must produce that structure.&lt;/p&gt;




&lt;h2&gt;
  
  
  When Turbo makes more sense
&lt;/h2&gt;

&lt;p&gt;Turbo is the right choice if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're in the &lt;strong&gt;Rails / Hotwire ecosystem&lt;/strong&gt; — the integration is deep, the helpers are mature, and the community is large&lt;/li&gt;
&lt;li&gt;You need &lt;strong&gt;Turbo Native&lt;/strong&gt; for iOS/Android apps&lt;/li&gt;
&lt;li&gt;Your team is already familiar with Turbo conventions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Outside the Rails ecosystem, Turbo's conventions become overhead without the ecosystem benefits.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;µJS&lt;/th&gt;
&lt;th&gt;Turbo&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Size&lt;/td&gt;
&lt;td&gt;~5 KB&lt;/td&gt;
&lt;td&gt;~25 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build step&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server changes needed&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;For Frames and Streams&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-fragment updates&lt;/td&gt;
&lt;td&gt;Patch mode (plain HTML)&lt;/td&gt;
&lt;td&gt;Turbo Streams (&lt;code&gt;&amp;lt;turbo-stream&amp;gt;&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP methods&lt;/td&gt;
&lt;td&gt;GET/POST/PUT/PATCH/DELETE&lt;/td&gt;
&lt;td&gt;GET/POST&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Triggers on any event&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debounce / Polling&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSE&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rails ecosystem&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;µJS is a focused library: drop it in, call &lt;code&gt;mu.init()&lt;/code&gt;, and your site gains AJAX navigation with no server changes. If you need more, the attributes are there. If you don't, you don't pay for them.&lt;/p&gt;




&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://mujs.org/playground" rel="noopener noreferrer"&gt;Live playground&lt;/a&gt;&lt;/strong&gt; — test each feature interactively&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href="https://mujs.org/comparison" rel="noopener noreferrer"&gt;Full comparison: µJS vs Turbo vs htmx&lt;/a&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href="https://github.com/Digicreon/muJS" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;npm: &lt;code&gt;npm install @digicreon/mujs&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>turbo</category>
      <category>htmx</category>
    </item>
    <item>
      <title>µCSS: I built a full-featured CSS framework on top of PicoCSS</title>
      <dc:creator>Amaury</dc:creator>
      <pubDate>Wed, 11 Mar 2026 08:24:45 +0000</pubDate>
      <link>https://forem.com/amaury_bouchard/ucss-i-built-a-full-featured-css-framework-on-top-of-picocss-4b4n</link>
      <guid>https://forem.com/amaury_bouchard/ucss-i-built-a-full-featured-css-framework-on-top-of-picocss-4b4n</guid>
      <description>&lt;p&gt;For over a decade, I used Bootstrap for all my projects. It's solid and well-documented, but over time I found myself increasingly frustrated, especially with themes. Most of them pull in dozens of JavaScript libraries and fonts you don't need, and cleaning that up takes forever.&lt;/p&gt;

&lt;p&gt;So last year I started looking for something lighter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discovering PicoCSS
&lt;/h2&gt;

&lt;p&gt;I reviewed a lot of alternatives: Pure, Chota, Milligram, Bulma, UIkit, Skeleton, Simple.css, MVP.css, and many others. Most were either abandoned, too bare-bones, or just not pleasant to look at.&lt;/p&gt;

&lt;p&gt;I ended up choosing &lt;a href="https://picocss.com" rel="noopener noreferrer"&gt;PicoCSS&lt;/a&gt;. It's beautiful, semantic, accessible, and weighs only 11KB. Most HTML elements look great with zero CSS classes. For simple projects, it's perfect.&lt;/p&gt;

&lt;p&gt;But PicoCSS intentionally stays minimal. No grid system. No modal, no tabs, no toast, no breadcrumb, no badge. For anything beyond a basic page, you're on your own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap
&lt;/h2&gt;

&lt;p&gt;I maintain a PHP framework called &lt;a href="https://www.temma.net/" rel="noopener noreferrer"&gt;Temma&lt;/a&gt;, and I was working on a UI component library for it. Adapting components to PicoCSS meant building most of them from scratch: the grid, modals, tabs, alerts, cards, navigation...&lt;/p&gt;

&lt;p&gt;At some point it became obvious: I had built enough pieces to assemble a proper CSS framework. One that uses PicoCSS as a stable foundation, and adds everything it omits.&lt;/p&gt;

&lt;h2&gt;
  
  
  µCSS
&lt;/h2&gt;

&lt;p&gt;That's what &lt;a href="https://mucss.org" rel="noopener noreferrer"&gt;&lt;strong&gt;µCSS&lt;/strong&gt;&lt;/a&gt; is: PicoCSS v2 + the missing parts.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;17 UI components&lt;/strong&gt; — Accordion, Alert, Badge, Breadcrumb, Button, Card, Forms, Hero, Modal, Nav, Pagination, Progress, Skeleton, Spinner, Table, Toast, Tabs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;12-column responsive grid&lt;/strong&gt; — 5 breakpoints, offsets, ordering, display utilities&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;20 color themes&lt;/strong&gt; — one self-contained CSS file per theme&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Utility classes&lt;/strong&gt; — color (text, background, border), positioning (sticky, fixed)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dark mode&lt;/strong&gt; — automatic (&lt;code&gt;prefers-color-scheme&lt;/code&gt;) or manual (&lt;code&gt;data-theme&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~19KB gzipped&lt;/strong&gt; — smaller than Bootstrap (~30KB)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pure CSS&lt;/strong&gt; — no JavaScript dependency&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No build step&lt;/strong&gt; — no Node.js, no SASS, nothing to compile&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Usage is a single &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tag:&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;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://cdn.jsdelivr.net/npm/@digicreon/mucss/dist/mu.css"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or pick a specific theme:&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;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://cdn.jsdelivr.net/npm/@digicreon/mucss/dist/mu.violet.css"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Available themes: azure (default), red, pink, fuchsia, purple, violet, indigo, blue, cyan, jade, green, lime, yellow, amber, pumpkin, orange, sand, grey, zinc, slate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;The site &lt;a href="https://mucss.org" rel="noopener noreferrer"&gt;mucss.org&lt;/a&gt; uses µCSS itself (along with &lt;a href="https://mujs.org" rel="noopener noreferrer"&gt;µJS&lt;/a&gt;, a lightweight alternative to HTMX that I also built).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/Digicreon/muCSS" rel="noopener noreferrer"&gt;https://github.com/Digicreon/muCSS&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Themes: &lt;a href="https://mucss.org/themes" rel="noopener noreferrer"&gt;https://mucss.org/themes&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy to discuss design decisions or answer questions.&lt;/p&gt;

</description>
      <category>css</category>
      <category>webdev</category>
      <category>frontend</category>
      <category>picocss</category>
    </item>
    <item>
      <title>Make your server-rendered website feel like an SPA — with 5KB of JavaScript</title>
      <dc:creator>Amaury</dc:creator>
      <pubDate>Tue, 10 Mar 2026 13:13:26 +0000</pubDate>
      <link>https://forem.com/amaury_bouchard/make-your-server-rendered-website-feel-like-an-spa-with-5kb-of-javascript-2ck1</link>
      <guid>https://forem.com/amaury_bouchard/make-your-server-rendered-website-feel-like-an-spa-with-5kb-of-javascript-2ck1</guid>
      <description>&lt;h2&gt;
  
  
  Make your server-rendered website feel like an SPA — with 5KB of JavaScript
&lt;/h2&gt;

&lt;p&gt;You've built a server-rendered website. PHP, Python, Ruby, Go — doesn't matter. It works. It's fast. It's SEO-friendly.&lt;/p&gt;

&lt;p&gt;But every link click triggers a full page reload. The browser flashes white. The scroll position resets. Users notice.&lt;/p&gt;

&lt;p&gt;The usual answer is: "rewrite everything in React." That's overkill. There's a lighter path.&lt;/p&gt;

&lt;h3&gt;
  
  
  What µJS does
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://mujs.org" rel="noopener noreferrer"&gt;µJS&lt;/a&gt; intercepts link clicks and form submissions, fetches pages in the background with the &lt;code&gt;fetch()&lt;/code&gt; API, and swaps the content — without a full page reload. Navigation feels instant. The URL updates. Back/forward buttons work.&lt;/p&gt;

&lt;p&gt;That's the core. No framework. No build step. No server changes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;~5 KB gzipped&lt;/li&gt;
&lt;li&gt;Zero dependencies&lt;/li&gt;
&lt;li&gt;Works with any backend&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let's walk through a concrete example, step by step.&lt;/p&gt;




&lt;h3&gt;
  
  
  Step 1: A plain HTML website
&lt;/h3&gt;

&lt;p&gt;Here's a minimal site with two pages and a navigation bar.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;index.html&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;My site&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/style.css"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;nav&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Home&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/about"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;About&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/contact"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Contact&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/nav&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;main&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"content"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;Home&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Welcome.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/main&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clicking "About" reloads the entire page. The nav bar re-renders, the stylesheet re-fetches, the layout flickers.&lt;/p&gt;




&lt;h3&gt;
  
  
  Step 2: Add µJS
&lt;/h3&gt;

&lt;p&gt;Add one script tag at the end of &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt;, and call &lt;code&gt;mu.init()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
    &lt;span class="c"&gt;&amp;lt;!-- your content --&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://unpkg.com/@digicreon/mujs/dist/mu.min.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;mu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Every internal link (URLs starting with &lt;code&gt;/&lt;/code&gt;) is now intercepted. Clicking "About" fetches &lt;code&gt;/about&lt;/code&gt; via AJAX and swaps the entire &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; — no full reload, no flash, no scroll reset.&lt;/p&gt;

&lt;p&gt;The browser history is updated. Back and forward buttons work. &lt;strong&gt;Your backend doesn't change.&lt;/strong&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  Step 3: Update only the content area
&lt;/h3&gt;

&lt;p&gt;Swapping the entire &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; works, but if your nav bar is complex (active states, dropdowns…), you may want to update only the &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt; block.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;mu-target&lt;/code&gt; and &lt;code&gt;mu-source&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/about"&lt;/span&gt; &lt;span class="na"&gt;mu-target=&lt;/span&gt;&lt;span class="s"&gt;"#content"&lt;/span&gt; &lt;span class="na"&gt;mu-source=&lt;/span&gt;&lt;span class="s"&gt;"#content"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;About&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;µJS fetches &lt;code&gt;/about&lt;/code&gt;, extracts &lt;code&gt;#content&lt;/code&gt; from the response, and replaces the current &lt;code&gt;#content&lt;/code&gt;. The nav bar is untouched.&lt;/p&gt;

&lt;p&gt;You can also set this globally in &lt;code&gt;mu.init()&lt;/code&gt; to avoid repeating it on every link:&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;mu&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#content&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#content&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Step 4: Handle forms
&lt;/h3&gt;

&lt;p&gt;µJS intercepts form submissions automatically. A GET form behaves like a link:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"/search"&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"get"&lt;/span&gt;
      &lt;span class="na"&gt;mu-target=&lt;/span&gt;&lt;span class="s"&gt;"#content"&lt;/span&gt; &lt;span class="na"&gt;mu-source=&lt;/span&gt;&lt;span class="s"&gt;"#content"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"q"&lt;/span&gt; &lt;span class="na"&gt;placeholder=&lt;/span&gt;&lt;span class="s"&gt;"Search…"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Search&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A POST form works too — data is sent as &lt;code&gt;FormData&lt;/code&gt;, and the response replaces the target:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"/comment"&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"post"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;textarea&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"body"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Post comment&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;HTML5 validation (&lt;code&gt;required&lt;/code&gt;, &lt;code&gt;pattern&lt;/code&gt;…) is checked before the request is sent. No extra code needed.&lt;/p&gt;




&lt;h3&gt;
  
  
  Step 5: Live search
&lt;/h3&gt;

&lt;p&gt;Want search results to update as the user types? Use &lt;code&gt;mu-trigger&lt;/code&gt; and &lt;code&gt;mu-debounce&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"q"&lt;/span&gt;
       &lt;span class="na"&gt;mu-trigger=&lt;/span&gt;&lt;span class="s"&gt;"change"&lt;/span&gt;
       &lt;span class="na"&gt;mu-debounce=&lt;/span&gt;&lt;span class="s"&gt;"300"&lt;/span&gt;
       &lt;span class="na"&gt;mu-url=&lt;/span&gt;&lt;span class="s"&gt;"/search"&lt;/span&gt;
       &lt;span class="na"&gt;mu-target=&lt;/span&gt;&lt;span class="s"&gt;"#results"&lt;/span&gt;
       &lt;span class="na"&gt;mu-source=&lt;/span&gt;&lt;span class="s"&gt;"#results"&lt;/span&gt;
       &lt;span class="na"&gt;mu-mode=&lt;/span&gt;&lt;span class="s"&gt;"update"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mu-trigger="change"&lt;/code&gt; fires on every keystroke&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mu-debounce="300"&lt;/code&gt; waits 300ms after the user stops typing before sending the request&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mu-mode="update"&lt;/code&gt; replaces the &lt;em&gt;inner content&lt;/em&gt; of &lt;code&gt;#results&lt;/code&gt; (not the element itself)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your &lt;code&gt;/search&lt;/code&gt; endpoint returns plain HTML. No JSON, no JavaScript on the server side.&lt;/p&gt;




&lt;h3&gt;
  
  
  Step 6: Update multiple fragments at once (patch mode)
&lt;/h3&gt;

&lt;p&gt;Sometimes one action needs to update several parts of the page: add a comment, increment a counter, clear the form. That would normally require multiple requests or a JSON API.&lt;/p&gt;

&lt;p&gt;With patch mode, the server returns a single HTML response with multiple fragments, each annotated with a target:&lt;/p&gt;

&lt;p&gt;Link or form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"/comment"&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"post"&lt;/span&gt; &lt;span class="na"&gt;mu-mode=&lt;/span&gt;&lt;span class="s"&gt;"patch"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;textarea&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"body"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;button&amp;gt;&lt;/span&gt;Send&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Server response:&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;!-- Append the new comment to the list --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"comment"&lt;/span&gt; &lt;span class="na"&gt;mu-patch-target=&lt;/span&gt;&lt;span class="s"&gt;"#comments"&lt;/span&gt; &lt;span class="na"&gt;mu-patch-mode=&lt;/span&gt;&lt;span class="s"&gt;"append"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;The new comment&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Update the comment counter --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;mu-patch-target=&lt;/span&gt;&lt;span class="s"&gt;"#count"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;42 comments&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Reset the form --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;form&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"/comment"&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"post"&lt;/span&gt; &lt;span class="na"&gt;mu-patch-target=&lt;/span&gt;&lt;span class="s"&gt;"#comment-form"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;textarea&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"body"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/textarea&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;button&amp;gt;&lt;/span&gt;Send&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/form&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three DOM updates, one HTTP request, no JavaScript on the client side.&lt;/p&gt;




&lt;h3&gt;
  
  
  Step 7: Real-time updates with SSE
&lt;/h3&gt;

&lt;p&gt;Need live updates without polling? µJS supports Server-Sent Events:&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;div&lt;/span&gt; &lt;span class="na"&gt;mu-trigger=&lt;/span&gt;&lt;span class="s"&gt;"load"&lt;/span&gt;
     &lt;span class="na"&gt;mu-url=&lt;/span&gt;&lt;span class="s"&gt;"/notifications/stream"&lt;/span&gt;
     &lt;span class="na"&gt;mu-method=&lt;/span&gt;&lt;span class="s"&gt;"sse"&lt;/span&gt;
     &lt;span class="na"&gt;mu-mode=&lt;/span&gt;&lt;span class="s"&gt;"patch"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server pushes HTML fragments over the SSE connection. µJS renders them using patch mode. No WebSocket setup, no client-side message parsing.&lt;/p&gt;




&lt;h3&gt;
  
  
  Bonus: prefetch on hover
&lt;/h3&gt;

&lt;p&gt;Prefetch is enabled by default. When the user hovers over a link for 50ms, µJS starts fetching the page in the background. By the time they click, the content is ready. This typically saves 100–300ms of perceived loading time — for free.&lt;/p&gt;

&lt;p&gt;Disable it per-link if needed:&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;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/heavy-page"&lt;/span&gt; &lt;span class="na"&gt;mu-prefetch=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Heavy page&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  What you get without changing your backend
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;How&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AJAX navigation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mu.init()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fragment update&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;mu-target&lt;/code&gt; + &lt;code&gt;mu-source&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Live search&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;mu-trigger&lt;/code&gt; + &lt;code&gt;mu-debounce&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-fragment update&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mu-mode="patch"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real-time push&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mu-method="sse"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prefetch on hover&lt;/td&gt;
&lt;td&gt;Enabled by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Progress bar&lt;/td&gt;
&lt;td&gt;Enabled by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;View Transitions&lt;/td&gt;
&lt;td&gt;Enabled by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DOM morphing&lt;/td&gt;
&lt;td&gt;Auto-detected (idiomorph)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Back/forward buttons&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h3&gt;
  
  
  Try it
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://mujs.org/playground" rel="noopener noreferrer"&gt;Live playground&lt;/a&gt;&lt;/strong&gt; — test each feature interactively, with the page HTML, server response, and live result side by side&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href="https://mujs.org/documentation" rel="noopener noreferrer"&gt;Documentation&lt;/a&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/Digicreon/muJS" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @digicreon/mujs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or via CDN:&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://unpkg.com/@digicreon/mujs/dist/mu.min.js"&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;µJS is MIT licensed, ~5KB gzipped, zero dependencies. Carson Gross (creator of htmx) listed it on the &lt;a href="https://htmx.org/essays/alternatives/#ujs" rel="noopener noreferrer"&gt;htmx alternatives page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you have questions about design decisions or how it compares to HTMX/Turbo, happy to discuss in the comments.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>htmx</category>
      <category>turbo</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
