<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Marius</title>
    <description>The latest articles on DEV Community by Marius (@riusmax).</description>
    <link>https://dev.to/riusmax</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3902021%2F76ef6960-4263-4167-8515-dfc80e2f62ed.jpeg</url>
      <title>DEV Community: Marius</title>
      <link>https://dev.to/riusmax</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/riusmax"/>
    <language>en</language>
    <item>
      <title>Strapi and Next.js: a headless architecture that holds up</title>
      <dc:creator>Marius</dc:creator>
      <pubDate>Sun, 28 Jun 2026 13:03:20 +0000</pubDate>
      <link>https://dev.to/riusmax/strapi-and-nextjs-a-headless-architecture-that-holds-up-52k1</link>
      <guid>https://dev.to/riusmax/strapi-and-nextjs-a-headless-architecture-that-holds-up-52k1</guid>
      <description>&lt;p&gt;"Headless" has turned into a sales pitch. People slap it on just about any web project, as if decoupling content from its display were always progress. Most of the time it isn't: you add servers, points of failure and hours of ops for a blog that three Markdown files would have served without complaint. But there are cases where decoupling isn't a fashion, it's just the right answer. A client project I built the architecture for is a good example.&lt;/p&gt;

&lt;p&gt;The need was twofold from the start. On one side a multilingual public site that showcases the records, one that has to load fast and rank properly. On the other an admin interface, the "manager", where the team runs the business data. Two uses, two audiences, two update rhythms. The real question wasn't "headless or not", it was this: how do these two applications share the same data without stepping on each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  One backend, two frontends
&lt;/h2&gt;

&lt;p&gt;The answer fits in a sentence: a single backend, two separate Next.js frontends. Strapi plays the headless CMS, PostgreSQL stores everything, and both apps consume the same API. The public site reads the records, the content pages and their translations. The manager reads and writes the business data. Same source of truth, two consumers that don't have to know about each other.&lt;/p&gt;

&lt;p&gt;What this avoids is duplicating the model. If the definition of a "record" lived in two places, once in the site and once in the manager, you'd have to maintain it twice. And one day the two versions drift apart, always at the worst moment. Here the model is defined once in Strapi, both frontends inherit it. You add a field, it shows up in the API, both apps can read it. Done.&lt;/p&gt;

&lt;p&gt;On the Next side, reading that data from a Server Component stays straightforward. No heavy client, a &lt;code&gt;fetch&lt;/code&gt; and Strapi's JSON response.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/[locale]/records/page.tsx (Server Component)&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;getRecords&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&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="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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&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;STRAPI_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/records?locale=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;populate=photos`&lt;/span&gt;&lt;span class="p"&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="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;process&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;STRAPI_TOKEN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;revalidate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&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;res&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="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Strapi responded &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;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="kd"&gt;const&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="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="k"&gt;return&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;revalidate&lt;/code&gt; caches the records on the Next side and refreshes them in the background: the visitor never pays for the round trip to Strapi in real time, and the editor sees their change appear without anyone rebuilding a thing. The &lt;code&gt;if (!res.ok)&lt;/code&gt; isn't decoration. A remote API goes down, returns a 500, changes its schema the day of a migration. Better to throw a clear error than to quietly serve a half-empty page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Editing without going through the developer
&lt;/h2&gt;

&lt;p&gt;The second win is less technical, but it's the one that changes daily life: the client's team edits their content without calling me. Strapi generates an admin interface from the data model. Adding a record, fixing a description, publishing a translation, all of it happens in the back office, not in a pull request.&lt;/p&gt;

&lt;p&gt;For a developer, that's almost counter-intuitive. We like keeping content in versioned files, tidy Markdown in the repo, because it's clean and it goes through code review. Except the day a non-technical team has to publish several times a week, the repo becomes a bottleneck, and you become one along with it. Strapi puts that power in their hands and takes me out of the critical path. That's exactly what I want: for nobody to need me to change a comma.&lt;/p&gt;

&lt;p&gt;The multilingual side follows the same logic. Strapi's i18n plugin handles translations at the record level, each record exists in several languages, and the frontend asks for the right one with a plain &lt;code&gt;?locale=&lt;/code&gt;. The API carries the multilingual logic, the site's code only ever requests the current language.&lt;/p&gt;

&lt;h2&gt;
  
  
  API-first, the long-term bet
&lt;/h2&gt;

&lt;p&gt;Third reason, more strategic: everything goes through the API. Today it's two web frontends. If tomorrow the client wants a mobile app for their teams in the field, or a portal aimed at a different audience, it hits the same API and rebuilds nothing on the data side.&lt;/p&gt;

&lt;p&gt;I say this carefully, because it's the argument that gets oversold the most. "API-first, you'll be able to plug anything in later": that "later" often never comes, and you've paid the complexity up front for a future that doesn't show. The difference on this project is that the second consumer existed from day one. The manager wasn't a roadmap hypothesis, it was in scope. The API wasn't serving a need imagined "just in case", it was serving two real consumers right away. That's the whole nuance between an architecture that anticipates a real need and one that complicates itself for the pleasure of the diagram on the whiteboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  What decoupling actually costs
&lt;/h2&gt;

&lt;p&gt;Now the honest part, the one the articles selling headless forget to mention. Decoupling means multiplying the moving parts. Instead of one application to deploy and watch, you've got four that have to run together: Strapi, PostgreSQL, the public frontend, the manager frontend, not counting the hosting and the backups for all of it. Each part has its logs, its updates, its own way of falling over. The ops surface is nothing like a monolith's.&lt;/p&gt;

&lt;p&gt;Authentication and permissions become a topic of their own. The public site reads anonymously, or through a tightly locked read-only token. The manager writes, so it needs real auth, roles, per-collection permissions. Strapi provides the machinery, but configuring it correctly, closing what should be closed and checking that a public token can't write, is work that simply doesn't exist in a monolithic app where access is controlled in the same place as rendering.&lt;/p&gt;

&lt;p&gt;i18n adds its own layer. Handling languages at the API level is convenient, but it means thinking about the fallback when a translation is missing, keeping records in sync across languages, and testing each path in each locale. Nothing insurmountable, but these are hours that pile up and that you never bill enough for.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I wouldn't do it
&lt;/h2&gt;

&lt;p&gt;If someone had come to me for a five-page brochure site with a contact form, I'd never have reached for Strapi. A monolithic Next with content in files would have done the job, or flat-out a WordPress if the client wants to edit it themselves, for a fraction of the cost and the maintenance. Headless doesn't justify itself by the elegance of the diagram. It justifies itself by a concrete need: several frontends, a content team that has to be autonomous, or an API meant to last and grow. Tick at least one of those boxes and decoupling does you a real service. Tick none and you've bought yourself a backend to maintain for nothing.&lt;/p&gt;

&lt;p&gt;This project ticked two boxes out of three from day one, two frontends and a team that edits. That's why I'd build the exact same architecture again without hesitating. And this blog you're reading? Precisely not. These articles are plain MDX files in the portfolio repo, no Strapi, no Postgres behind them. The right architecture isn't the most impressive one, it's the one that fits the need. And a personal blog's need fits in a folder of files.&lt;/p&gt;

&lt;p&gt;If you're torn between a headless CMS and something simpler for your project, &lt;a href="https://sergent.dev/contact" rel="noopener noreferrer"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>strapi</category>
      <category>nextjs</category>
      <category>headless</category>
      <category>cms</category>
    </item>
    <item>
      <title>Securing a contact form: what most people forget</title>
      <dc:creator>Marius</dc:creator>
      <pubDate>Sun, 28 Jun 2026 13:02:46 +0000</pubDate>
      <link>https://dev.to/riusmax/securing-a-contact-form-what-most-people-forget-3kp2</link>
      <guid>https://dev.to/riusmax/securing-a-contact-form-what-most-people-forget-3kp2</guid>
      <description>&lt;p&gt;A contact form is often the last thing you build, knocked out quickly, because "it's just a form." Three fields, a button, an email goes out. Except behind that button sits a public POST route, open to the internet, that triggers a server-side action: sending an email, through a service billed by usage. A target, in other words. Not the juiciest one on the web, but a target all the same, and far more exposed than the "about" page sitting right next to it.&lt;/p&gt;

&lt;p&gt;This week I audited the one on this portfolio. I found a wide-open door that I'd installed myself months earlier, without realising it. I'll come back to it, it's the heart of the article. Before that, the point I want to land: a form's security doesn't fit in a single checkbox. It's a stack of small defences that, taken one by one, look trivial, and that hold together as a whole. The link that gives way, in my experience, is almost always a dev shortcut someone forgot to remove.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reCAPTCHA widget protects nothing on its own
&lt;/h2&gt;

&lt;p&gt;reCAPTCHA v3 has become a reflex. You add the provider on the client, the little badge shows up in the bottom right, and you feel covered. It's a comfortable illusion. The client widget generates a token, nothing more. As long as nobody verifies that token server-side, it's worthless: a bot doesn't even need to load Google's script, it posts straight to your endpoint with a bogus &lt;code&gt;recaptchaToken&lt;/code&gt; field, or an empty one, or a copied one.&lt;/p&gt;

&lt;p&gt;The real barrier is on the server. When the request comes in, you call Google's verification API back with your secret key, and you read what it returns: the &lt;code&gt;success&lt;/code&gt;, the &lt;code&gt;score&lt;/code&gt; (between 0 and 1, the higher it is the more likely the request is human), and the expected &lt;code&gt;action&lt;/code&gt;. All three have to check out.&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifyRecaptchaV3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expectedAction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contact_form&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;secretKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&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;RECAPTCHA_SECRET_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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;secretKey&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;isValid&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="na"&gt;score&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;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://www.google.com/recaptcha/api/siteverify&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/x-www-form-urlencoded&lt;/span&gt;&lt;span class="dl"&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="s2"&gt;`secret=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;secretKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;response=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="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;isValid&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;success&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;expectedAction&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&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="nx"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&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 0.5 threshold is the tuning knob. Too high and you block real visitors who are a bit jumpy on the click. Too low and you let bots through. 0.5 is a sensible middle for a brochure site. And checking the &lt;code&gt;action&lt;/code&gt; matters as much as the score: it guarantees the token was actually issued for your contact form, not lifted from another page on the site.&lt;/p&gt;

&lt;h2&gt;
  
  
  The story of the magic token
&lt;/h2&gt;

&lt;p&gt;Here's the open door. While building the form, I needed to test sending without dealing with reCAPTCHA on every local submission. Classic. So I'd added a small bypass: if the token received was the string &lt;code&gt;"test_token_no_recaptcha"&lt;/code&gt;, the server treated the captcha as valid and moved on. Handy in dev. The kind of crutch you promise yourself you'll remove before going to production.&lt;/p&gt;

&lt;p&gt;Except it stayed. And not only that: the front-end was sending it all by itself. The client-side logic said, in effect, "if reCAPTCHA isn't available, send the fallback token anyway." And reCAPTCHA is unavailable far more often than you'd think: an ad blocker filtering Google's script, a privacy extension, a public key missing from an environment, a plain network hiccup at load time. In every one of those cases, a perfectly legitimate visitor's browser fell back to the magic token. And anyone glancing at the client code for two minutes saw the string sitting there in plain text.&lt;/p&gt;

&lt;p&gt;The result: an attacker could post to &lt;code&gt;/api/contact&lt;/code&gt; as much as they liked, without ever solving a single captcha, just by sending &lt;code&gt;"test_token_no_recaptcha"&lt;/code&gt;. The entire anti-bot layer, defeated by one line I'd written to make my own life easier six months earlier.&lt;/p&gt;

&lt;p&gt;The fix is a reversal of logic. If reCAPTCHA isn't available, you don't bypass, you block. The server no longer knows any special token. The client, for its part, refuses to call the API and shows a clear message instead of inventing a free pass.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;executeRecaptcha&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;recaptchaToken&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;executeRecaptcha&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contact_form&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// reCAPTCHA unavailable: block, don't send a fallback token&lt;/span&gt;
  &lt;span class="nf"&gt;setSubmitError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Security check unavailable. Please refresh the page.&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson goes well beyond reCAPTCHA. A dev bypass left in production is a back door you installed yourself, documented, and then forgot existed. The worst vulnerabilities are almost never sophisticated attacks. They're convenience shortcuts nobody got around to removing. When I &lt;code&gt;grep&lt;/code&gt; a project before a production release, &lt;code&gt;test&lt;/code&gt;, &lt;code&gt;debug&lt;/code&gt;, &lt;code&gt;bypass&lt;/code&gt;, &lt;code&gt;skip&lt;/code&gt; and &lt;code&gt;TODO&lt;/code&gt; are the first words I look for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate-limit first, before you even think
&lt;/h2&gt;

&lt;p&gt;Say the captcha is solid. There's still a volume problem. With no cap, nothing stops someone from hammering your endpoint: a few thousand POSTs, and it's your email-sending quota draining away (Resend, in my case, bills by usage), your inbox overflowing, or your server spending its time calling Google's API for nothing.&lt;/p&gt;

