<?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: Roger Rosset</title>
    <description>The latest articles on Forem by Roger Rosset (@rrosset91).</description>
    <link>https://forem.com/rrosset91</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%2F458942%2F4505581b-5ad7-4823-98e4-dd5ec593ba8c.jpeg</url>
      <title>Forem: Roger Rosset</title>
      <link>https://forem.com/rrosset91</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/rrosset91"/>
    <language>en</language>
    <item>
      <title>How I Show Car Photos Without Storing a Single Image</title>
      <dc:creator>Roger Rosset</dc:creator>
      <pubDate>Sun, 22 Feb 2026 13:04:57 +0000</pubDate>
      <link>https://forem.com/rrosset91/how-i-show-car-photos-without-storing-a-single-image-4f96</link>
      <guid>https://forem.com/rrosset91/how-i-show-car-photos-without-storing-a-single-image-4f96</guid>
      <description>&lt;p&gt;When I started building &lt;strong&gt;AutoFeedback&lt;/strong&gt; — a European car review platform — I ran into an obvious problem early: I needed photos of thousands of car models, but I had no budget for a car photo API, no CDN costs to absorb, and no time to manually source and host images.&lt;/p&gt;

&lt;p&gt;The solution turned out to be elegant, free, and hiding in plain sight.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4kg1lbq131lxysf7kndy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4kg1lbq131lxysf7kndy.png" alt="Car Review Model Banner" width="800" height="292"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Most car data APIs are either expensive, require licensing agreements, or only cover popular markets. I have models like the Renault Clio, SEAT Ibiza, and Fiat Punto — bread-and-butter European cars that might not even appear in a US-centric paid API.&lt;/p&gt;

&lt;p&gt;And even if I found a source, hosting thousands of images on R2 or S3 would mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A pipeline to download and store images&lt;/li&gt;
&lt;li&gt;Storage costs that grow with the catalogue&lt;/li&gt;
&lt;li&gt;Maintenance when images go stale&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There had to be a better way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: Wikimedia Commons
&lt;/h2&gt;

&lt;p&gt;Wikimedia Commons is a free media repository with millions of images — including an enormous collection of car photos, all under Creative Commons licenses. And it has a &lt;strong&gt;public JSON API&lt;/strong&gt; that requires no authentication, no API key, and has no per-request cost.&lt;/p&gt;

&lt;p&gt;The trick: search Commons for &lt;code&gt;"BMW 3 Series"&lt;/code&gt; and grab the first image result. Done.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://commons.wikimedia.org/w/api.php&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;?action=query&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;generator=search&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;gsrsearch=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;gsrnamespace=6&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="c1"&gt;// namespace 6 = File/Image namespace only&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;prop=imageinfo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;iiprop=url&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;format=json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;origin=*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// enables CORS for browser requests&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;firstPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;imageUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;firstPage&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;imageinfo&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;url&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. I get a direct URL to a full-resolution image hosted on Wikimedia's CDN. My server never touches it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Svelte Component
&lt;/h2&gt;

