<?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: Krzysztof "Chris" Radomski</title>
    <description>The latest articles on DEV Community by Krzysztof "Chris" Radomski (@krzysztofradomski).</description>
    <link>https://dev.to/krzysztofradomski</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%2F3925844%2F1a1ad225-16a3-441d-83da-619e46db6e5c.jpeg</url>
      <title>DEV Community: Krzysztof "Chris" Radomski</title>
      <link>https://dev.to/krzysztofradomski</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/krzysztofradomski"/>
    <language>en</language>
    <item>
      <title>Developing Payload CMS plugins</title>
      <dc:creator>Krzysztof "Chris" Radomski</dc:creator>
      <pubDate>Wed, 01 Jul 2026 22:19:35 +0000</pubDate>
      <link>https://dev.to/krzysztofradomski/developing-payload-cms-plugins-28nm</link>
      <guid>https://dev.to/krzysztofradomski/developing-payload-cms-plugins-28nm</guid>
      <description>&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%2Fm6fyw1vhd2n7l270yw0d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm6fyw1vhd2n7l270yw0d.png" alt="close up of the editor menu button" width="800" height="108"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A practical guide to building, testing, and shipping Payload 3.x plugins from a real visual editor plugin.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://payloadcms.com/docs" rel="noopener noreferrer"&gt;Payload&lt;/a&gt; plugins look simple at first: take a config in, return a config out. That model is a gift. It means a plugin can add fields, endpoints, admin components, hooks, collections, and editor behavior from a small touch point.&lt;/p&gt;

&lt;p&gt;The tricky part is not the plugin function itself. The tricky part is everything around it: local development, import maps, package exports, testing, HMR, and the difference between source code and the code your users install.&lt;/p&gt;

&lt;p&gt;This article is based on lessons from building &lt;a href="https://github.com/krzysztofradomski/payload-visual-editor" rel="noopener noreferrer"&gt;payload-visual-editor&lt;/a&gt;, a Payload plugin experiment for visual editing inside live previews. The repo follows the &lt;a href="https://github.com/payloadcms/payload/tree/3.x/templates/plugin" rel="noopener noreferrer"&gt;official Payload plugin template&lt;/a&gt;, but the most useful lessons came from the rough edges.&lt;/p&gt;

&lt;h3&gt;
  
  
  Start with the plugin shape
&lt;/h3&gt;

&lt;p&gt;Payload plugins are config transformers. The common shape is a curried function: plugin options first, incoming Payload config second.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Plugin&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;payload&lt;/span&gt;&lt;span class="dl"&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;myPlugin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pluginOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MyPluginConfig&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Plugin&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;incomingConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Config&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="na"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&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="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;incomingConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;collections&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...(&lt;/span&gt;&lt;span class="nx"&gt;incomingConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;collections&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[])],&lt;/span&gt;
      &lt;span class="na"&gt;endpoints&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[...(&lt;/span&gt;&lt;span class="nx"&gt;incomingConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endpoints&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[])],&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Extend collections, fields, endpoints, hooks, or admin config.&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;pluginOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important habit is to preserve what the app already has. Spread existing arrays. Keep existing hooks. Call an existing onInit before adding your own initialization. Treat the user app as the owner of the config.&lt;/p&gt;

&lt;p&gt;That sounds obvious, but it is where many plugin bugs begin. A plugin should compose with the host app. It should not quietly replace parts of it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep source, dev, and published output separate
&lt;/h3&gt;