&lt;p&gt;A rate limit fixes this, and it has to run first of all, before validation, before the reCAPTCHA call, before any expensive operation. The idea: count requests per IP over a sliding window, and refuse beyond a threshold. For a brochure site, you don't need Redis or Upstash. An in-memory &lt;code&gt;Map&lt;/code&gt; is plenty, five requests per ten-minute window per IP.&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;rateLimitMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&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="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RATE_LIMIT_MAX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&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;RATE_LIMIT_WINDOW_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 10 minutes&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkRateLimit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&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;boolean&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;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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;windowStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;RATE_LIMIT_WINDOW_MS&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;recent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rateLimitMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&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="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&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;t&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;windowStart&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;recent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;RATE_LIMIT_MAX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;rateLimitMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&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;// limit reached&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;rateLimitMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that make the difference. The client's IP, behind a reverse proxy (and everyone is, in practice), isn't read off the socket: it's in the &lt;code&gt;x-forwarded-for&lt;/code&gt; header, the first link in the list. If you take the direct connection's IP, you rate-limit your own proxy, which means everyone at once. Second point, yes, an in-memory &lt;code&gt;Map&lt;/code&gt; empties on every redeploy, and doesn't survive across multiple instances. For a single-instance portfolio with modest traffic, that's a trade-off I'll take without blinking. Pulling in Redis for this would be engineering for the fun of it. On a high-traffic or multi-instance app, that's where the shared store becomes non-negotiable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Never trust what comes in
&lt;/h2&gt;

&lt;p&gt;Anything arriving from a form is hostile by default, until proven otherwise. In practice, two moves.&lt;/p&gt;

&lt;p&gt;First, validate the shape. A Zod schema describes what you accept (minimum lengths, email format, required fields), and you reject everything else before touching anything. It's fail-fast applied directly: a non-conforming piece of data stops at the door, it doesn't wander into your business logic.&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;contactSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;recaptchaToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, escape on the way out. My server sends me an HTML notification email for every message, with the name and the content interpolated into it. If I drop those fields raw into the HTML, I open up an injection: someone puts an &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; tag or an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; in the "message" field, and my notification email ends up with a fake link or an image pulled from anywhere. Not the drama of the century, but it's my inbox turning into an attack surface. The countermeasure is trivial, escape the five characters that matter before any interpolation.&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;function&lt;/span&gt; &lt;span class="nf"&gt;escapeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&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="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;amp;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;amp;&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;lt;&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;gt;&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/"/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;quot;&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/'/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;#39;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Validate what comes in, escape what goes out. Same rule as old as the web, applied to a channel we often forget to treat as one: an email rendered as HTML is a web page like any other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secrets have no business in the browser
&lt;/h2&gt;

&lt;p&gt;Last point, and this one's more a plumbing trap than a vulnerability as such. With Next.js, any environment variable prefixed &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; gets inlined into the JavaScript bundle shipped to the client. Visible to anyone who opens the dev tools. The reCAPTCHA public key is made for exactly that, no problem. But the reCAPTCHA secret key and the email-sending API key have no business there. They're read at runtime, server-side only, and stay without the &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix. Mixing up the two means publishing your secret key for all to see, and handing someone the ability to send emails in your name. The kind of mistake a secret scan in CI catches, and that's exactly why you put one there.&lt;/p&gt;

&lt;p&gt;None of these layers, on its own, makes a form "safe." The server captcha without rate-limiting lets the flood through. The rate limit without validation lets the junk through. The validation without escaping lets the injection through. It's the stacking that does the work, and the hard part isn't writing them, it's thinking of all of them, and not sabotaging the whole thing with a forgotten dev crutch. The most ordinary form on a site is often the one that most deserves an audit, precisely because nobody's looking at it.&lt;/p&gt;

&lt;p&gt;If you want an outside eye on yours, &lt;a href="https://sergent.dev/contact" rel="noopener noreferrer"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>securite</category>
      <category>nextjs</category>
      <category>recaptcha</category>
      <category>rgpd</category>
    </item>
    <item>
      <title>React Native or PWA: how to choose for a business app</title>
      <dc:creator>Marius</dc:creator>
      <pubDate>Sun, 28 Jun 2026 13:02:11 +0000</pubDate>
      <link>https://dev.to/riusmax/react-native-or-pwa-how-to-choose-for-a-business-app-4cak</link>
      <guid>https://dev.to/riusmax/react-native-or-pwa-how-to-choose-for-a-business-app-4cak</guid>
      <description>&lt;p&gt;People almost always ask me the question backwards. "Would you build this in React Native or as a PWA?", as if you had to pick a side once and for all and defend one stack against the other. I do both, and because I do both, I have no loyalty either way. The real question is never which one is better. It's what your app needs to do, for whom, and how much you can spend on it. Once you've answered that, the choice mostly makes itself. Three or four criteria settle it, and they settle it fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardware is the first thing that decides
&lt;/h2&gt;

&lt;p&gt;If your app has to talk to hardware, the discussion is often over before it starts. An NFC badge reader, a Bluetooth sensor, a connected scale, a payment terminal, a camera you control precisely (focus, exposure, raw stream), accurate motion sensors. The moment you go down to that level, the web shows its limits, and on the iPhone it's a wall.&lt;/p&gt;

&lt;p&gt;Concretely: Web Bluetooth, Web NFC, WebUSB and Web Serial aren't supported on iOS. Since every browser on the iPhone runs on Safari's engine, this isn't a case of "Chrome on iOS will handle it". No iOS browser has access, and Apple shows no sign of wanting to change that. Code says it more clearly than a long paragraph:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// On Android, in Chrome: navigator.bluetooth exists, you scan your sensor.&lt;/span&gt;
&lt;span class="c1"&gt;// On the iPhone, in any browser: undefined. Door closed.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bluetooth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;navigator&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;device&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bluetooth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestDevice&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="cm"&gt;/* filters */&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// no pure-web fallback on iOS&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Android, a PWA can absolutely speak BLE to a device, and it works well. But if your app also has to run on the iPhone (and for most clients that's non-negotiable), you can't lean on an API that half your install base will never have. React Native goes through the native building blocks with battle-tested modules (BLE, NFC, advanced camera) on both platforms. When hardware sits at the heart of the need, React Native or going fully native wins, and there isn't really a debate.&lt;/p&gt;

&lt;p&gt;One honest caveat: the "simple" camera (snap a photo, scan a QR code) works fine on the web, on iOS as everywhere else. It's plugged-in hardware, low-level protocols and fine-grained sensor control that tip you toward native.&lt;/p&gt;

&lt;h2&gt;
  
  
  Serious offline, the other thing that doesn't forgive
&lt;/h2&gt;

&lt;p&gt;There's offline and there's offline. A PWA handles "I'm checking my data on the subway with no signal" perfectly well: a service worker, a cache, a bit of IndexedDB, and the app responds offline. For a lot of business apps, that's enough.&lt;/p&gt;

&lt;p&gt;Then there's the other offline. The one where there's never any network, where the app is the only software running, where it has to take hours of use without ever calling back to a server. I built Prolog on exactly that: a React Native app that talks over direct WiFi to a drilling probe and runs an embedded machine learning model, on a ruggedised Android tablet, next to a drill rig, with no internet connection whatsoever. The model lives in the app, inference happens on the device, the measurements are written locally. A PWA would not have met that spec, if only because on iOS mechanisms like background sync aren't there, and running an embedded ML model that talks to a sensor over a direct link is not browser territory.&lt;/p&gt;

&lt;p&gt;So if your app lives in the field, on a dedicated device, and it has to be reliable without a network and keep its grip on the system, lean React Native without hesitating. If your "offline" comes down to convenience caching for a desk user or a salesperson on the road, a PWA does the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Distribution and cost, where the PWA takes the lead back
&lt;/h2&gt;

&lt;p&gt;We've covered the cases where the web stalls. The rest of the time it's the one in front, by a wide margin.&lt;/p&gt;

&lt;p&gt;A PWA is a URL. You deploy, it's live, everyone reaches it from their browser, and your update is instant for all of them on the next reload. No App Store submission, no review that holds you up three days over a comma in the description, no per-platform build to sign, no paid developer account to renew every year, no version 1.2.4 that half your users will never install. A single web codebase that reaches mobile, tablet and the desktop. To get started, and above all to maintain over time, it's clearly cheaper and lighter.&lt;/p&gt;

&lt;p&gt;React Native asks for more investment, and it's worth saying plainly. You've got two stores to manage, builds to produce and sign, sometimes native code to write when a module doesn't cover your need, and the stores' review cycle sitting between you and your users. It's all very manageable (the tooling around Expo has smoothed a lot of this out), but it stays a real cost, in time and money, that a PWA doesn't carry. If reach, upfront budget and update simplicity are your priorities, and nothing in the list above forces you to go native, start with a PWA. You can always build a native app later, when a genuine need justifies it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Perceived experience, more often a fake argument than a real one
&lt;/h2&gt;

&lt;p&gt;This is the argument people throw at me to justify going native "by default": it'll be smoother, prettier, more "real". Sometimes that's true. On complex gestures, animations that hug the system, deep integration (native sharing, widgets, fine-grained interactions), React Native is ahead, and you can feel it.&lt;/p&gt;

&lt;p&gt;But look honestly at what your business app actually does. Forms, lists, dashboards, browsing, data entry, filters. For 80% of those screens, a well-made PWA is indistinguishable from a native app to the user. Nobody, in real life, pulls out their phone to guess whether the app they log site data into is "native or not". They want it to open fast, not crash, and respond. The "native because it's more professional" reflex costs a lot for a gain the end user never perceives. I'd advise against it as long as no real technical criterion calls for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  iOS, the stone in the PWA's shoe
&lt;/h2&gt;

&lt;p&gt;Let's be precise, because this is where the disappointments hide. The PWA's weak spot isn't "the web", it's iOS. On Android the PWA experience is solid: an install prompt at the right moment, access to a fair bit of hardware, notifications. On the iPhone it's rougher.&lt;/p&gt;

&lt;p&gt;Push notifications finally exist on iOS as of Safari 16.4, but with one condition: the user has to have added the PWA to their home screen. As long as your site lives in a Safari tab, no push, full stop. And that home-screen install is precisely a manual gesture: no automatic install banner like on Android, it's up to the user to dig it out of the share menu. Which means you have to explain it to them, and a good share of people never will. Add the missing hardware APIs already mentioned and some background mechanisms, and you get the real face of the decision: a PWA is excellent everywhere, and a little hemmed in on the iPhone. If your audience is heavily on iOS and you need reliable notifications or a strong home-screen presence, that friction has to weigh in your call.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I decide, in practice
&lt;/h2&gt;

&lt;p&gt;My default, concretely: I go PWA when reach, cost and simplicity rule, and nothing demands hardware or hard offline. I switch to React Native the moment I need to talk to a sensor, hold up without a network on a dedicated device, or deliver a genuinely native experience that the use case justifies. And I do sometimes build both on the same project: a PWA for the general public, reaching everyone with no friction, and a native app for the field operators, who need the hardware and the offline. That's not a wobbly compromise, it's often the right answer, each tool on the ground where it's unbeatable.&lt;/p&gt;

&lt;p&gt;The trap is picking the tech before you've looked at the need. Start by listing what your app actually has to do, and the answer is already half written.&lt;/p&gt;

&lt;p&gt;Stuck on your own project? &lt;a href="https://sergent.dev/contact" rel="noopener noreferrer"&gt;Let's talk&lt;/a&gt;, and I'll tell you honestly which way I'd go.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>pwa</category>
      <category>mobile</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Next.js 16 in production: the traps no tutorial shows you</title>
      <dc:creator>Marius</dc:creator>
      <pubDate>Sun, 28 Jun 2026 13:01:37 +0000</pubDate>
      <link>https://dev.to/riusmax/nextjs-16-in-production-the-traps-no-tutorial-shows-you-5gmn</link>
      <guid>https://dev.to/riusmax/nextjs-16-in-production-the-traps-no-tutorial-shows-you-5gmn</guid>
      <description>&lt;p&gt;Next.js 16 is a good release, I want to say that before spending the rest of the article griping about it. The App Router has matured, Turbopack became the default, build times have melted away. On paper, all is well. And yet, redeploying this portfolio this week (Next 16, self-hosted, Docker image, no Vercel to cushion the corners), I walked into four walls the tutorials never show. What they have in common is that none of them throws an error. The build goes green. That, precisely, is the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The middleware is called proxy.ts now
&lt;/h2&gt;

&lt;p&gt;First thing that throws you when you land on Next 16: the middleware file isn't called &lt;code&gt;middleware.ts&lt;/code&gt; anymore, it's called &lt;code&gt;proxy.ts&lt;/code&gt;. It's a convention rename, not an option. If you show up with your Next 14 or 15 reflexes, you go looking for a file that no longer exists.&lt;/p&gt;

&lt;p&gt;The trap isn't in the code, it's in the tools that aren't aware of it yet. An automated audit of my own repo flagged it to me as a bug: "non-standard &lt;code&gt;proxy.ts&lt;/code&gt; file, rename to &lt;code&gt;middleware.ts&lt;/code&gt;". A complete false positive. Had I listened to it, I'd have broken the site's whole i18n routing. The proof that the convention is the right one is live: a request to &lt;code&gt;/&lt;/code&gt; answers with a 307 to &lt;code&gt;/fr&lt;/code&gt;. That's exactly the job of the next-intl middleware, and it runs, in a file named &lt;code&gt;proxy.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="c1"&gt;// src/proxy.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;createMiddleware&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;next-intl/middleware&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;routing&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;./i18n/routing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;createMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;routing&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;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;matcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/(fr|en)/:path*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/((?!api|_next|_vercel|docs|.*&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;..*).*)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson isn't "proxy.ts is weird". It's more like: when a linter or an audit flags a framework convention as an anomaly, go check your version's docs before you "fix" it. On a rename this recent, the tool is often a step behind the framework.&lt;/p&gt;

&lt;h2&gt;
  
  
  generateStaticParams doesn't get the locale from its parent
&lt;/h2&gt;