&lt;p&gt;I wrapped this in a &lt;code&gt;VehicleImage&lt;/code&gt; component that handles loading, errors, and the fallback gracefully:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight svelte"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"ts"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;onMount&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;svelte&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;loading&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nl"&gt;$&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;searchQuery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;year&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;loadImage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;loading&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="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://commons.wikimedia.org/w/api.php&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;?action=query&amp;amp;generator=search&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;gsrsearch=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;searchQuery&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;gsrnamespace=6&amp;amp;prop=imageinfo&amp;amp;iiprop=url&amp;amp;format=json&amp;amp;origin=*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pages&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{})[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;any&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;imageUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;imageinfo&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;loading&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;onMount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loadImage&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="si"&gt;{&lt;/span&gt;&lt;span class="k"&gt;#if&lt;/span&gt; &lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- Spinner --&amp;gt;&lt;/span&gt;
&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="k"&gt;:else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- Fallback placeholder with car icon --&amp;gt;&lt;/span&gt;
&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;brand&lt;/span&gt;&lt;span class="si"&gt;} {&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"w-full h-full object-cover"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="k"&gt;/if&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key insight: the fetch happens &lt;strong&gt;in the browser&lt;/strong&gt;, not on the server. This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Zero server load&lt;/li&gt;
&lt;li&gt;Zero storage costs&lt;/li&gt;
&lt;li&gt;Images are served from Wikimedia's global CDN directly to the user&lt;/li&gt;
&lt;li&gt;If Wikimedia is down, we show a graceful fallback — no error thrown&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Client-Side?
&lt;/h2&gt;

&lt;p&gt;I'm running on Cloudflare Workers, which has a 10ms CPU limit on free tier for some operations. A server-side fetch to Wikimedia for every page load would be wasteful and could hit timeouts. By doing it client-side with &lt;code&gt;onMount&lt;/code&gt;, the page loads instantly with SSR content, and the image "pops in" after — a much better perceived performance story.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Caveat: First Result Isn't Always Perfect
&lt;/h2&gt;

&lt;p&gt;The first Wikimedia search result isn't guaranteed to be a perfect photo of the exact trim. Sometimes you get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A photo from a slightly different year&lt;/li&gt;
&lt;li&gt;A factory or motor show image&lt;/li&gt;
&lt;li&gt;Occasionally an interior shot&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's why I added a disclaimer across all 6 languages:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Images are illustrative and may not exactly represent the described model."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For a community review platform, this is totally acceptable. Users aren't buying from us — they're reading reviews. An approximate image is far better than a blank box.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Legal Side: Attribution Matters
&lt;/h2&gt;

&lt;p&gt;Here's something easy to miss: &lt;strong&gt;Wikimedia Commons images are not public domain by default.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Most are under Creative Commons licenses, which have real requirements:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;License&lt;/th&gt;
&lt;th&gt;What it requires&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CC BY&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Credit the author&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CC BY-SA&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Credit the author + share derivatives under the same license&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Public Domain&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No restrictions — use freely&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you just grab the URL and display the image with no attribution, you may be violating the license — even though Wikimedia doesn't technically block hotlinking.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fetching Attribution Metadata
&lt;/h3&gt;

&lt;p&gt;The good news: the same API call that returns the image URL can also return everything you need to comply. You just need to add &lt;code&gt;extmetadata&lt;/code&gt; to the &lt;code&gt;iiprop&lt;/code&gt; parameter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://commons.wikimedia.org/w/api.php&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;?action=query&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;generator=search&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;gsrsearch=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;searchQuery&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;gsrnamespace=6&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;prop=imageinfo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;iiprop=url|extmetadata&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;                          &lt;span class="c1"&gt;// &amp;lt;-- add this&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;iiextmetadatafilter=Artist|LicenseShortName|LicenseUrl&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="c1"&gt;// only what we need&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;format=json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;origin=*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;extmetadata&lt;/code&gt; object in the response contains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Artist.value&lt;/code&gt; — the author, often as an HTML string like &lt;code&gt;&amp;lt;a href="..."&amp;gt;John Doe&amp;lt;/a&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LicenseShortName.value&lt;/code&gt; — e.g. &lt;code&gt;"CC BY-SA 4.0"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LicenseUrl.value&lt;/code&gt; — e.g. &lt;code&gt;"https://creativecommons.org/licenses/by-sa/4.0"&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Rendering the Attribution
&lt;/h3&gt;

&lt;p&gt;Once you have the metadata, strip the HTML from the author field (since you don't want to blindly inject it) and render a proper &lt;code&gt;&amp;lt;figcaption&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight svelte"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;figure&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;brand&lt;/span&gt;&lt;span class="si"&gt;} {&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"w-full object-cover"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;figcaption&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-xs text-gray-400 mt-1"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    Via &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pageUrl&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;target=&lt;/span&gt;&lt;span class="s"&gt;"_blank"&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"noopener noreferrer"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Wikimedia Commons&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="k"&gt;#if&lt;/span&gt; &lt;span class="nx"&gt;author&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; · &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;author&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="k"&gt;/if&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="k"&gt;#if&lt;/span&gt; &lt;span class="nx"&gt;licenseShortName&lt;/span&gt;&lt;span class="si"&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="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;licenseUrl&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;target=&lt;/span&gt;&lt;span class="s"&gt;"_blank"&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"noopener noreferrer license"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;licenseShortName&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="k"&gt;/if&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/figcaption&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/figure&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This renders something like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Via &lt;a href="https://commons.wikimedia.org" rel="noopener noreferrer"&gt;Wikimedia Commons&lt;/a&gt; · Vauxford · &lt;a href="https://creativecommons.org/licenses/by-sa/4.0" rel="noopener noreferrer"&gt;CC BY-SA 4.0&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Which is exactly what the license requires — and it adds credibility to the page rather than detracting from it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What About Hotlinking?
&lt;/h3&gt;

&lt;p&gt;Wikimedia explicitly allows external image embedding via their CDN (&lt;code&gt;upload.wikimedia.org&lt;/code&gt;). They don't block it. But their &lt;a href="https://foundation.wikimedia.org/wiki/Policy:Terms_of_Use" rel="noopener noreferrer"&gt;Terms of Use&lt;/a&gt; and the individual file licenses still apply. Serving images without attribution is technically a license violation even if it works technically.&lt;/p&gt;

&lt;p&gt;The correct mental model: &lt;strong&gt;hotlinking is permitted, attribution is required.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;0 images stored&lt;/strong&gt; in my database or on any storage bucket&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;0 API keys&lt;/strong&gt; to manage or rotate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;0 monthly costs&lt;/strong&gt; for image serving&lt;/li&gt;
&lt;li&gt;Works for &lt;strong&gt;every European car brand&lt;/strong&gt; that has any Wikipedia/Commons presence&lt;/li&gt;
&lt;li&gt;Graceful fallback for truly obscure models&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full legal compliance&lt;/strong&gt; with a single extra API field and four lines of HTML&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The entire solution is ~50 lines of Svelte. Sometimes the best engineering is finding the data that already exists, pointing at it — and reading the license.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>javascript</category>
      <category>backend</category>
    </item>
    <item>
      <title>Adding Content Moderation to a SvelteKit App with OpenAI's Moderation API</title>
      <dc:creator>Roger Rosset</dc:creator>
      <pubDate>Sun, 22 Feb 2026 12:28:09 +0000</pubDate>
      <link>https://forem.com/rrosset91/adding-content-moderation-to-a-sveltekit-app-with-openais-moderation-api-1f4e</link>
      <guid>https://forem.com/rrosset91/adding-content-moderation-to-a-sveltekit-app-with-openais-moderation-api-1f4e</guid>
      <description>&lt;p&gt;When you build a platform where users can submit free-text content, it's only a matter of time before someone tries to post something nasty. My project, &lt;strong&gt;AutoFeedback&lt;/strong&gt;, a European car review platform built with SvelteKit and deployed on Cloudflare Pages, was no exception. I needed a way to catch harmful content before it ever reached the database, without over-blocking legitimate (even strongly-worded) car criticism.&lt;/p&gt;

&lt;p&gt;Here's how I did it in under 70 lines of server-side code.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpprxwx7zqxgqj0q8y4zt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpprxwx7zqxgqj0q8y4zt.png" alt="Review Form" width="800" height="544"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Just Block Bad Words?
&lt;/h2&gt;

&lt;p&gt;The first instinct most developers have, myself included, is to build a keyword blocklist. Maintain a list of slurs, flag anything that matches. Simple, right?&lt;/p&gt;

&lt;p&gt;In practice, it falls apart fast. Consider &lt;strong&gt;hate speech&lt;/strong&gt;: it's not just a list of banned words. Hate speech relies on context, phrasing, and intent. Someone might write &lt;em&gt;"people like that shouldn't be allowed to drive"&lt;/em&gt;, no slurs, no flagged keywords, but clearly hateful depending on context. Or they might use coded language, deliberate misspellings, or Unicode tricks to bypass your list. You'd be playing an endless game of whack-a-mole, maintaining an ever-growing blocklist across multiple languages (AutoFeedback supports six), and &lt;em&gt;still&lt;/em&gt; missing things.&lt;/p&gt;

&lt;p&gt;And then there's the flip side: &lt;strong&gt;false positives&lt;/strong&gt;. A car review that says &lt;em&gt;"this car is a killer deal"&lt;/em&gt; or &lt;em&gt;"the acceleration is insane, it absolutely murders the competition"&lt;/em&gt;, perfectly legitimate, but a naive keyword filter would flag them. On a car review site, people use strong, emotional language all the time. That's the whole point.&lt;/p&gt;

&lt;p&gt;Here's a quick comparison:&lt;/p&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;Keyword Blocklist&lt;/th&gt;
&lt;th&gt;AI Moderation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hate speech detection&lt;/td&gt;
&lt;td&gt;Catches known slurs only&lt;/td&gt;
&lt;td&gt;Understands context, coded language, intent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-language support&lt;/td&gt;
&lt;td&gt;Need separate lists per language&lt;/td&gt;
&lt;td&gt;Works across languages out of the box&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintenance&lt;/td&gt;
&lt;td&gt;Constant manual updates&lt;/td&gt;
&lt;td&gt;Model improves automatically&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;False positives&lt;/td&gt;
&lt;td&gt;High ("killer deal", "this car is a beast")&lt;/td&gt;
&lt;td&gt;Very low, understands product context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Evasion&lt;/td&gt;
&lt;td&gt;Trivial (misspellings, Unicode, spacing)&lt;/td&gt;
&lt;td&gt;Much harder to circumvent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup time&lt;/td&gt;
&lt;td&gt;Hours of curating word lists&lt;/td&gt;
&lt;td&gt;~70 lines of code, one API call&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So I looked at proper moderation APIs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Perspective API (Google/Jigsaw)&lt;/strong&gt;, solid and battle-tested, but another Google dependency, more complex setup with API key provisioning, and a separate scoring model you need to interpret yourself&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI Moderation&lt;/strong&gt;, free to use (even without a paid OpenAI plan), purpose-built for exactly this use case, and supports their latest &lt;code&gt;omni-moderation-latest&lt;/code&gt; model with a dead-simple API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I went with OpenAI. The endpoint checks text against categories like hate speech, harassment, self-harm, sexual content, and violence. It returns both a boolean &lt;code&gt;flagged&lt;/code&gt; result and per-category scores. Critically for a car review site, it &lt;em&gt;won't&lt;/em&gt; flag someone saying "this engine is terrible" or "worst purchase of my life", it understands the difference between a frustrated car owner and actual harmful content.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3vdk48cuh81j7ea6d40z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3vdk48cuh81j7ea6d40z.png" alt="Harmful Review" width="800" height="327"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;

&lt;p&gt;My stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SvelteKit&lt;/strong&gt; for the full-stack framework&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Pages&lt;/strong&gt; (Workers) for hosting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare D1&lt;/strong&gt; for the database&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server-side form actions&lt;/strong&gt; for review submission&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The moderation check sits between form validation and database insertion, a simple gate that rejects flagged content with a 400 error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User submits review
    → Turnstile CAPTCHA verification
    → Zod schema validation
    → OpenAI moderation check
    → Insert into D1 database
    → Redirect to model page
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Moderation Utility
&lt;/h2&gt;

&lt;p&gt;I created a single utility file at &lt;code&gt;src/lib/server/moderation.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ModerationResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;flagged&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;boolean&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="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;moderateReview&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ModerationResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Fail open, if no API key, allow the review&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;flagged&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.openai.com/v1/moderations&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;omni-moderation-latest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;text&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;`[Moderation] API error: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusText&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;flagged&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="c1"&gt;// Fail open&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[Moderation] No results in response&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;flagged&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;flagged&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;triggered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(([,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s2"&gt;`[Moderation] Content flagged. Categories: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;triggered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="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="na"&gt;flagged&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;flagged&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;categories&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;categories&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;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;[Moderation] Failed to call API:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;flagged&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="c1"&gt;// Fail open&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;h3&gt;
  
  
  Key Design Decisions
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Fail open, not closed.&lt;/strong&gt; If the OpenAI API is down, slow, or the API key isn't configured, reviews go through. I'd rather have one bad review slip past than block every legitimate user because of a third-party outage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Log flagged categories.&lt;/strong&gt; When content &lt;em&gt;is&lt;/em&gt; rejected, I log exactly which categories were triggered. This helps me understand what's being caught and tune if needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Single concatenated string.&lt;/strong&gt; Rather than making separate API calls for each field, I concatenate all text fields into one string. One API call, one latency hit.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn6l5k55a41cwazrvtuhm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn6l5k55a41cwazrvtuhm.png" alt="Server Log" width="800" height="429"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Integrating Into the Form Action
&lt;/h2&gt;

&lt;p&gt;In my SvelteKit form action (&lt;code&gt;+page.server.ts&lt;/code&gt; for the review page), the integration is minimal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;moderateReview&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$lib/server/moderation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// ... inside the form action, after Zod validation passes:&lt;/span&gt;

&lt;span class="c1"&gt;// Content moderation, check text fields before storing&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;recommendation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pros&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cons&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;summary_line&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;parseResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;textToModerate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;recommendation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pros&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cons&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;summary_line&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;modResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;moderateReview&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;textToModerate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;modResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;flagged&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Your review contains content that violates our guidelines. Please revise and try again.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// If we get here, content is clean, proceed with createReview()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. The user sees the same error format as a validation failure, their form values are preserved, and they can edit and resubmit.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmbilxkj1v4fp3y7w0jw3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmbilxkj1v4fp3y7w0jw3.png" alt="User Error" width="800" height="269"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up the API Key on Cloudflare
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;OPENAI_API_KEY&lt;/code&gt; is stored as a Cloudflare Pages secret, never in code, never in &lt;code&gt;wrangler.toml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx wrangler pages secret put OPENAI_API_KEY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in &lt;code&gt;app.d.ts&lt;/code&gt;, I declare the type so TypeScript knows about it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Platform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;env&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;D1Database&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// ... other bindings&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;
  
  
  What It Catches (and Doesn't)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Blocked:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hate speech, including subtle, context-dependent phrasing that no keyword list would ever catch&lt;/li&gt;
&lt;li&gt;Slurs and derogatory language targeting protected groups&lt;/li&gt;
&lt;li&gt;Threats and incitement to violence&lt;/li&gt;
&lt;li&gt;Sexually explicit content&lt;/li&gt;
&lt;li&gt;Graphic violence descriptions&lt;/li&gt;
&lt;li&gt;Self-harm content&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Allowed (correctly):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"This car is absolute garbage"&lt;/li&gt;
&lt;li&gt;"Worst money I ever spent, the dealer was useless"&lt;/li&gt;
&lt;li&gt;"The engine sounds like it's dying"&lt;/li&gt;
&lt;li&gt;"This thing is a death trap on wheels"&lt;/li&gt;
&lt;li&gt;"It kills every competitor in its class"&lt;/li&gt;
&lt;li&gt;Strong but legitimate criticism with mild profanity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is where the AI approach really shines compared to a blocklist. A keyword filter would choke on half of those "allowed" examples. Meanwhile, the &lt;code&gt;omni-moderation-latest&lt;/code&gt; model understands that someone ranting about their unreliable Renault is not the same as someone posting genuinely harmful content, even if both use aggressive language. In my testing across multiple languages, I haven't hit a single false positive on legitimate car reviews.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance Impact
&lt;/h2&gt;

&lt;p&gt;The OpenAI moderation endpoint is fast, typically &lt;strong&gt;50-150ms&lt;/strong&gt; from Cloudflare Workers. Since this only runs on form submission (not page loads), users barely notice. The total review submission flow goes from ~200ms to ~300ms. Completely acceptable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Rate limiting per user&lt;/strong&gt;, Currently, a determined user could keep tweaking phrasing to bypass moderation. Adding per-user rate limits on review submissions would help.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Category-specific thresholds&lt;/strong&gt;, The API returns confidence scores per category. I could allow borderline content in some categories (like &lt;code&gt;harassment/threatening&lt;/code&gt; with low confidence) while being stricter on others (like &lt;code&gt;sexual/minors&lt;/code&gt;).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Moderation queue&lt;/strong&gt;, Instead of outright rejecting, I could put flagged reviews in a moderation queue for manual review. But for a small project, the binary accept/reject is simpler.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Adding content moderation to AutoFeedback took about an hour of work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;~70 lines for the moderation utility&lt;/li&gt;
&lt;li&gt;~10 lines to integrate it into the form action&lt;/li&gt;
&lt;li&gt;A few lines of translation strings per language&lt;/li&gt;
&lt;li&gt;One &lt;code&gt;wrangler secret&lt;/code&gt; command for the API key&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The OpenAI moderation endpoint is free, fast, and remarkably accurate for UGC platforms. If you're building anything where users submit text, reviews, comments, forums, this is one of the easiest safety nets you can add.&lt;/p&gt;

</description>
      <category>sveltekit</category>
      <category>cloudflare</category>
      <category>openai</category>
      <category>moderation</category>
    </item>
    <item>
      <title>Maximizing Your Salesforce QCP Potential: Overcoming the Character Limit Challenge</title>
      <dc:creator>Roger Rosset</dc:creator>
      <pubDate>Wed, 02 Oct 2024 14:00:50 +0000</pubDate>
      <link>https://forem.com/rrosset91/maximizing-your-salesforce-qcp-potential-overcoming-the-character-limit-challenge-21g0</link>
      <guid>https://forem.com/rrosset91/maximizing-your-salesforce-qcp-potential-overcoming-the-character-limit-challenge-21g0</guid>
      <description>&lt;h2&gt;
  
  
  Goals
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;End the character limitation&lt;/strong&gt; in Salesforce Quote Calculator Plugin (QCP) while retaining the current implementation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leverage the proper use of QCP&lt;/strong&gt; for future customizations and enhancements by removing the existing code limitation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Specifications
&lt;/h2&gt;

&lt;p&gt;Salesforce QCP code is stored within a custom script field (&lt;strong&gt;SBQQ_&lt;em&gt;CustomScript&lt;/em&gt;_c&lt;/strong&gt;). Since this is a standard Salesforce object, the usual limitations, rules, and execution guidelines for Salesforce objects apply. In particular, the code is stored in the &lt;strong&gt;SBQQ_&lt;em&gt;Code&lt;/em&gt;_c&lt;/strong&gt; field, which has a maximum character limit of &lt;strong&gt;131,072 characters&lt;/strong&gt; due to its field type (Long Text Area).&lt;/p&gt;

&lt;p&gt;In typical JavaScript development, code can be lengthy and, practically speaking, unlimited. Modern JavaScript allows the use of &lt;strong&gt;modules&lt;/strong&gt;, enabling developers to separate and organize code in ways that improve readability, reusability, and maintenance across an entire application. However, when using QCP in Salesforce CPQ, the custom script is transpiled in real time by the CPQ application (usually in the Quote Line Editor), which means we are limited to having only one record read as a plugin at a time.&lt;/p&gt;

&lt;p&gt;The setting for this can be found here:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Setup &amp;gt; Installed Packages &amp;gt; Salesforce CPQ &amp;gt; Configure &amp;gt; Plugins Tab &amp;gt; Quote Calculator Plugin&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This configuration tells the Quote Line Editor which specific custom script record to read as the active plugin.&lt;/p&gt;

&lt;p&gt;Unfortunately, due to the &lt;strong&gt;Salesforce CPQ architecture&lt;/strong&gt; and the &lt;strong&gt;out-of-the-box (OOB)&lt;/strong&gt; behavior, JavaScript modules are not natively supported for QCP. To address this limitation, a creative workaround involves adapting the code architecture to work more effectively within Salesforce, using &lt;strong&gt;Static Resources&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Static Resource Approach
&lt;/h2&gt;

&lt;p&gt;QCP and custom scripts in Salesforce allow the invocation of Apex methods from JavaScript code by leveraging the &lt;code&gt;conn&lt;/code&gt; parameter, which is powered by &lt;a href="https://jsforce.github.io/document/#apex-rest" rel="noopener noreferrer"&gt;&lt;strong&gt;JSForce&lt;/strong&gt;&lt;/a&gt; (a JavaScript library for interacting with Salesforce) to make Apex REST calls. By using this mechanism, we can design an architecture that overcomes the character limitation. Here’s how it works:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnvbh7esfypd8j3j64sdf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnvbh7esfypd8j3j64sdf.png" alt="Architecture Diagram"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Static Resources&lt;/strong&gt;: Store the main JavaScript logic in a Static Resource, which can hold the full, modular JavaScript code without the character limitations of a text area field.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Custom Script Record&lt;/strong&gt;: Use the &lt;strong&gt;SBQQ_&lt;em&gt;CustomScript&lt;/em&gt;_c&lt;/strong&gt; record to include only the logic needed to call the appropriate methods or initialize processes from the Static Resource.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Apex Handler&lt;/strong&gt;: Write an Apex handler class to facilitate communication between the QCP script and Salesforce, making it possible to evaluate the JavaScript code in the Static Resource at runtime.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This approach leverages JavaScript's &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval" rel="noopener noreferrer"&gt;&lt;code&gt;eval()&lt;/code&gt;&lt;/a&gt; function in a controlled manner to load the code from the Static Resource dynamically. It’s important to note that while &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval" rel="noopener noreferrer"&gt;&lt;code&gt;eval()&lt;/code&gt;&lt;/a&gt; can introduce security risks if misused, using it in this context—carefully and within the Salesforce controlled environment—can be a pragmatic solution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation Details
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Custom Script Record
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;let QCP;

export async function onInit(quoteLineModels, conn) {
    try {
        const responseString = await conn.apex.get('/qcphandler/');
        const response = JSON.parse(responseString);
        const qcpCode = response.result;

        if (qcpCode) {
            await readQCP(qcpCode);
        } else {
            throw new Error("QCP code is not available. Check for the static resource name that should be exactly 'qcpsourcecode'");
        }
    } catch (e) {
        console.error(e);
    }

    return Promise.resolve();
}

async function readQCP(qcpCode) {
    await eval(qcpCode);

    if (window.QCPCode !== undefined) {
        QCP = window.QCPCode;
    } else {
        QCP = globalThis.QCPCode;
    }

    try {
        QCP.test();
    } catch (error) {
        console.error("Error executing QCP.test():", error);
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;SBQQ__CustomScript__c&lt;/strong&gt; should contain a minimal script that connects to the Static Resource. This script will handle any initial setup and call the Apex methods needed for the rest of the processing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Static Resource (JavaScript TXT file)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;class QCPCode{
    static test(){
        console.log('Hello from QCP Static Resource');
    }
}

if(typeof window !== 'undefined'){
    window.QCPCode = QCPCode;
}else{
    globalThis.QCPCode = QCPCode;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The main logic of the QCP is stored here, including modular code that otherwise would exceed character limits. This allows you to use modern JavaScript coding practices like functions and code separation for better readability.&lt;br&gt;
In this example we are just implementing one simple method that does a simple console.log, but you can basically replicate your existing logic for all the QCP functions such as onBeforeCalculate(), onAfterCalculate(), etc.&lt;/p&gt;

&lt;p&gt;Basically, the code needs to be a JS class, that after it's definition will be saved to the &lt;strong&gt;window&lt;/strong&gt; or &lt;strong&gt;globalThis&lt;/strong&gt; context of the browser. This is because it's not possible to use things like &lt;strong&gt;localStorage&lt;/strong&gt; or &lt;strong&gt;sessionStorage&lt;/strong&gt; here.&lt;/p&gt;
&lt;h3&gt;
  
  
  Apex Handler
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@restresource(urlmapping='/qcphandler/*')
global with sharing class CPQ_QCPHandler {

    @HttpGet
    global static String startQcp(){
        String responseString = '';
        try {
            String qcpSourceCode = [SELECT Body FROM StaticResource WHERE Name = 'qcpsourcecode'].Body.toString();
            QCPHandlerResponse response = new QCPHandlerResponse(qcpSourceCode, true, null);
            responseString = JSON.serialize(response);
        } catch (Exception e) {
            System.debug('Exception: ' + e.getMessage());
            QCPHandlerResponse response = new QCPHandlerResponse(null, false, e.getMessage());
            responseString = JSON.serialize(response);
        }
        return responseString;
    }

    private class QCPHandlerResponse{
        public QCPHandlerResponse(String result, Boolean success, String errorMessage){
            this.result = result;
            this.success = success;
            this.errorMessage = errorMessage;
        }
        public String result;
        public Boolean success;
        public String errorMessage;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The Apex handler acts as a REST endpoint that the JavaScript can call to fetch or execute resources as needed. This makes integration straightforward and keeps test coverage easy to maintain, as only a single, stable Apex method is used.&lt;/p&gt;

&lt;p&gt;In this sample case where we basically only added one single method that does a console.log, this should be the outcome when entering the Quote Line Editor (QLE) and calculating the quote: (Screenshot taken from browser's console)&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgs23cnqhxqknzxv4917a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgs23cnqhxqknzxv4917a.png" alt="Browser Console Screenshot"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here it's a simple boilerplate about what would the code really look like for using this approach to properly use it in real QCP scenarios:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;export function onBeforeCalculate(quote, lines, conn) {
    return CPQ.onBeforeCalculate(quote,lines,conn);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Benefits of This Approach
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Readability and Maintenance&lt;/strong&gt;: By storing the bulk of the JavaScript in Static Resources, the QCP code becomes significantly more readable and modular. It also facilitates easier long-term maintenance.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Character Limit Removed&lt;/strong&gt;: The QCP code size is effectively unrestricted since the Static Resource can hold much larger JavaScript files than the text field in Salesforce.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Easy Rollback&lt;/strong&gt;: During testing and implementation, it is easy to revert to the current setup using Salesforce’s OOB configuration. This ensures minimal disruption during experimentation.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Simplified Test Coverage&lt;/strong&gt;: Since this solution involves only one Apex class that operates as a REST resource with a single method, the Apex test coverage requirements remain stable. Deploying the QCP code is simplified to deploying a new version of the Static Resource.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Simplified Testing&lt;/strong&gt;: This approach is more about restructuring the way code is accessed rather than adding new features. Thus, testing focuses on verifying stability rather than introducing new potential bugs, making the process less risky.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Drawbacks
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Existing Code Complexity&lt;/strong&gt;: While this approach addresses the character limit issue, it doesn’t inherently improve the quality of the existing codebase. The same logic and practices (whether well-structured or not) are preserved, meaning any pre-existing messiness or inefficiencies in the code will remain. Further refactoring could be pursued in the future to optimize code quality and structure.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;This approach provides an effective workaround to Salesforce CPQ's limitations by leveraging Static Resources and a streamlined Apex handler. It brings significant improvements in readability, scalability, and maintenance without requiring a complete overhaul of existing business logic. Although this solution does not inherently improve the underlying code practices, it sets the stage for easier future optimizations and more robust customizations.&lt;/p&gt;




&lt;h3&gt;
  
  
  Important🥰
&lt;/h3&gt;

&lt;p&gt;Please like this post and don't forget to make a proper reference to this article if it's useful for you. &lt;br&gt;
You can find more information about me &lt;strong&gt;&lt;a href="https://www.rrosset.com/" rel="noopener noreferrer"&gt;HERE&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
😃&lt;/p&gt;

</description>
      <category>cpq</category>
      <category>qcp</category>
      <category>limitation</category>
      <category>salesforce</category>
    </item>
    <item>
      <title>How create CPF Input Mask on Salesforce Aura Framework</title>
      <dc:creator>Roger Rosset</dc:creator>
      <pubDate>Tue, 18 May 2021 17:10:57 +0000</pubDate>
      <link>https://forem.com/rrosset91/how-create-cpf-input-mask-on-salesforce-aura-framework-cno</link>
      <guid>https://forem.com/rrosset91/how-create-cpf-input-mask-on-salesforce-aura-framework-cno</guid>
      <description>&lt;p&gt;Brazilians systems that are related to person accounts and customer information, always use CPF inputs, and this type of input has it's own definitions. One of them is about the pattern.&lt;/p&gt;

&lt;p&gt;When it comes to provide custom masks within Salesforce Aura lightning inputs, everything goes into a dark zone. One often used workaround, is to use custom regex &lt;code&gt;pattern&lt;/code&gt; attribute with &lt;code&gt;patternMismatch&lt;/code&gt; message:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;messageWhenPatternMismatch="CPF Inválido. Por favor utilize o padrão 000.000.000-00"&lt;/code&gt;&lt;br&gt;
&lt;code&gt;pattern="[0-9]{3}.[0-9]{3}.[0-9]{3}-[0-9]{2}"&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;It works, but in terms of UX we can say it is not one of the best solutions possible. &lt;br&gt;
&lt;a href="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4ync4o4ehtscrlzgf2tu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4ync4o4ehtscrlzgf2tu.png" alt="invalid-cpf-mask.png" width="391" height="92"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When working with Salesforce, specially Aura and LWC, we are always being careful about limitations, and Shadow Dom, so there is no default way to implement input masks as would be possible using pure JavaScript for example.&lt;/p&gt;

&lt;p&gt;The good news is that after a little and simple development using pure JavaScript with Aura peculiarities, you can implement a automatic CPF input mask into your &lt;code&gt;lightning:input&lt;/code&gt; field.&lt;/p&gt;

&lt;p&gt;First of all, you need to create the following attribute:&lt;br&gt;
&lt;code&gt;&amp;lt;aura:attribute name="cpfValue" type="String" default=""/&amp;gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;After that, create your lightning:input field that will be used to store the CPF information. The type here will be default, because we need the special characters on this input, otherwise, we would set the type as number, but it's not the case.&lt;br&gt;
&lt;code&gt;&amp;lt;lightning:input aura:id="cpf" label="{!v.cpfLabel}" value="{!v.cpfValue}" onchange="{!c.handleCpfChange}" class="input sfdc_usernameinput sfdc" maxlength="14" required="true"/&amp;gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;In my case, all the inputs uses a defined label that comes from the design file, but you don't have to worry about this. The attributes that are needed for this implementation to work, is:&lt;br&gt;
-maxlength&lt;br&gt;
-value&lt;br&gt;
-aura:id&lt;br&gt;
-onchange&lt;/p&gt;

&lt;p&gt;Now, going to the js controller, we will have the following code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    handleCpfChange: function (component, event){
        let inputCpf = event.getParam("value");
        component.set("v.cpfValue", inputCpf);
        let size = component.get("v.cpfValue").length;
        if(size === 3 || size === 7){
            component.set("v.cpfValue", inputCpf+'.');
        }
        if(size === 11){
            component.set("v.cpfValue", inputCpf+'-');
        }
    }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this code is doing, is running every time the value on the cpf field changes, by the &lt;code&gt;onchange&lt;/code&gt; attributed that we've set before. Each time it runs, it will save the event value on the &lt;code&gt;inputCpf&lt;/code&gt; variable, and set the attribute &lt;code&gt;v.cpfValue&lt;/code&gt; with that variable value. Also, each time it will read for the length of the attribute, and when this length reaches 3 or 7, it will understand that it's time to put a dot, and when it reaches a size of 11, it will understand that it's time to put a slash.&lt;/p&gt;

&lt;p&gt;So, there you have. A custom made and ready to go CPF input mask. You can also use this logic to develop your own custom masks, the reasoning will be the same.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbpqj35kag5eb8gfkba0k.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/cdn-cgi/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fbpqj35kag5eb8gfkba0k.gif" alt="Alt Text" width="480" height="85"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I hope this be useful!&lt;/p&gt;

&lt;h4&gt;
  
  
  Roger Rosset
&lt;/h4&gt;

</description>
      <category>salesforce</category>
      <category>aura</category>
      <category>javascript</category>
      <category>inputmask</category>
    </item>
    <item>
      <title>How to Import Salesforce Custom Metadata Records using CSV/JSON</title>
      <dc:creator>Roger Rosset</dc:creator>
      <pubDate>Thu, 27 Aug 2020 13:17:10 +0000</pubDate>
      <link>https://forem.com/rrosset91/how-to-import-salesforce-custom-metadata-records-using-csv-json-1jic</link>
      <guid>https://forem.com/rrosset91/how-to-import-salesforce-custom-metadata-records-using-csv-json-1jic</guid>
      <description>&lt;p&gt;Importing Salesforce Custom Metadata Records can be really tricky. Forget about using import wizard or dataloader to do that. These tools are amazing but simply do not support operations on Custom Metadata Records.&lt;/p&gt;

&lt;p&gt;If you perform a quick search on Google about this theme, you probably will, at certain point of your search, get into an guide that will tell you to use the &lt;strong&gt;Custom Metadata Loader Loader&lt;/strong&gt;. This tool may be useful if you have just s few records to import, and none of your records has an special character on it, otherwise you certainly will fail after a lot of bugs and errors.&lt;/p&gt;

&lt;p&gt;I've developed an class using the official Salesforce Documentation as guidelines to achieve that requirement, and I'll help you to import your Salesforce Custom Metadata Records following steps below:&lt;/p&gt;

&lt;h2&gt;
  
  
  1:Prepare your Data
&lt;/h2&gt;

&lt;p&gt;You can use any online service that converts &lt;strong&gt;csv&lt;/strong&gt; into &lt;strong&gt;json&lt;/strong&gt;. I recommend the service below:&lt;br&gt;
&lt;a href="https://csvjson.com/csv2json" rel="noopener noreferrer"&gt;https://csvjson.com/csv2json&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fi%2Fsl3kjvgn3yg27iyz57ct.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fi%2Fsl3kjvgn3yg27iyz57ct.jpg" alt="Alt Text"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Simply upload your &lt;strong&gt;CSV&lt;/strong&gt; file containing the records you want to upload, click on &lt;strong&gt;CONVERT&lt;/strong&gt; and copy the generated &lt;strong&gt;JSON&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  1.1:Make your JSON inline
&lt;/h2&gt;

&lt;p&gt;What we have to do next, is to parse that &lt;strong&gt;JSON&lt;/strong&gt; into a single line, you can easily do that accessing the following link:&lt;br&gt;
&lt;a href="https://jsonformatter.curiousconcept.com/" rel="noopener noreferrer"&gt;https://jsonformatter.curiousconcept.com/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Simply paste your &lt;strong&gt;JSON&lt;/strong&gt;, select &lt;strong&gt;COMPACT&lt;/strong&gt; on the &lt;strong&gt;JSON Template Option&lt;/strong&gt; and click &lt;strong&gt;PROCESS&lt;/strong&gt;. Copy the generated compact &lt;strong&gt;JSON&lt;/strong&gt; and keep that safe for later steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  2:Create the mdtImport Apex Class using the code below
&lt;/h2&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;

//author: Roger Rosset
//description: Upload custom metadata records using CSV/JSON

public class mdtImport  {    
    public static void insertMetadata(String metaDataTypeName, String jsonString){
        try{
            Integer count = 1;
            Metadata.DeployContainer mdContainer = new Metadata.DeployContainer();
            JSONCsvTemplate csv = (JSONCsvTemplate)JSON.deserialize(jsonStringMdt(jsonString), JSONCsvTemplate.class);
            for(JSONCsvTemplate.mdtRecords item : csv.data.mdtRecordsList){
            //Sets the custom metadata type you'll insert your records on

            //If you're using namespaces on your org set it here
            String nameSpacePrefix ='';                                     
            Metadata.CustomMetadata rec =  new Metadata.CustomMetadata();
            String label = 'Record '+count;                      
            rec.fullName = nameSpacePrefix+metaDataTypeName+'.'+label;
            rec.label = label; 

            //Sets the custom metadata custom fields to be inserted
            /*
            * Use the template below to setup any fields you want to:
            *    
            Metadata.CustomMetadataValue fieldX = new Metadata.CustomMetadataValue();     //New instance
            fieldX.field = 'Custom_Field_Name__c';              //Custom Metadata Field you want to fill
            field1.value = item.JSON_Matching_Key_Value;        //The matching key value on the wrapper
            rec.values.add(fieldX);                             //adds the value and the matching field
            *
            */

            Metadata.CustomMetadataValue field1 = new Metadata.CustomMetadataValue();
            field1.field = 'SubGroup__c';
            field1.value = item.SubGroup;
            rec.values.add(field1);            
            Metadata.CustomMetadataValue field2 = new Metadata.CustomMetadataValue();
            field2.field = 'Description__c';
            field2.value = item.Description;
            rec.values.add(field2);                       
            Metadata.CustomMetadataValue field3 = new Metadata.CustomMetadataValue();
            field3.field = 'keyId__c';
            field3.value = item.keyId;
            rec.values.add(field3);                        
            mdContainer.addMetadata(rec);  
            count++;
    } 
            Id jobId = Metadata.Operations.enqueueDeployment(mdContainer, null);
            system.debug('Container&amp;gt;&amp;gt;'+mdContainer);
            system.debug('Id&amp;gt;&amp;gt;'+jobId);
            return;
        }

            catch(Exception ex){             
            system.debug('Error on insert');
            system.debug('Error:'+ex.getMessage());
        }

    }
    private static String jsonStringMdt(String jsonString){
        String resultJson = '{"data":{"mdtRecordsList":'+jsonString+'}}';
        return resultJson;
    }

    private class JSONCsvTemplate{
        private class Data{
            private List&amp;lt;mdtRecords&amp;gt; mdtRecordsList;
    }
        private class mdtRecords{
            private String keyId;
            private String SubGroup;
            private String Description;
        }
        private Data data;
    }
}



&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;As you can see you can execute the methods &lt;strong&gt;insertMetaData&lt;/strong&gt; passing the &lt;strong&gt;metaDataTypeName&lt;/strong&gt; and &lt;strong&gt;jsonString&lt;/strong&gt; as attributes, where &lt;strong&gt;metaDataTypeName&lt;/strong&gt; is the API Name of your Custom Metadata Type (&lt;strong&gt;&lt;em&gt;ending with __mdt&lt;/em&gt;&lt;/strong&gt;) and &lt;strong&gt;jsonString&lt;/strong&gt; is your inline JSON that you've copied on the step 1.1, But first you have to adjust the attributes on the JSONCsvTemplate&amp;gt;mdtRecords to fit like your csv database.&lt;/p&gt;

&lt;h2&gt;
  
  
  3:Adjust the Wrapper class to your requirements
&lt;/h2&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;

    private class JSONCsvTemplate{
        private class Data{
            private List&amp;lt;mdtRecords&amp;gt; mdtRecordsList;
    }
        private class mdtRecords{
            private String keyId;
            private String SubGroup;
            private String Description;
        }
        private Data data;
    }
}


&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;As you can see above, we are uploading records that has 3 columns:&lt;br&gt;
 -keyId&lt;br&gt;
 -SubGroup&lt;br&gt;
 -Description&lt;/p&gt;

&lt;p&gt;These values matches exactly as my csv database headers, and you can add, delete or modify the template to meet your criteria. Keep in mind that your csv database must fit the wrapper class, not only about the field names, but also the primitive data types (Integer, String, Etc.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Csv Database used:&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fi%2Fruf4a7c9bv3c5xrum0u6.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fi%2Fruf4a7c9bv3c5xrum0u6.jpg" alt="Alt Text"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4:Execute the method
&lt;/h2&gt;

&lt;p&gt;After following all the previous steps and making sure you've created and saved the class on your Salesforce Org, all you have to do next is to execute the &lt;strong&gt;insertMetadata&lt;/strong&gt;&lt;br&gt;
Get your Custom Metadata Type API Name (ending with __mdt) and your JSON String generated in the step 1.1, and execute the method on the Apex Anonymous Window inside the developer console (CTRL+E)&lt;br&gt;
&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fi%2Fl6jmkf5h31pi5wzgzvgi.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fi%2Fl6jmkf5h31pi5wzgzvgi.jpg" alt="Alt Text"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Keep in mind that your JSON String may be really big, and to avoid problems, it's highly recommended you to place the method template and quotes before pasting the "super string" on the parameter inside the method execution, as below:&lt;br&gt;
&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fi%2Fw8r365lj7ab0pfxssje4.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fi%2Fw8r365lj7ab0pfxssje4.jpg" alt="Alt Text"&gt;&lt;/a&gt;&lt;br&gt;
After preparing the the method, simply paste your big JSON String inside the quotes, and click execute.&lt;br&gt;
&lt;a href="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fi%2Fvjqv50nioanayas6io2l.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fi%2Fvjqv50nioanayas6io2l.jpg" alt="Alt Text"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you have checked the Open Log option, you should be taken to the log of this execution, where you can find the ID of your enqueued deploy, and check it's status on the deployment status of Salesforce on:&lt;br&gt;
&lt;strong&gt;SETUP &amp;gt; DEPLOY &amp;gt; DEPLOYMENT STATUS&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Attention
&lt;/h2&gt;

&lt;p&gt;I've already tested the deploy of 600 records per execution, try to respect that limit to avoid errors.&lt;/p&gt;

&lt;p&gt;Feel free to modify the code as you need to, keeping the credits if possible.&lt;/p&gt;

&lt;p&gt;Hope it be useful.&lt;/p&gt;

&lt;p&gt;Regards,&lt;br&gt;
Roger Rosset&lt;/p&gt;

&lt;p&gt;Based documentation:&lt;br&gt;
&lt;a href="https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_class_Metadata_Operations.htm" rel="noopener noreferrer"&gt;https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_class_Metadata_Operations.htm&lt;/a&gt;&lt;/p&gt;

</description>
      <category>salesforce</category>
      <category>apex</category>
      <category>json</category>
      <category>metadata</category>
    </item>
  </channel>
</rss>