&lt;p&gt;In this repo, the plugin lives in three worlds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;payload-plugin-visual-editor/
|-- src/ # Plugin source
|-- dev/ # Full Payload + Next.js app for development
`-- dist/ # Compiled package output
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each world has a different job. src/ is the code you write. dev/ is a real Payload app that consumes the plugin while you build it. dist/ is the package your users install.&lt;/p&gt;

&lt;p&gt;That separation keeps the feedback loop fast without lying about what you are shipping. During normal development, the dev app points at src/. Before publishing, the build proves that dist/ contains the files consumers will import.&lt;/p&gt;

&lt;h3&gt;
  
  
  Avoid a self-link dependency
&lt;/h3&gt;

&lt;p&gt;One tempting local setup is to make the dev app install the plugin through a self-reference like link:.. It works locally, but it makes the package model muddy.&lt;/p&gt;

&lt;p&gt;The cleaner approach is to let the dev app resolve the package name to source files. In the example repo, that takes three small pieces.&lt;/p&gt;

&lt;p&gt;First, dev/tsconfig.json maps the package name and public subpaths to files under src/. Second, dev/next.config.mjs maps the same package subpaths at runtime for Next.js. Third, dev/payload.config.ts imports the plugin entry directly, because Payload’s CLI runs under Node and does not know about Next.js aliases.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;payloadVisualEditor&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;../src/index.js&lt;/span&gt;&lt;span class="dl"&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;buildConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;payloadVisualEditor&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;collections&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})],&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you a fast local loop. You can edit TypeScript in src/, refresh the dev app, and avoid rebuilding the package after every change. Use pnpm build when you want to validate the published artifact.&lt;/p&gt;

&lt;h3&gt;
  
  
  Be explicit about public entry points
&lt;/h3&gt;

&lt;p&gt;A Payload plugin usually has more than one kind of code. The plugin function is server-safe config code. Admin UI components may be client components. Some admin components can be React Server Components.&lt;/p&gt;

&lt;p&gt;Those should not all be exported through one door. The package in this repo exposes separate entry points.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exports"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"."&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/index.js"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"./client"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/exports/client.js"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"./rsc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./dist/exports/rsc.js"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The main export is for the plugin function and shared types. The /client export is for components and hooks with 'use client'. The /rsc export is for server components used in admin slots that support them.&lt;/p&gt;

&lt;p&gt;This makes your package easier to reason about. It also helps consumers avoid accidentally pulling client-only code into server config.&lt;/p&gt;

&lt;h3&gt;
  
  
  Admin components are resolved by path string
&lt;/h3&gt;

&lt;p&gt;Payload admin components are not usually direct imports in your config. They are identified by component paths that Payload resolves while building the admin panel. The &lt;a href="https://payloadcms.com/docs/custom-components/overview" rel="noopener noreferrer"&gt;Payload custom components docs&lt;/a&gt; cover the model in detail.&lt;/p&gt;

&lt;p&gt;In a plugin, the path often points to your package name and a named export.&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="nx"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;components&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;edit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;beforeDocumentControls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;components&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;edit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;beforeDocumentControls&lt;/span&gt; &lt;span class="o"&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;payload-plugin-visual-editor/client#VisualEditorAdmin&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;That string has to line up with your package exports.&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/exports/client.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;VisualEditorAdmin&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;../admin/VisualEditorAdmin.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the string says payload-plugin-visual-editor/client#VisualEditorAdmin, then your dev aliases must also understand payload-plugin-visual-editor/client. The generated import map must understand it too. That is why import maps deserve their own workflow.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm generate:types
pnpm generate:importmap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Regenerate the import map whenever you add, rename, remove, or move admin components. If an admin component stops rendering after a rename, suspect the import map first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Configure Next.js for the dev app, not for the published package
&lt;/h3&gt;

&lt;p&gt;The dev app in this repo uses @payloadcms/next/withPayload with a normal &lt;a href="https://nextjs.org/docs/app/api-reference/config/next-config-js" rel="noopener noreferrer"&gt;Next.js config&lt;/a&gt;. Because the dev app aliases the package name to src/, Next.js compiles those files as local source.&lt;/p&gt;

&lt;p&gt;That means the dev app does not need transpilePackages for this plugin during local development. Consumers install the built package from dist/. For them, transpilePackages should be unnecessary unless you see a real parse error in a downstream Next.js app.&lt;/p&gt;

&lt;p&gt;That distinction matters. Do not ask every consumer to carry workaround config just because your development setup needed aliases.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cache in-memory MongoDB across HMR
&lt;/h3&gt;

&lt;p&gt;The most surprising bug in this setup came from the database. For local development and tests, the repo can run with &lt;a href="https://github.com/typegoose/mongodb-memory-server" rel="noopener noreferrer"&gt;mongodb-memory-server&lt;/a&gt; instead of a persistent DATABASE_URL.&lt;/p&gt;

&lt;p&gt;That is convenient, but there is a trap when Payload runs inside next dev. Next.js Hot Module Replacement can re-evaluate payload.config.ts. Turbopack can also reset module caches.&lt;/p&gt;

&lt;p&gt;If the config creates a fresh in-memory MongoDB instance on every load, each HMR cycle gets a new empty database. Seeded content disappears. The frontend may show stale or empty state.&lt;/p&gt;

&lt;p&gt;The fix is to cache the memory server URI on globalThis for the life of the Node process.&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;globalStore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;globalThis&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;_mongoMemoryDBUri&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;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;globalStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_mongoMemoryDBUri&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;memoryDB&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;MongoMemoryReplSet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;replSet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dbName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payloadmemory&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="nx"&gt;globalStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_mongoMemoryDBUri&lt;/span&gt; &lt;span class="o"&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;memoryDB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUri&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;retryWrites=true`&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;DATABASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;globalStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_mongoMemoryDBUri&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use MongoMemoryReplSet, not a standalone memory server, when testing transactional behavior. MongoDB transactions require a replica set. Use a real DATABASE_URL in dev/.env when you want data to survive full process restarts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test at the right layer
&lt;/h3&gt;

&lt;p&gt;Good plugin tests are layered. Use &lt;a href="https://vitest.dev/guide/" rel="noopener noreferrer"&gt;Vitest&lt;/a&gt; unit tests for pure logic in src/. Those tests should be fast and free of Payload bootstrapping when possible.&lt;/p&gt;

&lt;p&gt;Use integration tests when you need a real Payload instance. That includes endpoints, schema transformations, hooks, and Local API behavior.&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="nf"&gt;beforeAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;payload&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;getPayload&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="nf"&gt;afterAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;destroy&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;Always tear down the database in afterAll. That is especially important with in-memory MongoDB and parallel test workers.&lt;/p&gt;

&lt;p&gt;Use &lt;a href="https://playwright.dev/docs/intro" rel="noopener noreferrer"&gt;Playwright&lt;/a&gt; for behavior that depends on real browser APIs. Selection, contenteditable, live preview iframes, drag-and-drop, and focus handling are all browser problems. Do not pretend they are unit-test problems.&lt;/p&gt;

&lt;p&gt;For this plugin, E2E tests run serially because the dev app shares an in-memory database. That tradeoff is worth it. It avoids races while testing the behavior users actually touch.&lt;/p&gt;

&lt;h3&gt;
  
  
  The live preview lesson
&lt;/h3&gt;

&lt;p&gt;The most interesting part of this plugin is not the package setup. It is the live preview editing model.&lt;/p&gt;

&lt;p&gt;Visual editing means touching real DOM inside a preview while Payload is also pushing document updates. That creates a sharp edge. If the user is typing inside a contenteditable region and the preview re-renders, React can wipe the DOM node.&lt;/p&gt;

&lt;p&gt;When that happens, the cursor jumps, selection disappears, and uncommitted text can be lost. The fix is to pause preview updates while edit mode is active. Queue the latest incoming preview data, then flush it when edit mode ends.&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;pendingRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;DevPost&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;editingRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isVisualEditing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nx"&gt;editingRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;isVisualEditing&lt;/span&gt;

&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;:&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;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;editingRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;pendingRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nf"&gt;setPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="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;isVisualEditing&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;pendingRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pendingRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;pendingRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isVisualEditing&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That pattern belongs in plugin documentation. The plugin can provide tools and conventions, but the consumer preview page still controls how live data enters React state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prefer field-scoped editable regions
&lt;/h3&gt;

&lt;p&gt;There is another DOM lesson hiding in visual editing. Not every editable boundary feels natural.&lt;/p&gt;