&lt;p&gt;The second trap cost me the most brainpower, and it's genuinely sneaky. My article route lives in &lt;code&gt;[locale]/blog/[slug]&lt;/code&gt;. Two nested dynamic segments: the language first, the slug next. To generate the pages at build time, the child's &lt;code&gt;generateStaticParams&lt;/code&gt; needs to know the locale of the parent segment.&lt;/p&gt;

&lt;p&gt;Except that in Next 16, that parent &lt;code&gt;params.locale&lt;/code&gt; doesn't propagate reliably into the child's &lt;code&gt;generateStaticParams&lt;/code&gt;. Concretely, I was calling my listing function with an &lt;code&gt;undefined&lt;/code&gt; locale. And &lt;code&gt;getAllPosts(undefined)&lt;/code&gt; doesn't crash, it politely returns an empty array. Zero slugs generated. Since I'm set on pure static, I also have &lt;code&gt;dynamicParams = false&lt;/code&gt;, which means "any URL I haven't pre-generated doesn't exist". You can see where this is going: zero pre-generated pages plus zero on-the-fly rendering makes an entire blog return 404.&lt;/p&gt;

&lt;p&gt;The fix is to stop waiting on the parent locale and enumerate it myself. I loop over the locales declared in my routing and return the complete &lt;code&gt;{ locale, slug }&lt;/code&gt; pairs, spelled out in full.&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="c1"&gt;// src/app/[locale]/blog/[slug]/page.tsx&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;routing&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;@/i18n/routing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getAllPosts&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;@/lib/blog&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;const&lt;/span&gt; &lt;span class="nx"&gt;dynamicParams&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;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;generateStaticParams&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;allParams&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;locale&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="nl"&gt;slug&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="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &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;locale&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;routing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locales&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;posts&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;getAllPosts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;for &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;post&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;allParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&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="nx"&gt;allParams&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;Nothing magic once you've understood it. The treacherous detail is that the "naive" code (the one that trusts the parent's &lt;code&gt;params.locale&lt;/code&gt;) compiles without a hitch. It only breaks at render time, and only for real in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  A green build proves nothing
&lt;/h2&gt;

&lt;p&gt;Here's the real subject of the article, the one that wraps the two before it. When &lt;code&gt;generateStaticParams&lt;/code&gt; returns an empty array, Next doesn't treat that as an error. It concludes there's nothing to pre-render, and with &lt;code&gt;dynamicParams = false&lt;/code&gt;, it prerenders the page as a 404. No warning. No exit code of 1. The pipeline stays all green. Same story for any page that calls &lt;code&gt;notFound()&lt;/code&gt; in a case you hadn't anticipated: it gets frozen as a 404 at build, silently.&lt;/p&gt;

&lt;p&gt;I lived it this week, and it's maddening. A blog article deployed, live, a clean 404, while &lt;code&gt;next build&lt;/code&gt; had passed all its checks and the image had already shipped to production. No signal anywhere. You discover the broken page like any random visitor, by clicking on your own link.&lt;/p&gt;

&lt;p&gt;It's counterintuitive because we've all learned to trust the green light. The build compiles, so "it works". No. The build tells you your code is syntactically valid and your types hold. It tells you nothing about what your pages actually return when a human asks for them. A prerendered 404, to the compiler, is a perfectly valid page. It just has the wrong content.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: test the server that actually runs
&lt;/h2&gt;

&lt;p&gt;The only thing that catches these silent 404s is to launch the real server and watch what it answers. And there, a second surprise specific to self-hosting: with &lt;code&gt;output: "standalone"&lt;/code&gt;, the &lt;code&gt;next start&lt;/code&gt; command doesn't serve your app. It's not a bug, it's just no longer the right command. You have to start the standalone server by hand, copying &lt;code&gt;.next/static&lt;/code&gt; and &lt;code&gt;public&lt;/code&gt; alongside the way the Docker image does.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; .next/static .next/standalone/.next/static
&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; public .next/standalone/public
node .next/standalone/server.js
&lt;span class="c"&gt;# then, in another terminal, check the key pages&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; http://localhost:3000/fr/blog/my-article
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thirty seconds, and the 404 jumps out at you before it ships to production. I turned it into a small smoke test I run before every deploy, which boots the standalone server and checks with &lt;code&gt;curl&lt;/code&gt; that my important URLs do answer 200. It's not glamorous. It's just the only way I know to tell "the code compiles" apart from "the site works". For the full mechanics of a self-hosted deploy (standalone image, registry, reverse proxy), I went into detail in &lt;a href="https://sergent.dev/blog/heberger-nextjs-vps-docker" rel="noopener noreferrer"&gt;my article on hosting a Next.js app on a VPS&lt;/a&gt;; here I mainly wanted to point at what a green build hides from you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two last stones in the shoe
&lt;/h2&gt;

&lt;p&gt;While we're on Turbopack, it comes with its own subtlety when you write in MDX. Under Turbopack (the default in 16), remark and rehype plugins only receive serialisable options. You can pass them strings, objects, arrays, but not a function. If your plugin config worked with a callback back in the Webpack days, it'll let you down with no clear explanation. You have to rethink the config as pure declarative.&lt;/p&gt;

&lt;p&gt;And a last one, which isn't specific to Next but bites in exactly the same spot: changing a dependency without regenerating &lt;code&gt;yarn.lock&lt;/code&gt;. Locally, &lt;code&gt;yarn install&lt;/code&gt; patches the gap on its own and you see nothing. In CI and in the Docker build, I install with &lt;code&gt;--immutable&lt;/code&gt;, which flatly refuses to touch the lockfile. The slightest mismatch between &lt;code&gt;package.json&lt;/code&gt; and &lt;code&gt;yarn.lock&lt;/code&gt; fails the install, so the build, so the deploy. Once again, it passes locally and breaks where you're not watching live.&lt;/p&gt;

&lt;p&gt;The thread running through this week is just that: on a managed platform, a per-branch preview might have shoved the 404 under your nose before the merge. Self-hosted, no one looks in your place. The only thing I still grant my trust to isn't GitHub's green badge, it's the standalone server answering 200 on my machine before the image takes off. A build that compiles talks to you about your code. It will never say a word about what your visitors are going to see.&lt;/p&gt;

&lt;p&gt;If you deploy self-hosted Next.js and you want an outside eye before it ships to production, &lt;a href="https://sergent.dev/contact" rel="noopener noreferrer"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>production</category>
      <category>deployment</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Bringing AI agents into your development workflow</title>
      <dc:creator>Marius</dc:creator>
      <pubDate>Sun, 28 Jun 2026 13:01:03 +0000</pubDate>
      <link>https://dev.to/riusmax/bringing-ai-agents-into-your-development-workflow-mia</link>
      <guid>https://dev.to/riusmax/bringing-ai-agents-into-your-development-workflow-mia</guid>
      <description>&lt;p&gt;Everyone has an opinion on AI replacing developers. Mine fits in one sentence: I've never seen an agent replace a dev, I've seen agents tear through work fast and well when it's framed precisely, and produce nonsense the moment you let them loose in the wild. That's less of a sell than a demo where the AI spits out an app in thirty seconds. It's also far more useful day to day.&lt;/p&gt;

&lt;p&gt;I stopped typing my prompts one at a time into a chat window a while ago. I built a small chain of agents that split the work across a project, and it's by running it on real deliverables (production code, audits, this blog) that I learned where it holds and where it breaks. Nothing magic in there. Mostly plumbing and a few rules I picked up by getting burned.&lt;/p&gt;

&lt;h2&gt;
  
  
  An orchestrator and specialists, not one lone genius
&lt;/h2&gt;

&lt;p&gt;The starting mistake is wanting a single agent that does everything. You ask it to architect, code the backend, build the frontend, write the tests and draft the docs in one go, and you get something mediocre across the board. A generalist in a hurry.&lt;/p&gt;

&lt;p&gt;I split it differently. There's an orchestrator that takes the brief, breaks it down and hands out the work. Behind it, specialised agents each with a clear role: one that writes the spec and settles the architecture decisions, one for the backend, one for the frontend, one for containerisation and deployment, one that reviews the code and writes the tests, one that writes prose. The orchestrator doesn't code. It coordinates and it checks. That's the whole point of it: someone whose only job is to hold the big picture and not trust the others blindly.&lt;/p&gt;

&lt;p&gt;Two settings make the difference in practice. First the reasoning budget, what I call the effort. An architecture decision deserves an agent that thinks long and wide, because a bad foundation rots everything that comes after it. Writing a Dockerfile is a known pattern, seen a thousand times, that calls for no particular depth. Giving both the same thinking budget wastes time and tokens on the second. So I modulate: a lot for the architect, almost nothing for the mechanical tasks. Then the tool scope. The writer doesn't need a browser or database access. The agent that deploys has no business poking around in my mockups. Restricting what each agent can touch shrinks the context it loads, keeps it focused on its job, and incidentally limits the damage if it goes off the rails.&lt;/p&gt;

&lt;p&gt;The gain isn't only speed. It's quality through separation. An agent that thinks about a single thing does it better than one that's juggling five.&lt;/p&gt;

&lt;h2&gt;
  
  
  An agent is only as good as its framing
&lt;/h2&gt;

&lt;p&gt;Here's the lesson I'd get tattooed if I could: an agent is good exactly to the degree that its task is well defined. Well briefed, constrained by a clear spec, it's fast and precise. Badly briefed, it doesn't stop, it doesn't tell you "I don't have enough to go on". It fills the gaps. It invents a plausible assumption and runs with it, completely unfazed.&lt;/p&gt;

&lt;p&gt;That's why I refuse to start any code before a spec is validated. Not out of dogma. Because the spec is the frame that stops the agent from embroidering. When I skip that step "to move faster", I pay for it twice over afterwards, untangling what the agent assumed in my place. Half a page of clear spec beats three paragraphs of fuzzy intentions and an agent left to its imagination. The model isn't to blame. You ask it to fill a void, it fills it, that's exactly what it was trained to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify, especially when the agent looks sure of itself
&lt;/h2&gt;

&lt;p&gt;The most dangerous trap isn't the agent that's visibly wrong. It's the one that's wrong with confidence, in a clean format, without the faintest sign of hesitation.&lt;/p&gt;

&lt;p&gt;A real case that stuck with me. An agent tasked with auditing my own code recommended, very seriously, that I rename a configuration file. Specifically, to put &lt;code&gt;proxy.ts&lt;/code&gt; back to &lt;code&gt;middleware.ts&lt;/code&gt;. Except that under Next.js 16 the middleware file is now called &lt;code&gt;proxy.ts&lt;/code&gt;, it's the framework's new convention, and my multilingual routing depends on it. Had I applied the recommendation, I'd have broken the site. The agent took a recent convention for a bug, because its intuition was still running on the earlier versions. Flawless output, confident tone, wrong conclusion.&lt;/p&gt;

&lt;p&gt;Another time, an agent miscounted entries in a dataset and produced a wrong total with the same calm it would have had if it were right. Nothing in the form gives the error away. That's the real danger: the false answer looks exactly like the right one, point for point.&lt;/p&gt;

&lt;p&gt;In both cases, what saved the day was the verification layer. The orchestrator doesn't pass a high-impact claim along without testing it, and I personally re-read anything touching decisions that can break something. The rule I apply: the heavier the consequence of a recommendation, the more I check it with my own eyes. Renaming a file that can take down the routing gets checked by hand, full stop. An agent's fluency is not proof. It's the opposite of proof, in fact, because it lulls your vigilance to sleep.&lt;/p&gt;

&lt;h2&gt;
  
  
  "It compiles" doesn't mean "it works"
&lt;/h2&gt;

&lt;p&gt;When an agent hands you code that passes the build, the reflex is to tick the box. The pipeline is green, so it's fine. No.&lt;/p&gt;

&lt;p&gt;A green build tells you one thing and one thing only: your code is syntactically valid and your types hold. It says nothing about what your pages return when a human actually opens them. I've had a blog article deployed, live, a full 404, while every check had passed. The compiler saw no problem with it, because to the compiler an error page is a perfectly valid page. It just has the wrong content. No agent that only looks at the code would have caught it. You have to start the real server and look at what it answers, with an actual test on the thing that's running.&lt;/p&gt;

&lt;p&gt;That's the safeguard nothing replaces: a human checking, and a test against the live application. Not a test the agent claims to have run in its head. A test that produces an observable answer, an HTTP code, a page on screen, a result you can point your finger at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reliability comes from the structure, not the model
&lt;/h2&gt;

&lt;p&gt;If I had to boil my conviction down to one idea, it would be this one, and it cuts against the prevailing talk. What makes a chain of agents reliable isn't the size of the model at the end of it. It's the structure around it: clear roles, ordered steps, a spec upstream, a review downstream, and a human keeping a hand on the consequential decisions. The big model helps, obviously. But a big model that's badly framed just produces more convincing falsehoods, which is worse.&lt;/p&gt;

&lt;p&gt;Orchestrated well, this machinery takes the drudgery off my plate and genuinely speeds me up. The repetitive work, the boilerplate, the first drafts, the proofreading passes, everything that used to tire me without making me think, I delegate. What I keep is the framing at the input and the judgement at the output. The two ends where there's actually something to decide.&lt;/p&gt;

&lt;p&gt;This article, you may have guessed, went through that chain. A writer agent produced a first draft from a brief I wrote, I re-read it, cut it, fixed it, and I'm the one signing it. The AI did the draft fast. The rest, the framing before and the control after, stays my job, and I don't see that changing any time soon.&lt;/p&gt;