&lt;p&gt;Wrapping individual words in spans breaks native text selection. Making the entire page editable lets users delete boundaries between fields. The better compromise is to mark field-level regions.&lt;/p&gt;

&lt;p&gt;Each editable region maps to one Payload field path. Inside that region, the browser can handle selection naturally. Outside it, the document structure remains protected.&lt;/p&gt;

&lt;p&gt;For rich text, choose the largest valid wrapping block. Early versions matched the smallest leaf node, so each paragraph became its own editable island. That made drag selection across paragraphs awkward. It also made Cmd+A feel wrong.&lt;/p&gt;

&lt;p&gt;Choosing the largest valid field wrapper made the rich text field behave like one editing surface. That kind of detail is easy to miss in an API design. It is painfully obvious when you test in a real browser.&lt;/p&gt;

&lt;h3&gt;
  
  
  Build before you publish
&lt;/h3&gt;

&lt;p&gt;The publishing flow should prove that the package works as a package.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm clean
pnpm build
pnpm &lt;span class="nb"&gt;test
&lt;/span&gt;pnpm check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The build should emit declarations and compiled JavaScript into dist/. The package should publish dist/, not src/ or the dev app.&lt;/p&gt;

&lt;p&gt;Check peerDependencies before publishing. Payload belongs in peerDependencies, because the host app owns its Payload version. Your dev app can keep @payloadcms/ui, database adapters, Next.js, and test tooling in devDependencies.&lt;/p&gt;

&lt;p&gt;Before opening a PR, I like to check this list.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pnpm build succeeds.&lt;/li&gt;
&lt;li&gt;dist/ contains the files from the package exports map.&lt;/li&gt;
&lt;li&gt;Types are regenerated when schema changes.&lt;/li&gt;
&lt;li&gt;The import map is regenerated when admin components change.&lt;/li&gt;
&lt;li&gt;Unit, integration, and E2E tests pass.&lt;/li&gt;
&lt;li&gt;Typecheck and lint pass.&lt;/li&gt;
&lt;li&gt;The README explains install, config, exports, and any required consumer setup.&lt;/li&gt;
&lt;li&gt;Breaking changes are called out.&lt;/li&gt;
&lt;li&gt;The Payload peer dependency range is intentional.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Closing thought
&lt;/h3&gt;

&lt;p&gt;Payload plugins are pleasant because the core abstraction is small. Most real-world complexity comes from making that small abstraction behave well inside a full app, a dev server, a package manager, and a browser.&lt;/p&gt;

&lt;p&gt;The best plugin setup keeps those boundaries visible. Source code is not the package. The dev app is not the consumer app. Admin component paths are not regular imports. Live preview state is not normal page state.&lt;/p&gt;

&lt;p&gt;Once those boundaries are clear, the work gets much calmer. You can focus on the plugin behavior instead of wrestling the toolchain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Further reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Example repo: &lt;a href="https://github.com/krzysztofradomski/payload-visual-editor" rel="noopener noreferrer"&gt;payload-visual-editor&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://payloadcms.com/docs/plugins/overview" rel="noopener noreferrer"&gt;Payload plugin docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://payloadcms.com/docs/custom-components/overview" rel="noopener noreferrer"&gt;Payload custom components docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/payloadcms/payload/tree/3.x/templates/plugin" rel="noopener noreferrer"&gt;Official Payload plugin template&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/payloadcms/payload/tree/3.x/packages" rel="noopener noreferrer"&gt;Official Payload plugin packages&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nextjs.org/docs/app/api-reference/config/next-config-js" rel="noopener noreferrer"&gt;Next.js config docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://vitest.dev/guide/" rel="noopener noreferrer"&gt;Vitest&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://playwright.dev/docs/intro" rel="noopener noreferrer"&gt;Playwright&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/typegoose/mongodb-memory-server" rel="noopener noreferrer"&gt;mongodb-memory-server&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>payloadcms</category>
      <category>payload</category>
      <category>wysiwyg</category>
      <category>nextjstutorial</category>
    </item>
    <item>
      <title>Locking down a public write endpoint on Cloudflare</title>
      <dc:creator>Krzysztof "Chris" Radomski</dc:creator>
      <pubDate>Mon, 11 May 2026 21:21:25 +0000</pubDate>
      <link>https://dev.to/krzysztofradomski/locking-down-a-public-write-endpoint-on-cloudflare-4h50</link>
      <guid>https://dev.to/krzysztofradomski/locking-down-a-public-write-endpoint-on-cloudflare-4h50</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvtyseh3wroj02tcfx2sx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvtyseh3wroj02tcfx2sx.png" width="800" height="834"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Online scoreboard — locking down a public api endpoint.
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://math4kids.paperplane.builders/" rel="noopener noreferrer"&gt;&lt;strong&gt;Math4Kids&lt;/strong&gt;&lt;/a&gt; is a small timed math quiz I built for fun and learning. And because this is the second time I’ve built a small game hosted on Cloudflare, I’m writing down how I handled the sensitive parts, as much for my own reference as anyone else’s..&lt;/p&gt;

&lt;p&gt;Once you put such a public write endpoint on the internet, a few questions show up. Who can post a score? How do you stop bots from flooding the board? How honest should you be about what you can actually guarantee?&lt;/p&gt;

&lt;p&gt;The text is divided into 3 main parts: what is implemented, what is skipped, and what I accepted.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;What I was worried about&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;Bots spamming fake scores. Someone breaking the API with a weird payload. Leaking something embarrassing in an error message. Secret keys ending up in the client bundle. Even if this is just a playground, common sense still applies, and the lesson learned here will compound into better instincts for future projects.&lt;/p&gt;

&lt;p&gt;What I wasn’t worried about: cryptographically proving a human sat down and played the game. Anything running in the browser can be faked. I know this. I accepted it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The stack&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Next.js (edge runtime) deployed to Cloudflare Pages via &lt;a href="[https://github.com/cloudflare/next-on-pages](https://github.com/cloudflare/next-on-pages)"&gt;&lt;code&gt;[@cloudflare/next-on-pages](http://twitter.com/cloudflare/next-on-pages)&lt;/code&gt;&lt;/a&gt;. Scores live in Cloudflare D1. There’s an in-memory fallback when no D1 binding is present, so plain &lt;code&gt;next dev&lt;/code&gt; still works.&lt;/p&gt;

&lt;p&gt;All the security logic sits in two route handlers: &lt;code&gt;GET/POST /api/scores&lt;/code&gt; and &lt;code&gt;POST /api/session&lt;/code&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  Layer 1: Route handler hygiene
&lt;/h4&gt;

&lt;p&gt;The boring stuff first.&lt;/p&gt;

&lt;p&gt;SQL is parameterized via &lt;code&gt;prepare().bind()&lt;/code&gt;, so usernames never get concatenated into a query string. Request bodies over 8 KB get rejected with HTTP 413 before &lt;code&gt;JSON.parse&lt;/code&gt; ever sees them. Usernames are 1–32 chars with no ASCII control characters. Scores must be finite integers in &lt;code&gt;[0, 500]&lt;/code&gt;, a fast human probably can’t score more in a 60-second round.&lt;/p&gt;

&lt;p&gt;Three per-IP rate limiters run in isolate memory: 120 req/min on &lt;code&gt;GET /api/scores&lt;/code&gt;, 15 on &lt;code&gt;POST /api/scores&lt;/code&gt;, 30 on &lt;code&gt;POST /api/session&lt;/code&gt;. The client always gets a generic 500 on errors; real details stay in logs.&lt;/p&gt;

&lt;p&gt;CORS is an exact-origin allowlist (&lt;code&gt;ALLOWED_ORIGINS&lt;/code&gt; is a comma-separated env var). First-party requests, where the Origin matches the handler’s own URL, are always allowed (fixes e.g. preview branches).&lt;/p&gt;

&lt;p&gt;Every response sets &lt;code&gt;Cache-Control: no-store&lt;/code&gt; and &lt;code&gt;CDN-Cache-Control: no-store&lt;/code&gt;. Without those, Cloudflare will happily cache the empty-leaderboard response on first request and you’ll spend an hour wondering why scores aren’t showing up. If you forget these, there is always manual cache busting to the rescue.&lt;/p&gt;

&lt;p&gt;Reads are public. Writes are where the rules live.&lt;/p&gt;

&lt;h4&gt;
  
  
  Layer 2: Turnstile
&lt;/h4&gt;

&lt;p&gt;I’m using the invisible widget. The site key goes in &lt;code&gt;NEXT_PUBLIC_TURNSTILE_SITE_KEY&lt;/code&gt; (Next.js public env, fine to expose), the secret goes into Pages secrets via &lt;code&gt;wrangler secret put&lt;/code&gt;. Server-side verification uses &lt;code&gt;CF-Connecting-IP&lt;/code&gt; as &lt;code&gt;remoteip&lt;/code&gt;, falling back to &lt;code&gt;X-Forwarded-For&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The interesting bit is what gets checked beyond the basic &lt;code&gt;success&lt;/code&gt; flag. The handler also asserts that &lt;code&gt;data.action === “score_submit”&lt;/code&gt;, which rejects any token minted by a widget that doesn’t carry that action (including widgets running on other sites). It checks &lt;code&gt;data.hostname&lt;/code&gt; against the configured &lt;code&gt;ALLOWED_ORIGINS&lt;/code&gt; hostnames too (not in local dev).&lt;/p&gt;

&lt;p&gt;The widget uses &lt;code&gt;execution: “execute”&lt;/code&gt; and &lt;code&gt;action: “score_submit”&lt;/code&gt;, which means it doesn’t run on render. The client triggers it once, shortly after mount, to proactively mint a session JWT so the first score submit is instant. Low friction for real users, real friction for unattended scripts.&lt;/p&gt;

&lt;p&gt;It does not prove a human played the game. It just makes “run a curl loop” stop being viable.&lt;/p&gt;

&lt;h4&gt;
  
  
  Layer 3: Session JWTs so Turnstile isn’t per-request
&lt;/h4&gt;

&lt;p&gt;Verifying a Turnstile token on every score post would be slow and irritating. So there’s a short-lived HS256 session JWT, minted in the route handler with the Web Crypto API. No library required for a payload this small.&lt;/p&gt;

&lt;p&gt;The flow:&lt;/p&gt;

&lt;blockquote&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;POST /api/session&lt;/code&gt; validates a Turnstile token (when &lt;code&gt;TURNSTILE_SECRET_KEY&lt;/code&gt; is set) and returns &lt;code&gt;{ sessionToken }&lt;/code&gt;. If &lt;code&gt;SESSION_SIGNING_SECRET&lt;/code&gt; isn’t configured, the endpoint returns 404 and is effectively disabled.
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /api/scores&lt;/code&gt; accepts a valid session JWT in place of a Turnstile token. With both secrets configured, the JWT is tried first; a fresh Turnstile token is accepted as fallback in case the session-mint request fails.
&lt;/li&gt;
&lt;li&gt;The client caches the token in a React ref. Not localStorage, because I don’t want it surviving a reload. It refreshes 45 seconds before &lt;code&gt;exp&lt;/code&gt; and retries once on a 401.&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;

&lt;p&gt;Payload looks like &lt;code&gt;{ v: 1, typ: “math4_score_sess”, jti, iat, exp }&lt;/code&gt;, signed HS256 with &lt;code&gt;SESSION_SIGNING_SECRET&lt;/code&gt;. Default TTL is 300 seconds, configurable via &lt;code&gt;SESSION_TTL_SEC&lt;/code&gt; and clamped to 120–900. The &lt;code&gt;jti&lt;/code&gt; is a random UUID, but it isn’t tracked server-side, so a token is reusable for its full TTL window. More on that in the gaps section.&lt;/p&gt;

&lt;h4&gt;
  
  
  Layer 4: The client-side submission gate
&lt;/h4&gt;

&lt;p&gt;The &lt;code&gt;SubmitScoreScreen&lt;/code&gt; component checks &lt;code&gt;scores.length &amp;lt; 10 || score &amp;gt; scores[9]?.score&lt;/code&gt; before showing the name input. If your run wouldn’t crack the top 10, you see a “you didn’t make it” screen instead of a form.&lt;/p&gt;

&lt;p&gt;This isn’t security. It’s protection against accidents. Someone with devtools open for five minutes can bypass it, and that’s fine. The point is to keep the experience clean for honest players, who are the overwhelming majority of anyone who’ll ever touch this thing.&lt;/p&gt;

&lt;h4&gt;
  
  
  Secret hygiene
&lt;/h4&gt;

&lt;p&gt;Three categories, three handling rules:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;NEXT_PUBLIC_TURNSTILE_SITE_KEY&lt;/code&gt; lives in &lt;code&gt;wrangler.toml [vars]&lt;/code&gt;. It’s public by design; committing it is correct.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TURNSTILE_SECRET_KEY&lt;/code&gt; and &lt;code&gt;SESSION_SIGNING_SECRET&lt;/code&gt; go through &lt;code&gt;wrangler secret put&lt;/code&gt; in production, &lt;code&gt;.dev.vars&lt;/code&gt; for &lt;code&gt;wrangler pages dev&lt;/code&gt;, or &lt;code&gt;.env.local&lt;/code&gt; for plain &lt;code&gt;next dev&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There’s an &lt;code&gt;.env.example&lt;/code&gt; documenting which is which and how to mint the signing secret (&lt;code&gt;openssl rand -base64 48&lt;/code&gt; does it).&lt;/p&gt;

&lt;p&gt;The line between &lt;code&gt;wrangler.toml [vars]&lt;/code&gt; (committed config, public) and &lt;code&gt;wrangler secret&lt;/code&gt; (encrypted, never in git) is the thing to get right. Everything else is detail.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I accepted
&lt;/h3&gt;

&lt;p&gt;A few honest gaps:&lt;/p&gt;

&lt;p&gt;Scores can be faked. There’s no server-side simulation of the game. The leaderboard reflects what clients report, not what they played.&lt;/p&gt;

&lt;p&gt;Session tokens are reusable within their TTL. The &lt;code&gt;jti&lt;/code&gt; is minted but not tracked, so a single valid token can submit several scores until it expires (5 minutes by default). For a hobby game that’s fine. For anything with stakes you’d want a server-side store.&lt;/p&gt;

&lt;p&gt;Per-isolate rate limits aren’t global. Workers run in multiple isolates across edge nodes, so a distributed flood will get past the limiter. If you need genuinely global rate limiting, that’s Durable Objects or Cloudflare’s rate limiting product, not a Map in isolate memory.&lt;/p&gt;

&lt;p&gt;Client-side controls are client-side controls. The submission gate is honest UX, not a barrier.&lt;/p&gt;

&lt;p&gt;The read API is public and is staying that way. Someone scraping the top 10 isn’t a problem I plan to solve.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I’d add if the stakes went up
&lt;/h3&gt;

&lt;p&gt;A server-side &lt;code&gt;jti&lt;/code&gt; blocklist would close the reusable-token gap. Use a KV namespace with a TTL matching &lt;code&gt;SESSION_TTL_SEC&lt;/code&gt;. &lt;code&gt;POST /api/session&lt;/code&gt; writes the ID; &lt;code&gt;POST /api/scores&lt;/code&gt; rejects any &lt;code&gt;jti&lt;/code&gt; already in the store. One score per session token. KV’s eventual consistency is good enough for this; the occasional duplicate inside a narrow race window doesn’t matter for a leaderboard.&lt;/p&gt;

&lt;p&gt;A Durable Object rate limiter would replace the per-isolate Maps. Single object keyed on IP, single atomic counter, genuinely global. Worth the complexity if distributed abuse becomes a real pattern.&lt;/p&gt;

&lt;p&gt;A signed score payload would close the obvious cheating vector partway. At the end of a game, the server issues a short-lived token that commits to &lt;code&gt;{ score, timestamp }&lt;/code&gt;. The client sends that on submit; the server verifies it’s the same token it issued. This doesn’t stop someone who reverse-engineers the game flow, but it does stop arbitrary number injection without a prior server round-trip.&lt;/p&gt;

&lt;p&gt;An admin panel would be handy: even an admin-only delete endpoint, something as simple as &lt;code&gt;DELETE /api/scores/:id&lt;/code&gt; behind a bearer token, removes the need to open a D1 console to clean up.&lt;/p&gt;

&lt;p&gt;Structured logging into Cloudflare Logpush, or a tail worker dumping into R2, gives you something to actually query when the board starts looking weird. A burst of submissions from one IP, all just above the #10 threshold, is exactly the kind of pattern you want to be able to see.&lt;/p&gt;

&lt;h3&gt;
  
  
  TL;DR
&lt;/h3&gt;

&lt;p&gt;Parameterize your SQL. Cap your payloads. Validate your inputs. Bind Turnstile tokens to the action and hostname you expect. Keep secrets out of the bundle. Be honest about what you can guarantee.&lt;/p&gt;

&lt;p&gt;It’s a toy, so I didn’t overbuild it.. I also didn’t leave the door open.&lt;/p&gt;

&lt;p&gt;If you find something broken in my deployment, please reach out privately.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>digitalscoreboard</category>
      <category>nextjs</category>
      <category>publicapi</category>
    </item>
    <item>
      <title>I Built a Free, No-Account Drawing App. Here’s the Tech Stack.</title>
      <dc:creator>Krzysztof "Chris" Radomski</dc:creator>
      <pubDate>Tue, 09 Sep 2025 14:46:04 +0000</pubDate>
      <link>https://dev.to/krzysztofradomski/i-built-a-free-no-account-drawing-app-heres-the-tech-stack-2ii5</link>
      <guid>https://dev.to/krzysztofradomski/i-built-a-free-no-account-drawing-app-heres-the-tech-stack-2ii5</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F57135b6v8nz5149ls45u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F57135b6v8nz5149ls45u.png" width="800" height="709"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I’ve launched a free web app for instant visual communication: &lt;strong&gt;draw.paperplane.builders&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It solves a simple problem: sharing a sketch shouldn’t require an account or saving files. My solution is to store the entire vector drawing directly in the URL. You draw, copy the link, and share. That’s it. It’s a free tool, and there is no need to use an account to share drawings because they are stored in the URL.&lt;/p&gt;

&lt;p&gt;Here is the tech stack that made it possible to build this so quickly.&lt;/p&gt;

&lt;h4&gt;
  
  
  Built with Next.js and TypeScript
&lt;/h4&gt;

&lt;p&gt;The application’s foundation is built on # &lt;strong&gt;Nextjs&lt;/strong&gt; and # &lt;strong&gt;TypeScript&lt;/strong&gt;. This stack was chosen to ensure a robust, scalable, and maintainable codebase. Next.js provided the familiar and easy to use framework, while TypeScript added the type-safety needed to manage complexity — providing strong contracts between various moving parts of this app.&lt;/p&gt;

&lt;h4&gt;
  
  
  A Final Touch with Rough.js
&lt;/h4&gt;

&lt;p&gt;For the visual style, I used # &lt;strong&gt;Roughjs&lt;/strong&gt;. It’s a interesting JavaScript library that gives the drawings a sketchy, hand-drawn feel. This ensured a unique visual identity — paper like — without the need to build some hacky solution, or even worse, a complex rendering engine from scratch by myself :)&lt;/p&gt;

&lt;h4&gt;
  
  
  Instant Deployment with Cloudflare
&lt;/h4&gt;

&lt;p&gt;Deployment was handled by # &lt;strong&gt;Cloudflare&lt;/strong&gt;. Pushing the code live on a global network took minutes, not hours, and for a project this size, it’s completely free. No server overhead, just a few minutes spend on tools and dns configs. More decision making than actual work.&lt;/p&gt;

&lt;h4&gt;
  
  
  Ethical Analytics with Self-Hosted Umami
&lt;/h4&gt;

&lt;p&gt;To understand usage patterns without compromising user privacy, I used a self-hosted instance of # &lt;strong&gt;Umami&lt;/strong&gt;. It’s a lightweight, open-source analytics tool that provides the insights I need while respecting my users.&lt;/p&gt;

&lt;h4&gt;
  
  
  The Result
&lt;/h4&gt;

&lt;p&gt;The result is a tool that is fast, private, and completely free to use. It’s built on the idea that anyone can build anything, you only need some time and Javascript.&lt;/p&gt;

&lt;p&gt;As for the business model, enough to say I give it 2 out of 3. It’s cheap, and it’s free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try it for yourself and share a sketch with&lt;/strong&gt; &lt;a href="https://draw.paperplane.builders/" rel="noopener noreferrer"&gt;&lt;strong&gt;https://draw.paperplane.builders&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>selfhost</category>
      <category>umami</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>How I Used GitHub Actions to Never Miss a Steam Deck Restock</title>
      <dc:creator>Krzysztof "Chris" Radomski</dc:creator>
      <pubDate>Mon, 05 May 2025 20:38:51 +0000</pubDate>
      <link>https://dev.to/krzysztofradomski/how-i-used-github-actions-to-never-miss-a-steam-deck-restock-8b7</link>
      <guid>https://dev.to/krzysztofradomski/how-i-used-github-actions-to-never-miss-a-steam-deck-restock-8b7</guid>
      <description>&lt;p&gt;&lt;strong&gt;The frustration is universal.&lt;/strong&gt; You’ve got your heart set on that impossible-to-find item — maybe it’s a high-end graphics card, those limited-edition sneakers, or in my case, a Steam Deck OLED. The traditional approach to nabbing one is soul-crushing: obsessively refreshing pages, setting calendar reminders, and inevitably experiencing that gut-wrenching feeling when you realize you forgot to check on the &lt;em&gt;exact day&lt;/em&gt; it came back in stock.&lt;/p&gt;

&lt;p&gt;While hunting for a refurbished Steam Deck OLED, I remembered stumbling across a post about someone who automated bike promotion alerts using Puppeteer. &lt;em&gt;Lightbulb moment!&lt;/em&gt; If they could automate their hunt, why couldn’t I? But the question remained — where should I run this solution? Buy a VPS? There had to be a more elegant (khem, cheap!) approach.&lt;/p&gt;

&lt;h3&gt;
  
  
  Enter GitHub Actions: The Free Automation Tool You’re Probably Overlooking
&lt;/h3&gt;

&lt;p&gt;Most web scraping solutions force you into one of two unpleasant corners: pay for a service or manage your own server. But there’s a surprisingly powerful alternative hidden in plain sight — GitHub Actions! It’s completely free (with a generous 2000 minutes per month per user), requires zero server maintenance, and already has email notifications baked right in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;  — &lt;a href="https://github.com/krzysztofradomski/scraper/blob/main/.github/workflows/steamdeck.yml" rel="noopener noreferrer"&gt;Here&lt;/a&gt; and below is the complete code that can transform your shopping slash scrapping frustrations:&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;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;*/60&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*'&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;scheduled&lt;/span&gt;&lt;span class="pi"&gt;:&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="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&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;Check out this repo&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;actions/checkout@v4&lt;/span&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;Set up Node.js&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;actions/setup-node@v3&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;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;16'&lt;/span&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;Install Puppeteer&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm install puppeteer&lt;/span&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;Create Node.js script for Puppeteer&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|-&lt;/span&gt;
        &lt;span class="s"&gt;cat &amp;lt;&amp;lt; 'EOF' &amp;gt; fetchPage.js&lt;/span&gt;
        &lt;span class="s"&gt;const fs = require("fs");&lt;/span&gt;
        &lt;span class="s"&gt;const puppeteer = require("puppeteer");&lt;/span&gt;

        &lt;span class="s"&gt;(async () =&amp;gt; {&lt;/span&gt;
          &lt;span class="s"&gt;try {&lt;/span&gt;
            &lt;span class="s"&gt;const browser = await puppeteer.launch({&lt;/span&gt;
              &lt;span class="s"&gt;args: ["--no-sandbox", "--disable-setuid-sandbox"],&lt;/span&gt;
            &lt;span class="s"&gt;});&lt;/span&gt;
            &lt;span class="s"&gt;const page = await browser.newPage();&lt;/span&gt;
            &lt;span class="s"&gt;await page.goto(&lt;/span&gt;
              &lt;span class="s"&gt;"https://store.steampowered.com/sale/steamdeckrefurbished/",&lt;/span&gt;
              &lt;span class="s"&gt;{ waitUntil: "networkidle0" }&lt;/span&gt;
            &lt;span class="s"&gt;);&lt;/span&gt;

            &lt;span class="s"&gt;await page.focus("div.CartBtn");&lt;/span&gt;
            &lt;span class="s"&gt;const btns = await page.$$("div.CartBtn");&lt;/span&gt;

            &lt;span class="s"&gt;let result = [];&lt;/span&gt;
            &lt;span class="s"&gt;for (let t of btns) {&lt;/span&gt;
              &lt;span class="s"&gt;result.push(&lt;/span&gt;
                &lt;span class="s"&gt;await t.evaluate(&lt;/span&gt;
                  &lt;span class="s"&gt;(x) =&amp;gt;&lt;/span&gt;
                    &lt;span class="s"&gt;x.parentElement.parentElement.parentElement.parentElement&lt;/span&gt;
                      &lt;span class="s"&gt;.textContent&lt;/span&gt;
                &lt;span class="s"&gt;)&lt;/span&gt;
              &lt;span class="s"&gt;);&lt;/span&gt;
            &lt;span class="s"&gt;}&lt;/span&gt;

            &lt;span class="s"&gt;const isOled512gbAvailable = result.some(&lt;/span&gt;
              &lt;span class="s"&gt;(x) =&amp;gt; x.includes("Steam Deck 512 GB OLED") &amp;amp;&amp;amp; x.includes("Add to cart")&lt;/span&gt;
            &lt;span class="s"&gt;);&lt;/span&gt;

            &lt;span class="s"&gt;console.log({ result });&lt;/span&gt;

            &lt;span class="s"&gt;if (isOled512gbAvailable) {&lt;/span&gt;
              &lt;span class="s"&gt;console.log("Oled in stock!");&lt;/span&gt;
              &lt;span class="s"&gt;const timestamp = new Date().toISOString();&lt;/span&gt;
              &lt;span class="s"&gt;fs.appendFileSync(&lt;/span&gt;
                &lt;span class="s"&gt;"in_stock.txt",&lt;/span&gt;
                &lt;span class="s"&gt;`OLED 512GB in stock at ${timestamp}\n`&lt;/span&gt;
              &lt;span class="s"&gt;);&lt;/span&gt;
            &lt;span class="s"&gt;} else {&lt;/span&gt;
              &lt;span class="s"&gt;console.log("No oled in stock.");&lt;/span&gt;
            &lt;span class="s"&gt;}&lt;/span&gt;

            &lt;span class="s"&gt;await browser.close();&lt;/span&gt;
          &lt;span class="s"&gt;} catch (error) {&lt;/span&gt;
            &lt;span class="s"&gt;const timestamp = new Date().toISOString();&lt;/span&gt;
            &lt;span class="s"&gt;fs.appendFileSync(&lt;/span&gt;
              &lt;span class="s"&gt;"error.txt",&lt;/span&gt;
              &lt;span class="s"&gt;`Error at: ${timestamp} - ${error.message}\n`&lt;/span&gt;
            &lt;span class="s"&gt;);&lt;/span&gt;
            &lt;span class="s"&gt;console.error(error);&lt;/span&gt;
          &lt;span class="s"&gt;}&lt;/span&gt;
        &lt;span class="s"&gt;})();&lt;/span&gt;

        &lt;span class="s"&gt;EOF&lt;/span&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 Puppeteer script&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node fetchPage.js&lt;/span&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;Commit and push changes&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|-&lt;/span&gt;
        &lt;span class="s"&gt;git config user.name "Automated"&lt;/span&gt;
        &lt;span class="s"&gt;git config user.email "actions@users.noreply.github.com"&lt;/span&gt;
        &lt;span class="s"&gt;git add in_stock.txt error.txt || true&lt;/span&gt;

        &lt;span class="s"&gt;if git diff --cached --quiet; then&lt;/span&gt;
          &lt;span class="s"&gt;echo "No changes in in_stock.txt or error.txt. Exiting."&lt;/span&gt;
          &lt;span class="s"&gt;exit 0&lt;/span&gt;
        &lt;span class="s"&gt;fi&lt;/span&gt;

        &lt;span class="s"&gt;git commit -m "Automated update at $(date +"%Y/%m/%d %H:%M")"&lt;/span&gt;
        &lt;span class="s"&gt;git push&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don’t let the code intimidate you! It might look complex at first glance, but it’s actually quite straightforward when broken down. Let’s dissect it piece by piece:&lt;/p&gt;

&lt;h3&gt;
  
  
  Automation Triggers
&lt;/h3&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;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;*/60&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This section defines when our script springs into action:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On every push to the repository (useful for testing)&lt;/li&gt;
&lt;li&gt;When manually triggered (for on-demand checks)&lt;/li&gt;
&lt;li&gt;Most importantly, on a schedule! The cron expression */60 * * * * tells it to run every 60 minutes like clockwork&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This scheduled automation is the heartbeat of our solution — it ensures our digital scout is consistently checking for stock without your manual intervention. You could adjust the frequency, but remember that each check consumes some of your GitHub Actions minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Environment Setup
&lt;/h3&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;scheduled&lt;/span&gt;&lt;span class="pi"&gt;:&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="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&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;Check out this repo&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;actions/checkout@v4&lt;/span&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;Set up Node.js&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;actions/setup-node@v3&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;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;16'&lt;/span&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;Install Puppeteer&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm install puppeteer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This section might seem like boring setup code, but it’s where the foundation gets laid. We’re using the latest Ubuntu agent, checking out our repository, installing Node, and — critically — installing Puppeteer the headless browser that will do our actual browsing and checking.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scraping Script
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;puppeteer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;puppeteer&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;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&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;puppeteer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;args&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;--no-sandbox&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;--disable-setuid-sandbox&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;page&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;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&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://store.steampowered.com/sale/steamdeckrefurbished/&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;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;networkidle0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;div.CartBtn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;btns&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="nf"&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;div.CartBtn&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;result&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;let&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;btns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&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;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parentElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parentElement&lt;/span&gt;
              &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isOled512gbAvailable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&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;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Steam Deck 512 GB OLED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Add to cart&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isOled512gbAvailable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Oled in stock!&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;in_stock.txt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s2"&gt;`OLED 512GB in stock at &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n`&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;No oled in stock.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="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;timestamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error.txt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;`Error at: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;timestamp&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;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n`&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where the real magic happens! Let’s break down what this digital detective is doing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Browser Setup&lt;/strong&gt; : We launch a specialized headless browser (no visual interface needed) with specific arguments required for GitHub’s environment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Site Navigation&lt;/strong&gt; : Our script visits the Steam Deck refurbished page and patiently waits for everything to load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Element Detection&lt;/strong&gt; : It identifies all cart buttons on the page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content Extraction&lt;/strong&gt; : For each button, it cleverly navigates up the HTML family tree to grab the full product description. (Yes, this is a bit of a hack — in a more robust solution, we might use more targeted selectors.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Availability Analysis&lt;/strong&gt; : The script then scans through all results, checking for both “Steam Deck 512 GB OLED” and “Add to cart” text appearing together.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert Logging&lt;/strong&gt; : If the coveted item is in stock, it records the exciting news with a timestamp in in_stock.txt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error Handling&lt;/strong&gt; : If anything goes wrong, it documents the issue in error.txt for troubleshooting.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Running the Script and Recording Results
&lt;/h3&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 Puppeteer script&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node fetchPage.js&lt;/span&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;Commit and push changes&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|-&lt;/span&gt;
    &lt;span class="s"&gt;git config user.name "Automated"&lt;/span&gt;
    &lt;span class="s"&gt;git config user.email "actions@users.noreply.github.com"&lt;/span&gt;
    &lt;span class="s"&gt;git add in_stock.txt error.txt || true&lt;/span&gt;

    &lt;span class="s"&gt;if git diff --cached --quiet; then&lt;/span&gt;
      &lt;span class="s"&gt;echo "No changes in in_stock.txt or error.txt. Exiting."&lt;/span&gt;
      &lt;span class="s"&gt;exit 0&lt;/span&gt;
    &lt;span class="s"&gt;fi&lt;/span&gt;

    &lt;span class="s"&gt;git commit -m "Automated update at $(date +"%Y/%m/%d %H:%M")"&lt;/span&gt;
    &lt;span class="s"&gt;git push&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These final steps execute our carefully crafted plan:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run our Node.js script&lt;/li&gt;
&lt;li&gt;Set up Git with an automated user identity&lt;/li&gt;
&lt;li&gt;Stage any changes to our tracking files&lt;/li&gt;
&lt;li&gt;Check if there were actually any changes (if not, exit quietly)&lt;/li&gt;
&lt;li&gt;Commit with a detailed timestamp and push the changes&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Here’s the brilliant part:&lt;/strong&gt; If our script detects the Steam Deck in stock, it modifies in_stock.txt, triggering a commit. GitHub then sends you a notification about this commit—essentially alerting you that your coveted item is available! No custom notification system needed; we're cleverly repurposing GitHub's built-in features.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why This Approach Shines
&lt;/h3&gt;

&lt;p&gt;This solution offers several compelling advantages:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero Infrastructure Costs&lt;/strong&gt; : No servers to maintain or cloud resources to pay for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built-in Notification System&lt;/strong&gt; : GitHub’s commit notifications act as your personal alert system.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complete History&lt;/strong&gt; : Every check is documented in your repository history — perfect for tracking patterns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Easy Debugging&lt;/strong&gt; : The error.txt file helps troubleshoot any issues, or you can run the script locally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infinitely Customizable&lt;/strong&gt; : You can easily adapt this framework to monitor virtually any website.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Ethical Considerations Before You Deploy
&lt;/h3&gt;

&lt;p&gt;Before implementing this solution, a few important points to consider:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Respect Rate Limits&lt;/strong&gt; : Be courteous to the website’s resources. Checking every few minutes might be too aggressive and could potentially get your IP flagged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check Terms of Service&lt;/strong&gt; : Some websites explicitly prohibit scraping in their ToS. Always verify before deploying.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Personal Use Only&lt;/strong&gt; : This solution is designed for personal use, not for enabling scalping or mass reselling.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Ironic Plot Twist
&lt;/h3&gt;

&lt;p&gt;Did this ingenious solution help me snag my Steam Deck OLED? &lt;em&gt;Not exactly&lt;/em&gt;. The only time the console became available was in the middle of the night, and I only saw the notification the next morning — too late!&lt;/p&gt;

&lt;p&gt;However, this project wasn’t a total loss. I adapted this script for a completely different purpose: monitoring a Next.js website hosted with a provider notorious for unannounced outages. When the website goes down, the script commits an update to the error.log file on the release branch, triggering a page rebuild and restart. Problem solved!&lt;/p&gt;

&lt;h3&gt;
  
  
  The Developer’s Satisfaction
&lt;/h3&gt;

&lt;p&gt;There’s something uniquely satisfying about using your programming skills to solve real-world problems. So many of our technical abilities seem confined to our professional work, rarely crossing over to enhance our personal lives. This project was a refreshing reminder that our coding skills can actually make everyday frustrations a little more manageable.&lt;/p&gt;

&lt;p&gt;Have you leveraged GitHub Actions or other developer tools to solve real-world problems outside of work? I’d love to hear about your creative solutions in the comments!&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>puppeteer</category>
      <category>scraping</category>
      <category>steamdeck</category>
    </item>
  </channel>
</rss>