&lt;p&gt;If you're building this kind of chain in your team and want a perspective that's already hit the walls, &lt;a href="https://sergent.dev/contact" rel="noopener noreferrer"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ia</category>
      <category>agents</category>
      <category>productivite</category>
      <category>workflow</category>
    </item>
    <item>
      <title>Leaving Vercel: hosting your Next.js app on a VPS</title>
      <dc:creator>Marius</dc:creator>
      <pubDate>Sun, 28 Jun 2026 13:00:28 +0000</pubDate>
      <link>https://dev.to/riusmax/leaving-vercel-hosting-your-nextjs-app-on-a-vps-4in4</link>
      <guid>https://dev.to/riusmax/leaving-vercel-hosting-your-nextjs-app-on-a-vps-4in4</guid>
      <description>&lt;p&gt;Vercel suited me for a long time. You push your code, thirty seconds later it's live with a valid certificate, a CDN and per-branch previews. To get a project off the ground, I don't know anything more comfortable. The trouble shows up later, once the project actually lives. The bill climbs with traffic and serverless functions, some proprietary features get awkward to reproduce elsewhere, and you end up not really knowing where or how your app runs. It's an excellent starting point, and a trap the moment you want to control your costs and your infrastructure.&lt;/p&gt;

&lt;p&gt;For this portfolio, like for several client projects, I went the other way. A VPS for a few euros a month, a Docker image, a reverse proxy, a homemade pipeline. The idea isn't to go back to the stone age of FTP deploys: I keep the "git push and it's live", but on a machine I control from end to end. Here's how it's wired, and the two or three spots where I got caught.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Docker image: everything rests on standalone mode
&lt;/h2&gt;

&lt;p&gt;The piece that changes everything is &lt;code&gt;output: "standalone"&lt;/code&gt; in &lt;code&gt;next.config.ts&lt;/code&gt;. At build time, Next traces exactly which files the runtime needs and copies them into &lt;code&gt;.next/standalone/&lt;/code&gt;. You go from an image of roughly 1 GB down to around 200 MB. Without it, you drag your whole &lt;code&gt;node_modules&lt;/code&gt; into your production container for nothing.&lt;/p&gt;

&lt;p&gt;The Dockerfile is multi-stage: one step to install dependencies, one to build, a last one that keeps only the bare minimum.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# deps: install dependencies (optimal Docker layer caching)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;deps&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json yarn.lock .yarnrc.yml ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;corepack &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; yarn &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--immutable&lt;/span&gt;

&lt;span class="c"&gt;# builder: build the app&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=deps /app/node_modules ./node_modules&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;corepack &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; yarn build

&lt;span class="c"&gt;# runner: final image, non-root&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runner&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;ENV&lt;/span&gt;&lt;span class="s"&gt; NODE_ENV=production&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;addgroup &lt;span class="nt"&gt;-g&lt;/span&gt; 1001 nodejs &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; adduser &lt;span class="nt"&gt;-u&lt;/span&gt; 1001 &lt;span class="nt"&gt;-G&lt;/span&gt; nodejs &lt;span class="nt"&gt;-S&lt;/span&gt; nextjs
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder /app/public ./public&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder --chown=nextjs:nodejs /app/.next/standalone ./&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; nextjs&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 3000&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things are worth pausing on. The final image runs as a non-root user (&lt;code&gt;nextjs:nodejs&lt;/code&gt;), because a web container running as root is a bad habit you rarely get away with for free. And you have to copy &lt;code&gt;public/&lt;/code&gt; and &lt;code&gt;.next/static/&lt;/code&gt; next to the standalone &lt;code&gt;server.js&lt;/code&gt; by hand: Next won't do it for you, and if you forget, your app boots but serves your pages with no CSS and no images.&lt;/p&gt;

&lt;h2&gt;
  
  
  Environment variables: build and runtime don't play on the same team
&lt;/h2&gt;

&lt;p&gt;This is the trap almost everyone falls into once. With Next, variables don't all behave the same way. The &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; ones are inlined at build time: they end up in plain text inside the JavaScript bundle shipped to the browser. So you have to pass them as &lt;code&gt;ARG&lt;/code&gt; to &lt;code&gt;docker build&lt;/code&gt;, otherwise they'll simply be empty on the client side. Server secrets, on the other hand (the email API key, the reCAPTCHA secret), are read at runtime and have no business being in the image. You inject them when the container starts, through an &lt;code&gt;env_file&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In practice, my production &lt;code&gt;docker-compose&lt;/code&gt; references a &lt;code&gt;.env.production&lt;/code&gt; that's never committed for the secrets, and receives the &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; values as &lt;code&gt;build.args&lt;/code&gt;. The rule I keep in mind: if a value has to be visible in the browser, it's baked at build time; if it has to stay secret, it arrives at runtime. Mix the two up and you get either an empty variable in production, or a secret key published in your bundle.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reverse proxy: automatic SSL, without touching a config file
&lt;/h2&gt;

&lt;p&gt;The container listens on port 3000, but I don't expose it on the host. Instead, it joins a shared Docker network with a reverse proxy that handles HTTPS and routing. Here I use Nginx Proxy Manager, and let me say it straight away: it's one choice among several, not an obligation. Caddy or Traefik do the same job perfectly well. If I pick Nginx Proxy Manager for this article, it's for how simple it is to demo: everything is driven from a graphical interface. You create a "Proxy Host", point &lt;code&gt;sergent.dev&lt;/code&gt; at the &lt;code&gt;portfolio&lt;/code&gt; container on port 3000, tick Let's Encrypt, and the certificate gets issued then renewed on its own. Routing and SSL are handled with a click, without ever opening a configuration file.&lt;/p&gt;

&lt;p&gt;That's also its limit. Caddy and Traefik lean more toward the infra-as-code philosophy: the config lives in a versioned, reproducible file that travels with your repo. For a server you set up once and leave running, NPM's graphical interface is unbeatable on comfort. For infrastructure you want to be able to recreate identically on command, I'd look at Caddy instead. Both approaches hold up; I mostly wanted you to know the brick is interchangeable.&lt;/p&gt;

&lt;p&gt;On the compose side, the service publishes no port on the host. It just joins the proxy's network.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.prod.yml (excerpt)&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;portfolio&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/&amp;lt;owner&amp;gt;/portfolio:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;portfolio&lt;/span&gt;
    &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.env.production&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;nginx-manager_default&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# shared network with the proxy&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nginx-manager_default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No application port is reachable directly from the internet. The only entry point is the proxy on 443. Several apps can live behind the same one, each on its own subdomain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline: getting "push and it's live" back
&lt;/h2&gt;

&lt;p&gt;All the rest is pointless if deploying turns into a manual chore again. The goal is to get exactly the Vercel reflex back: I push to &lt;code&gt;master&lt;/code&gt;, and the app ships to production on its own. A GitHub Actions workflow takes care of it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;push master
  ├─► lint
  ├─► security scan (Trivy, blocking on fixable CVEs)
  └─► build → push image to GHCR (private GitHub registry)
        └─► deploy: SSH into the VPS → docker compose pull → up -d
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The image is stored on GHCR, GitHub's container registry. The deploy step connects to the VPS over SSH, runs a &lt;code&gt;docker compose pull&lt;/code&gt; then an &lt;code&gt;up -d&lt;/code&gt;: the new container replaces the old one with no visible downtime. I add a Trivy scan that fails the pipeline the moment a dependency carries a critical vulnerability with a fix available. Security isn't a review you do "later", it sits right in the deployment path. If a fixable CVE slips through, nothing ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug no green build will flag
&lt;/h2&gt;

&lt;p&gt;I'm saving the best for last, because it's the one that cost me the most time. With &lt;code&gt;output: "standalone"&lt;/code&gt;, the &lt;code&gt;next start&lt;/code&gt; command doesn't serve the app properly. It's not a bug, it's just no longer the right command: you have to run &lt;code&gt;node .next/standalone/server.js&lt;/code&gt;. If you keep &lt;code&gt;next start&lt;/code&gt; in your container, you can spend a while wondering why nothing answers.&lt;/p&gt;

&lt;p&gt;Nastier still: a &lt;code&gt;next build&lt;/code&gt; that goes green doesn't guarantee that all your pages render. A page that calls &lt;code&gt;notFound()&lt;/code&gt;, or a static route with no generated params, gets prerendered as a 404 without the slightest build error. The pipeline is all green, the image is pushed, deployed, and it's in production that you discover your broken page.&lt;/p&gt;

&lt;p&gt;The fix takes one line and thirty seconds. Before every push to production, I run &lt;code&gt;node .next/standalone/server.js&lt;/code&gt; locally, with &lt;code&gt;.next/static&lt;/code&gt; and &lt;code&gt;public&lt;/code&gt; copied alongside like in the real image, and I check with &lt;code&gt;curl&lt;/code&gt; that my key pages do answer 200. That smoke test is what catches the silent 404s before a visitor sees them. A green build lies sometimes; the standalone server actually running, never.&lt;/p&gt;

&lt;p&gt;Self-hosting a modern Next.js app has never struck me as complicated or nostalgic. It's a handful of config files, once, and then the same comfort as a managed platform, on a machine whose exact price and contents I know. For a marketing site, an internal business app or a SaaS getting started, it's the trade-off I'd make again without hesitating.&lt;/p&gt;

&lt;p&gt;If you want a hand hosting or deploying your app, &lt;a href="https://sergent.dev/contact" rel="noopener noreferrer"&gt;let's talk about your project&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>docker</category>
      <category>selfhosting</category>
      <category>devops</category>
    </item>
    <item>
      <title>Running a WordPress site with an AI, without breaking everything</title>
      <dc:creator>Marius</dc:creator>
      <pubDate>Sun, 28 Jun 2026 12:59:54 +0000</pubDate>
      <link>https://dev.to/riusmax/running-a-wordpress-site-with-an-ai-without-breaking-everything-3d9</link>
      <guid>https://dev.to/riusmax/running-a-wordpress-site-with-an-ai-without-breaking-everything-3d9</guid>
      <description>&lt;p&gt;Handing an AI the keys to a production site sounds like a terrible idea. That's the first reaction from most people I bring it up with, and honestly, it's a healthy one. An assistant that can edit pages, move media around and touch the SEO of a site with real traffic, no safety net, is an accident waiting to happen.&lt;/p&gt;

&lt;p&gt;And yet that's exactly what I built. A bridge between an AI assistant and WordPress that lets the AI work on the real site. Not a copy, not copy-paste into the admin that I'd redo by hand afterwards. The real site. What separates that from "terrible idea" comes down entirely to how you fence in the access. My starting point is simple: an AI operating a production site gets treated like any other access to production. The same precautions you'd take for an intern you hand admin access on day one, except the intern at least hesitates before clicking "delete".&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a bridge, and not just the AI copy-pasting
&lt;/h2&gt;

&lt;p&gt;The natural reflex, when you want an AI to write content, is to ask it for the text in a chat window and paste it into WordPress yourself. That works, for one page. For thirty pages it's a chore, and that's where mistakes slip in: a forgotten meta, a title transcribed wrong, a layout that drifts from one page to the next.&lt;/p&gt;

&lt;p&gt;The bridge changes the nature of the work. The AI no longer hands me text I retype, it runs scoped actions directly on the site: list and create pages and posts, compose a layout with the page builder (Divi, in my case), manage the media library, organise menus, fill in titles, meta descriptions and structured data, run diagnostics, trigger backups. Each action is a precise, traceable operation, not a block of text I have to interpret.&lt;/p&gt;

&lt;p&gt;The win isn't only that it goes faster (it does go faster). It's that the work becomes reproducible. When the AI rolls out ten service pages on the same template, the ten come out consistent, because it's the same operation repeated ten times, not ten copy-pastes done by a tired human on a Friday night.&lt;/p&gt;

&lt;h2&gt;
  
  
  The guardrails are the whole article
&lt;/h2&gt;

&lt;p&gt;If you take away one thing, take this: without guardrails, what I'm describing is irresponsible. With them, it's just one more tool. All the value sits in the frame, not in the AI's raw ability to change pages.&lt;/p&gt;

&lt;p&gt;First guardrail, the access scope. You never start with write access. The API key I hand the assistant starts read-only. The AI can see everything, list pages, read existing content, audit the SEO, spot dead links, but it can't change a thing. We stay there long enough to confirm it understands the site and isn't heading the wrong way. Write access comes only afterwards, with eyes open. That step looks trivial and it's the one that prevents the most damage: half the potential harm vanishes simply because the AI isn't allowed to write while it's still learning.&lt;/p&gt;

&lt;p&gt;Second guardrail, a backup before any destructive operation. Before a page gets overwritten, before content gets replaced, we take a backup and keep the way back open. If the result is wrong, we restore the previous state. This isn't an option you switch on for cautious days, it's the default. WordPress already keeps content revisions, which helps, but I don't lean on that alone: an explicit backup before the operation, plus a tested rollback, is what lets me sleep.&lt;/p&gt;

&lt;p&gt;Third guardrail, human validation on what matters. The AI proposes, I keep a hand on the wheel. For a meta description or tidying up some spacing, I let it run and check afterwards. For anything structural, anything that can break something or is hard to undo, it stops and waits for my go-ahead. The split is deliberately lopsided: the AI handles the volume, the judgement stays on my side. I never wanted a system that decides on its own what should change on a client's site.&lt;/p&gt;

&lt;p&gt;Fourth guardrail, the access itself is locked down. Requests are signed, and only certain IPs are allowed to talk to the site. In practice, even if a key leaked, it would be useless from a machine that isn't on the list. That's basic hygiene for any machine-to-machine access, and it matters all the more when the other end is an assistant running actions on its own.&lt;/p&gt;

&lt;p&gt;None of these four points is spectacular. Stacked together, they're the difference between a tool I'll point at a live site and a demo I'd never dare plug into anything but a sandbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like on a real site
&lt;/h2&gt;

&lt;p&gt;I didn't test this on a toy project. The real case is a client site, running WordPress and Divi 5. A real site, with real pages, a real need to be found on Google and legal obligations to meet.&lt;/p&gt;

&lt;p&gt;What I had the AI drive: fleshing out the existing pages (making them fuller, clearer), filling in the SEO page by page where it was empty or sloppy, and producing the baseline legal content. Useful work, but repetitive, the kind of task you keep putting off because it's thankless. Rolling out meta tags cleanly across the whole site, checking that every page has a title that actually means something, harmonising sections that had drifted apart over the months: the AI is very good at that, and it doesn't get bored, unlike me.&lt;/p&gt;

&lt;p&gt;Where I stayed in charge was the judgement calls. What tone for the site, what deserves its own page, whether what's written is accurate and faithful to what the client means. Those decisions the AI can propose, but it shouldn't settle them alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three things I learned
&lt;/h2&gt;

&lt;p&gt;The first is where the AI genuinely shines. The repetitive and the structured. Rolling out pages on one template, filling SEO fields, harmonising a layout, drafting a clean first pass of content. It's mechanical, it's tedious, and it's exactly the kind of work that costs a human hours for a thankless result. The AI swallows it without flinching. If you're looking for where it buys you time, it's there, not in the fine editorial choices.&lt;/p&gt;

&lt;p&gt;The second is that the guardrails aren't negotiable. I know I'm repeating myself, it's on purpose. Backup before write and "read-only first" act as fuses, and without them the experiment doesn't stay reasonable for long. The day you skip them because "it's just a small edit" is the day you learn why they were there.&lt;/p&gt;

&lt;p&gt;The third is the most important, and it cuts against the prevailing talk. The AI doesn't replace the webmaster. It takes the chore off their plate. All the judgement work stays human: deciding what to change, checking it's right, sensing what rings false. What the AI removes is the thankless part, the data entry, the repetition, the filling-in. What it doesn't touch is what makes the job worth something. Selling the opposite is a lie, and it shows on the result soon enough.&lt;/p&gt;

&lt;p&gt;The interesting thing, in the end, isn't that an AI can edit WordPress. It's that it forces you to write down, in black and white, what a safe operation on your site actually is: what gets backed up first, what needs a sign-off, what must never run on its own. Most sites have never formalised any of that. Plugging an AI into one is the occasion to do it, and that's probably the real benefit, well before the time saved.&lt;/p&gt;

&lt;p&gt;If you've got a WordPress site and this approach appeals to you for yours, &lt;a href="https://sergent.dev/contact" rel="noopener noreferrer"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>ia</category>
      <category>automatisation</category>
      <category>seo</category>
    </item>
    <item>
      <title>Shipping an offline-first ML model — architecture and lessons learned</title>
      <dc:creator>Marius</dc:creator>
      <pubDate>Sun, 28 Jun 2026 12:59:20 +0000</pubDate>
      <link>https://dev.to/riusmax/shipping-an-offline-first-ml-model-architecture-and-lessons-learned-55ek</link>
      <guid>https://dev.to/riusmax/shipping-an-offline-first-ml-model-architecture-and-lessons-learned-55ek</guid>
      <description>&lt;h2&gt;
  
  
  The challenge
&lt;/h2&gt;

&lt;p&gt;A mobile app that talks to a field measurement instrument — drilling, geotechnics, environmental surveys — has to collect signals in real time, interpret them, and surface actionable information to the operator. All of that &lt;strong&gt;with no internet connection&lt;/strong&gt;, on a smartphone or a ruggedised Android tablet.&lt;/p&gt;

&lt;p&gt;The concrete case: sensors push streams of mechanical data (pressure, torque, advance rate, depth, GPS) over a direct wireless link. From those raw signals, the app must produce a real-time classification of the ground being drilled through — &lt;strong&gt;no cloud, no server, entirely on the device&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The classification model (LightGBM, trained on real annotated data) already exists, but integrating it into a React Native app raises architectural questions that go well beyond simply calling a library.&lt;/p&gt;




&lt;h2&gt;
  
  
  A three-layer architecture
&lt;/h2&gt;

&lt;p&gt;The system is organised into three strictly separated domains:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Field layer&lt;/strong&gt;: The measurement instrument communicates over direct WiFi with the app. No cable, no network infrastructure, no cloud. The flow is one-way: the sensor pushes its measurements, the app receives them. Fixed-frequency polling keeps the data fresh without saturating the channel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Application layer&lt;/strong&gt;: This is the heart of the system, itself split into three responsibilities:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Acquisition and display&lt;/strong&gt;: the UI receives the data and renders it in real time as curves and visual representations. The operator can interact (start, pause, stop a measurement) and export results.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transformation pipeline&lt;/strong&gt;: raw data isn't directly consumable by the model. A chain of successive transformations (normalisation, calibration, windowing, aggregation) turns it into numeric feature vectors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inference and aggregation&lt;/strong&gt;: the ONNX model runs on the device CPU, produces predictions per measurement point, which are then consolidated by slice to yield a stable, usable result.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Storage layer&lt;/strong&gt;: Entirely local. The ML model is embedded in the app. Measurement data is persisted to a structured CSV file, organised by job site. User metadata (display preferences, configuration) uses native key-value storage.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────┐
│  Field layer                                │
│  Instrument → direct WiFi → measurement flow │
└─────────────────────┬───────────────────────┘
                      │
┌─────────────────────▼────────────────────────┐
│  Application layer                           │
│                                              │
│  ┌──────────┐  ┌──────────────┐  ┌─────────┐ │
│  │Real-time │  │Transformation│  │ONNX     │ │
│  │UI        │  │pipeline      │  │inference│ │
│  │+ export  │  │+ calibration │  │+ vote   │ │
│  └──────────┘  └──────────────┘  └─────────┘ │
└─────────────────────┬────────────────────────┘
                      │
┌─────────────────────▼───────────────────────┐
│  Storage layer                              │
│  Embedded model · CSV · Preferences         │
└─────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4kkn73jusc8k46u756wr.webp" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4kkn73jusc8k46u756wr.webp" alt="Architecture diagram" width="799" height="436"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The structuring architecture decisions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. A 100% JavaScript preprocessor
&lt;/h3&gt;

&lt;p&gt;The ML model expects normalised feature vectors, not raw data. The tempting path would have been to use Python (numpy, pandas) for this step, since that's how the model was trained — but Python doesn't exist on a React Native smartphone.&lt;/p&gt;

&lt;p&gt;Two options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Write a native binding (C++/Kotlin) that reimplements the preprocessing&lt;/li&gt;
&lt;li&gt;Do everything in pure JavaScript&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We chose the second. Why? Because the preprocessing is exclusively &lt;strong&gt;arithmetic&lt;/strong&gt;: medians, quantiles, sorts, per-window statistics. No matrix algebra, no GPU. In pure JavaScript, this code is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unit-testable&lt;/strong&gt; with no infrastructure (Jest)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dependency-free&lt;/strong&gt; (no versioning to keep in sync with the ONNX runtime)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fast&lt;/strong&gt; (a few tens of milliseconds for thousands of points)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Portable&lt;/strong&gt; (the same code can run on iOS if needed)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's an interesting counterpoint to the "everything native for performance" trend. Here, JavaScript is a perfect fit.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Calibration against an embedded reference
&lt;/h3&gt;

&lt;p&gt;A classic problem with sensor-signal classification: two identical instruments, two different days, two different operators — raw values can vary significantly, even on the same material. A model trained naively on those values would be brittle and context-dependent.&lt;/p&gt;

&lt;p&gt;The solution: before each campaign, a &lt;strong&gt;baseline (empty) reference&lt;/strong&gt; measurement is taken. The model never works on raw values, only on the &lt;strong&gt;deltas from that reference&lt;/strong&gt;. This differential approach makes the system invariant to hardware, weather conditions and mechanical wear — only what changes &lt;em&gt;relative to the day's reference&lt;/em&gt; carries the useful information.&lt;/p&gt;

&lt;p&gt;It's a pattern that goes beyond geotechnics: any app that classifies from sensor signals in a real-world environment should consider some form of contextual normalisation.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Inference that never blocks the UI
&lt;/h3&gt;

&lt;p&gt;The ONNX runtime (&lt;code&gt;onnxruntime-react-native&lt;/code&gt;) has a little-known architectural property: the &lt;code&gt;session.run()&lt;/code&gt; call is asynchronous on the JavaScript side, but the real work runs on a &lt;strong&gt;dedicated native C++ thread&lt;/strong&gt;, independent of the main JS thread.&lt;/p&gt;

&lt;p&gt;In practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The JS thread prepares the data and triggers the call&lt;/li&gt;
&lt;li&gt;The C++ runtime spawns a secondary thread, runs inference there, and returns the result via a Promise&lt;/li&gt;
&lt;li&gt;Meanwhile, the JS thread keeps handling the UI, the polling, the animations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is exactly what you want for a real-time app: inference, even on batches of several thousand points, causes &lt;strong&gt;no perceptible latency&lt;/strong&gt; on the interface.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Aggregation to smooth out noise
&lt;/h3&gt;

&lt;p&gt;The model was trained on individual measurement points. Inferring point by point would produce noisy, unstable predictions. The solution: &lt;strong&gt;infer over all the points in a depth slice, then vote&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The mechanism is simple: each point votes for a class, the majority class wins. On a tie, average confidence decides. This majority vote smooths out outliers and keeps only the dominant behaviour of the slice.&lt;/p&gt;

&lt;p&gt;It's a pattern found in many embedded classification systems: rather than trying to improve point-by-point accuracy (which would be expensive in training data), you exploit the natural redundancy of the measurements to gain robustness.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. An idempotent real-time pipeline
&lt;/h3&gt;

&lt;p&gt;In real-time mode, data arrives in successive batches (polling). The challenge: don't re-infer slices that are already processed, and detect the moment a slice is complete.&lt;/p&gt;

&lt;p&gt;The chosen architecture is a &lt;strong&gt;stateful singleton&lt;/strong&gt; that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Initialises with the calibration data (once)&lt;/li&gt;
&lt;li&gt;Ingests new points on each polling cycle&lt;/li&gt;
&lt;li&gt;Triggers inference only when a slice is complete (as soon as a point from the next slice arrives)&lt;/li&gt;
&lt;li&gt;Guarantees idempotence: if the same data is passed twice, it's ignored&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lifecycle is simple: &lt;code&gt;init()&lt;/code&gt; → &lt;code&gt;process()&lt;/code&gt; (in a loop) → &lt;code&gt;reset()&lt;/code&gt;. No complex state management, no state machine. A singleton, a &lt;code&gt;Set&lt;/code&gt; for idempotence, and a slice-completion rule.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Embedded has its own rules
&lt;/h3&gt;

&lt;p&gt;What works on a backend (infer in batches, load everything into memory, tolerate 500ms of latency) doesn't work on a field mobile device. The constraints are different:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Limited RAM&lt;/strong&gt;: loading the entire dataset into memory is out of the question&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared CPU&lt;/strong&gt;: the JS thread runs the polling, the rendering, the animations AND the preprocessing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No cloud fallback&lt;/strong&gt;: if the model is corrupt or missing, the app must detect it and report it without crashing&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The model's output format is a weak link
&lt;/h3&gt;

&lt;p&gt;Models exported to ONNX can have different output formats depending on the exporter version: &lt;code&gt;int64&lt;/code&gt;, &lt;code&gt;Int32&lt;/code&gt;, &lt;code&gt;string&lt;/code&gt;, dictionary — sometimes depending on the pipeline's mood. If the app assumes one format and gets another, the result is a silent error or a crash.&lt;/p&gt;

&lt;p&gt;The lesson: &lt;strong&gt;always parse outputs defensively&lt;/strong&gt;. Check the type, provide a fallback, log the discrepancy. A wrong prediction beats a crash, especially on a job site where restarting the app means losing time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Python/JS cross-validation is essential
&lt;/h3&gt;

&lt;p&gt;The preprocessing was developed and tested in Python (numpy, pandas) for training, then rewritten in JavaScript for the embedded side. Without validating that both implementations produce exactly the same results, bit for bit, on the same data, you cannot trust the predictions.&lt;/p&gt;

&lt;p&gt;The method: a reference dataset, two standalone executables (Python and JS), and a test battery that compares the outputs with a Float32 noise tolerance. This approach should be systematic whenever you port an ML pipeline from a research environment to a production one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Calibration, not raw data
&lt;/h3&gt;

&lt;p&gt;The most important decision in the project isn't technical but &lt;strong&gt;methodological&lt;/strong&gt;: never work on raw values, always on normalised deltas from a contextual reference. That's what makes the system robust in real conditions, on variable hardware, in uncontrolled environments.&lt;/p&gt;




&lt;h2&gt;
  
  
  To wrap up
&lt;/h2&gt;

&lt;p&gt;This project illustrates a fact that's becoming hard to ignore: ML models are no longer confined to servers. You can — and should — embed them on field devices, where the data is produced, where the decision has to be made.&lt;/p&gt;

&lt;p&gt;But embedding a model isn't just calling a library. It means rethinking the whole architecture: how to normalise inputs without Python, how to guarantee reliability without a server, how to validate consistency between training and inference, how to run it all on a smartphone in the rain, with gloves on, next to a vibrating drilling rig.&lt;/p&gt;

&lt;p&gt;Edge AI, in 2025, is no longer a lab. It's a job site.&lt;/p&gt;




&lt;p&gt;Learn more about Prolog: &lt;a href="https://prolog-system.ai/" rel="noopener noreferrer"&gt;prolog-system.ai&lt;/a&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>machinelearning</category>
      <category>onnx</category>
      <category>offlinefirst</category>
    </item>
    <item>
      <title>A homemade CI/CD pipeline with GitHub Actions</title>
      <dc:creator>Marius</dc:creator>
      <pubDate>Sun, 28 Jun 2026 12:58:45 +0000</pubDate>
      <link>https://dev.to/riusmax/a-homemade-cicd-pipeline-with-github-actions-1bl5</link>
      <guid>https://dev.to/riusmax/a-homemade-cicd-pipeline-with-github-actions-1bl5</guid>
      <description>&lt;p&gt;In the previous article on &lt;a href="https://sergent.dev/blog/heberger-nextjs-vps-docker" rel="noopener noreferrer"&gt;hosting a Next.js app on a VPS&lt;/a&gt;, I'd left the deployment pipeline as a rough sketch: four lines to say "it ships to production on its own when you push." That's the piece I want to open up here, because it's what separates a VPS you fuss over by hand from infrastructure you can forget about.&lt;/p&gt;

&lt;p&gt;There's a stubborn myth that CI/CD is a big-company thing, with a dedicated DevOps team and six-figure tooling. Not true. The pipeline that deploys this portfolio fits in two YAML files, you can read it in five minutes, and it gives me back exactly the comfort I liked about Vercel: I push to &lt;code&gt;master&lt;/code&gt;, I go grab a coffee, the app is live when I'm back. The one thing I gained along the way is knowing precisely what happens between the &lt;code&gt;git push&lt;/code&gt; and the running container.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four steps, in this order
&lt;/h2&gt;

&lt;p&gt;Deployment is a chain. On every push to &lt;code&gt;master&lt;/code&gt;, GitHub Actions runs lint, security scan, image build, and deploy. What matters is the &lt;code&gt;needs&lt;/code&gt;: as long as a step fails, the following ones don't start. A critical vulnerability caught by the scan, and the image never gets built. At all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;lint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;        &lt;span class="c1"&gt;# ESLint&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="c1"&gt;# ...&lt;/span&gt;
  &lt;span class="na"&gt;security&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;    &lt;span class="c1"&gt;# Trivy scan (reusable workflow)&lt;/span&gt;
    &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/workflows/security.yml&lt;/span&gt;
  &lt;span class="na"&gt;build-push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# build the Docker image → push to GHCR&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;lint&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;security&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="c1"&gt;# ...&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;# SSH to the VPS → docker compose pull &amp;amp;&amp;amp; up -d&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;build-push&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lint first, because it's the cheapest step and there's no point building an image if ESLint is already screaming. The scan next, as a barrier. Then the build, which produces the Docker image and pushes it to GHCR, GitHub's container registry (private, in my case). And finally the deploy, which connects over SSH to the VPS, pulls the new image and restarts the container. Four links, each blocking the next. That's the whole secret.&lt;/p&gt;

&lt;h2&gt;
  
  
  The security scan is in the path, not in a review "for later"
&lt;/h2&gt;

&lt;p&gt;This is the one I won't budge on. Dependency security, in a lot of projects, is a Dependabot opening PRs you read on Friday if there's time left (so, never). I've put it where it can't be ignored: on the deployment path. Trivy scans the repo's filesystem on every push, and if a critical or high CVE is hanging around, the pipeline fails before the build.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run Trivy vulnerability scanner&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aquasecurity/trivy-action@master&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;scan-type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fs"&lt;/span&gt;
    &lt;span class="na"&gt;scan-ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;."&lt;/span&gt;
    &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CRITICAL,HIGH"&lt;/span&gt;
    &lt;span class="na"&gt;ignore-unfixed&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;   &lt;span class="c1"&gt;# don't block on what can't be fixed yet&lt;/span&gt;
    &lt;span class="na"&gt;exit-code&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;         &lt;span class="c1"&gt;# fail the run if a fix exists&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nuance that makes this livable is &lt;code&gt;ignore-unfixed: true&lt;/code&gt;. Without that setting, the scan blocks you on vulnerabilities for which no fix exists upstream yet. You end up stuck, unable to deploy because of a flaw you can do strictly nothing about, and the temptation grows to turn the scan off "just for now." Bad idea, we know how that ends. With &lt;code&gt;ignore-unfixed&lt;/code&gt;, the rule is clean: if a critical flaw has a fix available, you fix it before merging, full stop. If it doesn't, the pipeline lets you through and you keep an eye on it. Blocking on the unfixable isn't rigour, it's just self-sabotage.&lt;/p&gt;

&lt;p&gt;In practice, that gives you mornings where the push fails with a Trivy report pointing at a version of Next to update. A &lt;code&gt;yarn up next@&amp;lt;fixed-version&amp;gt;&lt;/code&gt;, a commit of the &lt;code&gt;package.json&lt;/code&gt; and the lockfile, and you're off again. That friction, I want it. It's what keeps known vulnerabilities from sleeping six months in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  From build to running container
&lt;/h2&gt;

&lt;p&gt;The build step takes the image described by the Dockerfile (multi-stage, standalone output, I covered it in the previous article) and pushes it to GHCR tagged &lt;code&gt;latest&lt;/code&gt; and &lt;code&gt;sha-&amp;lt;commit&amp;gt;&lt;/code&gt;. The &lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt; variables get passed as &lt;code&gt;build-args&lt;/code&gt; at this point, because they're inlined into the bundle at &lt;code&gt;next build&lt;/code&gt; time. Everything that has to be visible browser-side is frozen here.&lt;/p&gt;

&lt;p&gt;The deploy itself is almost boring, it's that simple, and that's exactly what you want from a deploy. An SSH action opens a session on the VPS and runs three commands there: a &lt;code&gt;docker login&lt;/code&gt; to GHCR, a &lt;code&gt;docker compose pull&lt;/code&gt; to fetch the fresh image, an &lt;code&gt;up -d&lt;/code&gt; to swap the running container for the new one. No visible downtime, the old one keeps running until the new one is ready. None of these commands holds a single secret in plain text: the SSH key, the registry credentials, the target host, it all lives in GitHub secrets and resolves at runtime. The repo itself knows nothing. That's the ground rule, and it's non-negotiable: a committed secret is a burned secret, even in a private repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate on the PR, don't discover in production
&lt;/h2&gt;

&lt;p&gt;Here's the first refinement I added after the fact, and one I'd put on any project from day one now. The deployment pipeline only fires on &lt;code&gt;master&lt;/code&gt;. As long as I'm working on a branch, nothing builds, nothing ships. The catch is you don't want to merge blind either and find out the build breaks once it's on &lt;code&gt;master&lt;/code&gt;, when the failure already kicks off the machine.&lt;/p&gt;

&lt;p&gt;Hence a second workflow, &lt;code&gt;pr-check&lt;/code&gt;, completely separate, that fires on Pull Requests. It runs lint, typecheck and build, but it deploys nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;master"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The typecheck in particular is worth its weight in gold: ESLint catches style, but it's &lt;code&gt;tsc&lt;/code&gt; that catches the real type errors, the ones that blow up a build. When a PR is green, I know the code compiles, the types hold, and the image builds. The merge becomes a formality instead of a gamble. Production is never again the first place I find out something doesn't pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two details that prevent dumb damage
&lt;/h2&gt;

&lt;p&gt;Editing a README should never trigger a full redeploy. Rebuilding a Docker image, pushing it, SSH, pull, restart, all that for a typo in a markdown file, is absurd. A &lt;code&gt;paths-ignore&lt;/code&gt; on the trigger settles it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;master"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;paths-ignore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**.md"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docs/**"&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I kept &lt;code&gt;workflow_dispatch&lt;/code&gt; alongside, so I can force a deploy by hand from the GitHub UI if I do change something that only touches docs but still needs to ship. The service entrance, basically.&lt;/p&gt;

&lt;p&gt;The other detail cost me a real scare before I put it in place. Picture two merges back to back, a minute apart. Two deployment pipelines start almost together, each builds its image, connects to the VPS out of order. That's exactly what happened to me: the two deploys collided, and the race was won by the image that was barely a second stale. The slower run's &lt;code&gt;pull&lt;/code&gt; arrived last and overwrote production with the previous version. Green build on both sides, and yet the wrong version live. The kind of bug that makes you doubt your own sanity.&lt;/p&gt;

&lt;p&gt;The countermeasure is three lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy-production&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One production deploy at a time. If a new one starts while another is still running, the old one is cancelled outright. The most recent commit always wins, by construction, and never again by chance. The race hasn't existed since.&lt;/p&gt;

&lt;p&gt;None of this is wizardry. It's two YAML files, a few guardrails learned by banging into things a bit, and the same reflex as a managed platform: you push, it's live. The difference is that I know what's in the box, what it costs me, and that the day I want to change a link in the chain, it's there, readable, mine.&lt;/p&gt;

&lt;p&gt;If you want to set up this kind of pipeline on your project, &lt;a href="https://sergent.dev/contact" rel="noopener noreferrer"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>githubactions</category>
      <category>devops</category>
      <category>docker</category>
    </item>
    <item>
      <title>Strapi et Next.js : une architecture headless qui tient la route</title>
      <dc:creator>Marius</dc:creator>
      <pubDate>Sun, 28 Jun 2026 12:52:01 +0000</pubDate>
      <link>https://dev.to/riusmax/strapi-et-nextjs-une-architecture-headless-qui-tient-la-route-4cnh</link>
      <guid>https://dev.to/riusmax/strapi-et-nextjs-une-architecture-headless-qui-tient-la-route-4cnh</guid>
      <description>&lt;p&gt;« Headless » est devenu un argument de vente. On le colle sur à peu près n'importe quel projet web, comme si découpler le contenu de son affichage était toujours un progrès. La plupart du temps c'est faux : tu ajoutes des serveurs, des points de panne et des heures d'ops pour un blog que trois fichiers Markdown auraient servi sans broncher. Mais il existe des cas où le découplage n'est pas une mode, c'est juste la bonne réponse. Un projet client pour lequel j'ai monté l'architecture en est un bon exemple.&lt;/p&gt;

&lt;p&gt;Le besoin était double dès le départ. D'un côté un site public multilingue, vitrine des fiches, qui doit charger vite et se référencer correctement. De l'autre une interface de gestion, le « manager », où l'équipe administre les données métier. Deux usages, deux audiences, deux rythmes de mise à jour. La vraie question n'était pas « headless ou pas », elle était : comment ces deux applications partagent les mêmes données sans se marcher dessus.&lt;/p&gt;

&lt;h2&gt;
  
  
  Un backend, deux fronts
&lt;/h2&gt;

&lt;p&gt;La réponse tient en une phrase : un seul backend, deux frontends Next.js distincts. Strapi joue le CMS headless, PostgreSQL stocke tout, et les deux apps consomment la même API. Le site public lit les fiches, les pages de contenu et leurs traductions. Le manager lit et écrit les données de gestion. Même source de vérité, deux consommateurs qui n'ont pas à se connaître.&lt;/p&gt;

&lt;p&gt;Ce que ça évite, c'est la duplication du modèle. Si la définition d'une « fiche » vivait à deux endroits, une fois dans le site et une fois dans le manager, il faudrait la maintenir deux fois. Et un jour les deux versions divergent, toujours au pire moment. Là, le modèle est défini une seule fois dans Strapi, les deux fronts en héritent. Tu ajoutes un champ, il apparaît dans l'API, les deux apps peuvent le lire. Point.&lt;/p&gt;

&lt;p&gt;Côté Next, lire ces données depuis un Server Component reste direct. Pas de client lourd, un &lt;code&gt;fetch&lt;/code&gt; et la réponse JSON de Strapi.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/[locale]/fiches/page.tsx (Server Component)&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;getFiches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&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="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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&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;STRAPI_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/fiches?locale=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;populate=photos`&lt;/span&gt;&lt;span class="p"&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="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;process&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;STRAPI_TOKEN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;revalidate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&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;res&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="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Strapi a répondu &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;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="kd"&gt;const&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="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="k"&gt;return&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Le &lt;code&gt;revalidate&lt;/code&gt; met les fiches en cache côté Next et les rafraîchit en arrière-plan : le visiteur ne paie jamais l'aller-retour vers Strapi en temps réel, et l'éditeur voit sa modification apparaître sans qu'on rebuild quoi que ce soit. Le &lt;code&gt;if (!res.ok)&lt;/code&gt; n'est pas décoratif. Une API distante, ça tombe, ça renvoie un 500, ça change de schéma le jour d'une migration. Mieux vaut lever une erreur franche que servir une page à moitié vide en silence.&lt;/p&gt;

&lt;h2&gt;
  
  
  L'édition sans passer par le développeur
&lt;/h2&gt;

&lt;p&gt;Le deuxième gain est moins technique, mais c'est lui qui change la vie au quotidien : l'équipe du client édite son contenu sans m'appeler. Strapi génère une interface d'administration à partir du modèle de données. Ajouter une fiche, corriger une description, publier une traduction, tout ça se fait dans le back-office, pas dans une pull request.&lt;/p&gt;

&lt;p&gt;Pour un dev, c'est presque contre-intuitif. On aime garder le contenu en fichiers versionnés, du Markdown bien rangé dans le repo, parce que c'est propre et que ça passe par la revue de code. Sauf que le jour où c'est une équipe non-technique qui doit publier plusieurs fois par semaine, le repo devient un goulot d'étranglement, et toi le goulot avec. Strapi met ce pouvoir entre leurs mains et me sort du chemin critique. C'est exactement ce que je veux : qu'on n'ait plus besoin de moi pour changer une virgule.&lt;/p&gt;

&lt;p&gt;Le multilingue suit la même logique. Le plugin i18n de Strapi gère les traductions au niveau de l'entrée, chaque fiche existe dans plusieurs langues, et le front réclame la bonne avec un simple &lt;code&gt;?locale=&lt;/code&gt;. C'est l'API qui porte le multilingue, le code du site ne fait que demander la langue courante.&lt;/p&gt;

&lt;h2&gt;
  
  
  API-first, le pari du long terme
&lt;/h2&gt;

&lt;p&gt;Troisième raison, plus stratégique : tout transite par l'API. Aujourd'hui ce sont deux fronts web. Si demain le client veut une app mobile pour ses équipes sur le terrain, ou un portail dédié à un autre public, il tape la même API et ne reconstruit rien côté données.&lt;/p&gt;

&lt;p&gt;Je le dis avec prudence, parce que c'est l'argument qu'on sur-vend le plus. « API-first, tu pourras tout brancher plus tard » : ce « plus tard » n'arrive souvent jamais, et on a payé la complexité d'avance pour un futur qui ne vient pas. La différence sur ce projet, c'est que le deuxième consommateur existait dès le premier jour. Le manager n'était pas une hypothèse de roadmap, il était dans le périmètre. L'API ne servait pas un besoin imaginé « au cas où », elle servait deux consommateurs réels tout de suite. C'est toute la nuance entre une architecture qui anticipe un vrai besoin et une qui se complique pour le plaisir du schéma au tableau blanc.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ce que le découplage coûte vraiment
&lt;/h2&gt;

&lt;p&gt;Maintenant la part honnête, celle que les articles qui vendent le headless oublient de mentionner. Découpler, c'est multiplier les pièces. Au lieu d'une application à déployer et surveiller, tu en as quatre qui doivent tourner ensemble : Strapi, PostgreSQL, le front public, le front manager, sans compter l'hébergement et les sauvegardes de tout ça. Chaque pièce a ses logs, ses mises à jour, sa façon de tomber. La surface d'ops n'a rien à voir avec celle d'un monolithe.&lt;/p&gt;

&lt;p&gt;L'authentification et les permissions deviennent un sujet à part entière. Le site public lit en anonyme, ou via un token en lecture seule bien cadenassé. Le manager, lui, écrit, donc il faut une vraie auth, des rôles, des permissions par collection. Strapi fournit la mécanique, mais la configurer correctement, fermer ce qui doit l'être et vérifier qu'un token public ne peut pas écrire, c'est du travail qui n'existe tout simplement pas dans une app monolithique où l'accès se contrôle au même endroit que le rendu.&lt;/p&gt;

&lt;p&gt;L'i18n ajoute sa propre couche. Gérer les langues au niveau de l'API, c'est pratique, mais ça veut dire penser le fallback quand une traduction manque, garder les entrées synchronisées entre les langues, et tester chaque parcours dans chaque locale. Rien d'insurmontable, mais ce sont des heures qui s'accumulent et qu'on ne facture jamais assez.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quand je ne le ferais pas
&lt;/h2&gt;

&lt;p&gt;Si on était venu me voir pour un site vitrine de cinq pages avec un formulaire de contact, je n'aurais jamais sorti Strapi. Un Next monolithique avec le contenu en fichiers aurait fait le travail, ou carrément un WordPress si le client tient à éditer lui-même, pour une fraction du coût et de la maintenance. Le headless ne se justifie pas par l'élégance du diagramme. Il se justifie par un besoin concret : plusieurs frontends, une équipe contenu qui doit être autonome, ou une API destinée à durer et à grandir. Coche au moins une de ces cases et le découplage te rend un vrai service. N'en coche aucune et tu t'es offert un backend à maintenir pour rien.&lt;/p&gt;

&lt;p&gt;Ce projet cochait deux cases sur trois dès le premier jour, deux fronts et une équipe qui édite. C'est pour ça que je referais exactement la même architecture sans hésiter. Et ce blog que tu lis là ? Justement non. Ces articles sont de simples fichiers MDX dans le dépôt du portfolio, aucun Strapi, aucun Postgres derrière. La bonne archi, ce n'est pas la plus impressionnante, c'est celle qui colle au besoin. Et le besoin d'un blog perso tient dans un dossier de fichiers.&lt;/p&gt;

&lt;p&gt;Si tu hésites entre un CMS headless et quelque chose de plus simple pour ton projet, &lt;a href="https://sergent.dev/contact" rel="noopener noreferrer"&gt;parlons-en&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>strapi</category>
      <category>nextjs</category>
      <category>headless</category>
      <category>cms</category>
    </item>
    <item>
      <title>Sécuriser un formulaire de contact : ce que la plupart oublient</title>
      <dc:creator>Marius</dc:creator>
      <pubDate>Sun, 28 Jun 2026 12:51:27 +0000</pubDate>
      <link>https://dev.to/riusmax/securiser-un-formulaire-de-contact-ce-que-la-plupart-oublient-23de</link>
      <guid>https://dev.to/riusmax/securiser-un-formulaire-de-contact-ce-que-la-plupart-oublient-23de</guid>
      <description>&lt;p&gt;Un formulaire de contact, c'est souvent la dernière chose qu'on code, vite fait, parce que « ce n'est qu'un formulaire ». Trois champs, un bouton, un e-mail qui part. Sauf que derrière le bouton, il y a une route POST publique, ouverte sur Internet, qui déclenche une action côté serveur : un envoi d'e-mail, via un service facturé à l'usage. Autrement dit, une cible. Pas la plus juteuse du web, mais une cible quand même, et bien plus exposée que la page « à propos » juste à côté.&lt;/p&gt;

&lt;p&gt;Cette semaine, j'ai audité celui de ce portfolio. J'y ai trouvé une porte grande ouverte que j'avais moi-même posée des mois plus tôt, sans m'en rendre compte. J'y reviens, c'est le cœur de l'article. Avant ça, le constat que je veux faire passer : la sécurité d'un formulaire ne tient pas dans une case à cocher. C'est un empilement de petites défenses qui, prises une à une, paraissent anecdotiques, et qui ensemble tiennent. Le maillon qui lâche, dans mon expérience, c'est presque toujours un raccourci de dev qu'on a oublié d'enlever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le widget reCAPTCHA ne protège rien tout seul
&lt;/h2&gt;

&lt;p&gt;reCAPTCHA v3, c'est devenu un réflexe. On ajoute le provider côté client, le petit badge apparaît en bas à droite, et on se sent couvert. C'est une illusion confortable. Le widget client génère un token, rien de plus. Tant que personne ne vérifie ce token côté serveur, il ne vaut rien : un bot n'a même pas besoin de charger le script Google, il poste directement sur ton endpoint avec un champ &lt;code&gt;recaptchaToken&lt;/code&gt; bidon, ou vide, ou recopié.&lt;/p&gt;

&lt;p&gt;La vraie barrière est sur le serveur. À la réception de la requête, tu rappelles l'API de vérification de Google avec ta clé secrète, et tu lis ce qu'elle te renvoie : le &lt;code&gt;success&lt;/code&gt;, le &lt;code&gt;score&lt;/code&gt; (entre 0 et 1, plus c'est haut plus c'est probablement humain), et l'&lt;code&gt;action&lt;/code&gt; attendue. Les trois doivent être bons.&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifyRecaptchaV3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expectedAction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contact_form&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;secretKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&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;RECAPTCHA_SECRET_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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;secretKey&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;isValid&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="na"&gt;score&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;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://www.google.com/recaptcha/api/siteverify&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/x-www-form-urlencoded&lt;/span&gt;&lt;span class="dl"&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="s2"&gt;`secret=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;secretKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;response=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="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;isValid&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;success&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;expectedAction&lt;/span&gt;
    &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&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="nx"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&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;Le seuil de 0.5 est le point de réglage. Trop haut, tu bloques de vrais visiteurs un peu nerveux du clic. Trop bas, tu laisses passer les bots. 0.5 est un milieu raisonnable pour un site vitrine. Et vérifier l'&lt;code&gt;action&lt;/code&gt; compte autant que le score : ça garantit que le token a bien été émis pour ton formulaire de contact, pas chipé sur une autre page du site.&lt;/p&gt;

&lt;h2&gt;
  
  
  L'histoire du token magique
&lt;/h2&gt;

&lt;p&gt;Voilà la porte ouverte. En développant le formulaire, j'avais besoin de tester l'envoi sans me coltiner reCAPTCHA à chaque soumission locale. Classique. J'avais donc ajouté un petit contournement : si le token reçu valait la chaîne &lt;code&gt;"test_token_no_recaptcha"&lt;/code&gt;, le serveur considérait le captcha comme validé et passait à la suite. Pratique en dev. Le genre de béquille qu'on se promet d'enlever avant la mise en prod.&lt;/p&gt;

&lt;p&gt;Sauf qu'elle est restée. Et pas qu'un peu : le front l'envoyait tout seul. La logique côté client disait, en substance, « si reCAPTCHA n'est pas disponible, envoie quand même le token de secours ». Or reCAPTCHA est indisponible bien plus souvent qu'on ne le croit : un bloqueur de pub qui filtre le script Google, une extension de confidentialité, la clé publique absente d'un environnement, un simple hoquet réseau au chargement. À chacun de ces cas, le navigateur d'un visiteur parfaitement légitime basculait sur le token magique. Et n'importe qui regardant le code client deux minutes voyait passer la chaîne en clair.&lt;/p&gt;

&lt;p&gt;Résultat : un attaquant pouvait poster sur &lt;code&gt;/api/contact&lt;/code&gt; autant qu'il voulait, sans jamais résoudre le moindre captcha, juste en envoyant &lt;code&gt;"test_token_no_recaptcha"&lt;/code&gt;. Toute la couche anti-bot, contournée par une ligne que j'avais écrite pour me simplifier la vie six mois plus tôt.&lt;/p&gt;

&lt;p&gt;Le correctif tient en un renversement de logique. Si reCAPTCHA n'est pas disponible, on ne contourne pas, on bloque. Le serveur ne connaît plus aucun token de faveur. Le client, lui, refuse d'appeler l'API et affiche un message clair au lieu d'inventer un laissez-passer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;executeRecaptcha&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;recaptchaToken&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;executeRecaptcha&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contact_form&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// reCAPTCHA indisponible : on bloque, on n'envoie pas de token de secours&lt;/span&gt;
  &lt;span class="nf"&gt;setSubmitError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;La vérification de sécurité est indisponible. Rafraîchissez la page.&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La leçon dépasse largement reCAPTCHA. Un bypass de dev qui traîne en prod, c'est une porte dérobée que tu as toi-même installée, documentée, et dont tu as oublié l'existence. Les pires failles ne sont presque jamais des attaques sophistiquées. Ce sont des raccourcis de confort qu'on a négligé de retirer. Quand je &lt;code&gt;grep&lt;/code&gt; un projet avant une mise en prod, &lt;code&gt;test&lt;/code&gt;, &lt;code&gt;debug&lt;/code&gt;, &lt;code&gt;bypass&lt;/code&gt;, &lt;code&gt;skip&lt;/code&gt; et &lt;code&gt;TODO&lt;/code&gt; sont les premiers mots que je cherche.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limiter le débit, avant même de réfléchir
&lt;/h2&gt;

&lt;p&gt;Mettons le captcha solide. Il reste un problème de volume. Sans plafond, rien n'empêche quelqu'un de marteler ton endpoint : quelques milliers de POST, et c'est ton quota d'envoi d'e-mails qui se vide (Resend, dans mon cas, facture à l'usage), ta boîte de réception qui déborde, ou ton serveur qui passe son temps à appeler l'API de Google pour rien.&lt;/p&gt;

&lt;p&gt;Un rate-limit règle ça, et il doit s'exécuter en tout premier, avant la validation, avant l'appel à reCAPTCHA, avant la moindre opération coûteuse. L'idée : compter les requêtes par IP sur une fenêtre glissante, et refuser au-delà d'un seuil. Pour un site vitrine, pas besoin de Redis ni d'Upstash. Une &lt;code&gt;Map&lt;/code&gt; en mémoire suffit largement, cinq requêtes par tranche de dix minutes et par IP.&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;rateLimitMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&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="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RATE_LIMIT_MAX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&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;RATE_LIMIT_WINDOW_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 10 minutes&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkRateLimit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&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;boolean&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;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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;windowStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;RATE_LIMIT_WINDOW_MS&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;recent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rateLimitMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&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="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&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;t&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;windowStart&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;recent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;RATE_LIMIT_MAX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;rateLimitMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&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;// limite atteinte&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;rateLimitMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deux détails qui font la différence. L'IP du client, derrière un reverse proxy (et tout le monde l'est, en pratique), ne se lit pas sur la socket : elle est dans l'en-tête &lt;code&gt;x-forwarded-for&lt;/code&gt;, premier maillon de la liste. Si tu prends l'IP de la connexion directe, tu rate-limites ton propre proxy, c'est-à-dire tout le monde d'un coup. Second point, oui, une &lt;code&gt;Map&lt;/code&gt; en mémoire se vide à chaque redéploiement, et ne tient pas sur plusieurs instances. Pour un portfolio à une instance et au trafic modeste, c'est un compromis que j'assume sans hésiter. Sortir Redis pour ça serait de l'ingénierie pour le plaisir. Sur une app à fort trafic ou multi-instance, là, le store partagé devient non négociable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ne jamais faire confiance à ce qui entre
&lt;/h2&gt;

&lt;p&gt;Tout ce qui arrive d'un formulaire est hostile par défaut, jusqu'à preuve du contraire. Concrètement, deux gestes.&lt;/p&gt;

&lt;p&gt;D'abord, valider la forme. Un schéma Zod décrit ce que tu acceptes (longueurs minimales, format d'e-mail, champs obligatoires), et tu refuses tout le reste avant de toucher à quoi que ce soit. C'est l'application directe du fail-fast : une donnée non conforme s'arrête à la porte, elle ne se balade pas dans ta logique métier.&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;contactSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;recaptchaToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ensuite, échapper à la sortie. Mon serveur m'envoie un e-mail de notification en HTML à chaque message, avec le nom et le contenu interpolés dedans. Si j'injecte ces champs bruts dans le HTML, j'ouvre une injection : quelqu'un met une balise &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; ou une &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; dans le champ « message », et mon e-mail de notification se retrouve avec un faux lien ou une image piochée n'importe où. Pas le drame du siècle, mais c'est ma boîte de réception qui devient une surface d'attaque. La parade est triviale, échapper les cinq caractères qui comptent avant toute interpolation.&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;function&lt;/span&gt; &lt;span class="nf"&gt;escapeHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&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="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;amp;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;amp;&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;lt;&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;gt;&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/"/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;quot;&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/'/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;#39;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Valider ce qui entre, échapper ce qui sort. C'est la même règle vieille comme le web, appliquée à un canal qu'on oublie souvent de considérer comme tel : un e-mail rendu en HTML est une page web comme une autre.&lt;/p&gt;

&lt;h2&gt;
  
  
  Les secrets n'ont rien à faire dans le navigateur
&lt;/h2&gt;

&lt;p&gt;Dernier point, et c'est plus un piège de plomberie qu'une faille à proprement parler. Avec Next.js, toute variable d'environnement préfixée &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; est inlinée dans le bundle JavaScript envoyé au client. Visible par n'importe qui ouvrant les outils de développement. La clé publique reCAPTCHA, elle, est faite pour ça, aucun problème. Mais la clé secrète reCAPTCHA et la clé d'API d'envoi d'e-mails n'ont rien à faire là. Elles se lisent au runtime, côté serveur uniquement, et restent sans le préfixe &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;. Confondre les deux, c'est publier sa clé secrète à la vue de tous, et offrir à quelqu'un la possibilité d'envoyer des e-mails en ton nom. Le genre d'erreur qu'un scan de secrets dans la CI attrape, et c'est exactement pour ça qu'on en met un.&lt;/p&gt;

&lt;p&gt;Aucune de ces couches, isolée, ne rend un formulaire « sûr ». Le captcha serveur sans rate-limit laisse passer le flood. Le rate-limit sans validation laisse passer les saletés. La validation sans échappement laisse passer l'injection. C'est l'empilement qui fait le travail, et le plus dur n'est pas de les écrire, c'est de penser à toutes, et de ne pas saboter l'ensemble avec une béquille de dev oubliée. Le formulaire le plus banal d'un site est souvent celui qui mérite le plus qu'on l'audite, justement parce que personne ne le regarde.&lt;/p&gt;

&lt;p&gt;Si tu veux un œil extérieur sur le tien, &lt;a href="https://sergent.dev/contact" rel="noopener noreferrer"&gt;parlons-en&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>securite</category>
      <category>nextjs</category>
      <category>recaptcha</category>
      <category>rgpd</category>
    </item>
    <item>
      <title>React Native ou PWA : comment choisir pour une app métier</title>
      <dc:creator>Marius</dc:creator>
      <pubDate>Sun, 28 Jun 2026 12:50:52 +0000</pubDate>
      <link>https://dev.to/riusmax/react-native-ou-pwa-comment-choisir-pour-une-app-metier-3mcf</link>
      <guid>https://dev.to/riusmax/react-native-ou-pwa-comment-choisir-pour-une-app-metier-3mcf</guid>
      <description>&lt;p&gt;On me pose la question à l'envers presque à chaque fois. « Tu ferais ça en React Native ou en PWA ? », comme s'il fallait choisir un camp une bonne fois, défendre une techno contre l'autre. Je fais les deux, et justement parce que je fais les deux, je n'ai aucune fidélité. La vraie question n'est jamais « laquelle est la meilleure ». C'est « qu'est-ce que ton app doit faire, pour qui, et combien tu peux y mettre ». À partir de là, le choix se fait presque tout seul. Il y a trois ou quatre critères qui tranchent, et ils tranchent vite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Le matériel, c'est le critère qui décide en premier
&lt;/h2&gt;

&lt;p&gt;Si ton app doit parler à du matériel, la discussion est souvent close avant d'avoir commencé. Lecteur de badge NFC, capteur Bluetooth, balance connectée, terminal de paiement, caméra que tu pilotes finement (mise au point, exposition, flux brut), capteurs de mouvement précis. Dès qu'on descend à ce niveau, le web montre ses limites, et sur iPhone, c'est un mur.&lt;/p&gt;

&lt;p&gt;Concrètement : Web Bluetooth, Web NFC, WebUSB, Web Serial ne sont pas supportés sur iOS. Comme tous les navigateurs de l'iPhone tournent sur le moteur de Safari, ce n'est pas « Chrome iOS le fera » : aucun navigateur iOS n'y a accès, et Apple ne montre aucun signe de vouloir changer ça. Le code parle plus clair qu'un long paragraphe :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Sur Android, dans Chrome : navigator.bluetooth existe, tu scannes ton capteur.&lt;/span&gt;
&lt;span class="c1"&gt;// Sur iPhone, dans n'importe quel navigateur : undefined. Porte fermée.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bluetooth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;navigator&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;device&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bluetooth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestDevice&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="cm"&gt;/* filtres */&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// pas de plan B en pur web sur iOS&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sur Android, une PWA peut tout à fait dialoguer en BLE avec un appareil, et ça marche bien. Mais si ton app doit aussi tourner sur iPhone (et pour la plupart des clients, c'est non négociable), tu ne peux pas t'appuyer sur une API que la moitié de ton parc n'aura jamais. React Native, lui, passe par les briques natives via des modules éprouvés (BLE, NFC, caméra avancée), des deux côtés. Quand le matériel est au cœur du besoin, React Native ou le natif gagnent, et il n'y a même pas vraiment de débat.&lt;/p&gt;

&lt;p&gt;Une nuance honnête : la caméra « simple » (prendre une photo, scanner un QR code) marche très bien en web, sur iOS comme ailleurs. C'est le matériel branché, les protocoles bas niveau et le contrôle fin des capteurs qui font basculer vers le natif.&lt;/p&gt;

&lt;h2&gt;
  
  
  L'offline sérieux, l'autre point qui ne pardonne pas
&lt;/h2&gt;

&lt;p&gt;Il y a offline et offline. Une PWA gère parfaitement le « je consulte mes données dans le métro sans réseau » : un service worker, un cache, un peu d'IndexedDB, et l'app répond hors-ligne. Pour beaucoup d'apps métier, c'est suffisant.&lt;/p&gt;

&lt;p&gt;Puis il y a l'autre offline. Celui où il n'y a jamais de réseau, où l'app est le seul logiciel qui tourne, où elle doit encaisser des heures d'usage sans jamais recontacter un serveur. J'ai construit Prolog là-dessus : une app React Native qui dialogue en WiFi direct avec une sonde de forage et fait tourner un modèle de machine learning embarqué, sur une tablette Android durcie, à côté d'une foreuse, sans la moindre connexion internet. Le modèle est dans l'app, l'inférence se fait sur l'appareil, les mesures sont écrites en local. Une PWA n'aurait pas tenu ce cahier des charges, ne serait-ce que parce que sur iOS, des mécanismes comme le background sync ne sont pas là, et que faire tourner un modèle ML embarqué qui cause à un capteur en liaison directe, ce n'est pas un terrain pour le navigateur.&lt;/p&gt;

&lt;p&gt;Donc si ton app vit sur le terrain, sur un appareil dédié, qu'elle doit être fiable sans réseau et garder la main sur le système, penche React Native sans hésiter. Si ton « offline » se résume à du cache de confort pour un utilisateur de bureau ou un commercial en déplacement, la PWA fait le travail.&lt;/p&gt;

&lt;h2&gt;
  
  
  La distribution et le coût, là où la PWA reprend la main
&lt;/h2&gt;

&lt;p&gt;On a parlé des cas où le web cale. Le reste du temps, c'est lui qui mène, et largement.&lt;/p&gt;

&lt;p&gt;Une PWA, c'est une URL. Tu déploies, c'est en ligne, tout le monde y accède depuis son navigateur, et ta mise à jour est instantanée pour tous au rechargement suivant. Pas de soumission à l'App Store, pas de revue qui te bloque trois jours pour une virgule dans la description, pas de build à signer par plateforme, pas de compte développeur payant à renouveler chaque année, pas de version 1.2.4 que la moitié de tes utilisateurs n'installera jamais. Une seule base de code web qui touche le mobile, la tablette et le poste fixe. Pour démarrer, et surtout pour maintenir dans la durée, c'est nettement moins cher et moins lourd.&lt;/p&gt;

&lt;p&gt;React Native demande plus d'investissement, et il faut le dire franchement. Tu as deux stores à gérer, des builds à produire et à signer, parfois du code natif à écrire quand un module ne couvre pas ton besoin, et le cycle de validation des stores entre toi et tes utilisateurs. Ça se gère très bien (l'outillage autour d'Expo a beaucoup adouci tout ça), mais ça reste un coût réel, en temps et en argent, qu'une PWA n'a pas. Si la portée, le budget de départ et la simplicité de mise à jour sont tes priorités, et que rien dans la liste plus haut ne t'oblige à passer natif, commence par une PWA. Tu pourras toujours faire une app native plus tard, quand un vrai besoin la justifiera.&lt;/p&gt;

&lt;h2&gt;
  
  
  L'expérience perçue, plus souvent un faux argument qu'un vrai
&lt;/h2&gt;

&lt;p&gt;C'est l'argument qu'on me sort pour justifier le natif « par défaut » : ce sera plus fluide, plus beau, plus « vrai ». Parfois c'est juste. Sur des gestes complexes, des animations qui collent au système, une intégration profonde (partage natif, widgets, interactions fines), React Native est devant, et ça se sent.&lt;/p&gt;

&lt;p&gt;Mais regarde honnêtement ce que fait ton app métier. Des formulaires, des listes, des tableaux de bord, de la consultation, de la saisie, des filtres. Pour 80 % de ces écrans, une PWA soignée est indiscernable d'une app native pour l'utilisateur. Personne, dans la vraie vie, ne sort son téléphone pour deviner si l'app de saisie de son chantier est « native ou pas ». Il veut qu'elle s'ouvre vite, qu'elle ne plante pas, qu'elle réponde. Le réflexe « natif parce que c'est plus pro » coûte cher pour un gain que l'utilisateur final ne perçoit pas. Je le déconseille tant qu'aucun critère technique réel ne le commande.&lt;/p&gt;

&lt;h2&gt;
  
  
  iOS, le caillou dans la chaussure des PWA
&lt;/h2&gt;

&lt;p&gt;Soyons précis, parce que c'est là que se nichent les déceptions. Le point faible des PWA, ce n'est pas « le web », c'est iOS. Sur Android, l'expérience PWA est solide : installation proposée au bon moment, accès à pas mal de matériel, notifications. Sur iPhone, c'est plus rugueux.&lt;/p&gt;

&lt;p&gt;Les notifications push existent enfin sur iOS depuis Safari 16.4, mais à une condition : l'utilisateur doit avoir ajouté la PWA à son écran d'accueil. Tant que ton site vit dans un onglet Safari, pas de push, point. Et cet ajout à l'écran d'accueil, justement, reste un geste manuel : pas de bannière d'installation automatique comme sur Android, c'est à l'utilisateur d'aller le chercher dans le menu de partage. Autant dire qu'il faut le lui expliquer, et qu'une bonne partie ne le fera jamais. Ajoute à ça l'absence des API matérielles déjà citées et de certains mécanismes d'arrière-plan, et tu obtiens le vrai visage de la décision : une PWA, c'est excellent partout, et un peu bridé sur iPhone. Si ton public est massivement sur iOS et que tu as besoin de notifications fiables ou d'une présence forte sur l'écran d'accueil, ce frein doit peser dans ta balance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comment je tranche, en pratique
&lt;/h2&gt;

&lt;p&gt;Mon réflexe, concrètement : je pars sur une PWA quand la portée, le coût et la simplicité commandent, et que rien n'exige du matériel ni de l'offline dur. Je bascule sur React Native dès qu'il faut parler à un capteur, tenir sans réseau sur un appareil dédié, ou offrir une expérience vraiment native que l'usage justifie. Et il m'arrive de faire les deux sur le même projet : une PWA pour le grand public, qui touche tout le monde sans friction, et une app native pour les opérateurs terrain, qui ont besoin du matériel et du hors-ligne. Ce n'est pas un compromis bancal, c'est souvent la bonne réponse, chaque outil sur le terrain où il est imbattable.&lt;/p&gt;

&lt;p&gt;Le piège, c'est de choisir la techno avant d'avoir regardé le besoin. Commence par lister ce que ton app doit faire, vraiment, et la réponse sera déjà à moitié écrite.&lt;/p&gt;

&lt;p&gt;Tu hésites sur ton propre projet ? &lt;a href="https://sergent.dev/contact" rel="noopener noreferrer"&gt;Parlons-en&lt;/a&gt;, je te dirai honnêtement vers quoi je partirais.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>pwa</category>
      <category>mobile</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
