Creating landing pages for friends is something I enjoy, but they often want the freedom to update content themselves without needing me for every small change. Despite my full-stack background and web development skills, I never found the motivation to dive into CMS platforms. I have experience with Magnolia CMS, but setting it up properly often requires more work than building a site from scratch!

Then there's the issue of subscription costs. Many of these side projects don’t gain much traction, yet maintaining them could end up costing $200 or more annually—all out of my own pocket. I’d rather not pay recurring fees just to host a project for a friend, especially when affordable CMS alternatives are limited.

I looked into WordPress as well, but setting it up means a full server environment, which feels like overkill for simple, lightweight sites. So instead of choosing something complex or costly, I finally decided to take a different approach: building my own CMS solution to fit exactly what I need.

Credits: https://xkcd.com/927/

Creating the website

After some brainstorming with my friend, we settled on a free HTML template called Alien. It was straightforward, so I added some Bootstrap 4 from my earlier coding days, fixed a few responsiveness issues, and, voilà, the site was live. I also took this as an opportunity to use the AI editor Cursor, intending to leverage it throughout the rest of this project

The website is not completely finished but visible under https://www.oma-food-truck.fr/

Adding an admin panel

To make the template content editable, I briefly considered Surreal CMS. However, since this is a small commercial project (a food truck), it didn’t make sense to invest in a CMS that charges for for-profit use, even for small projects.

So, I started thinking about the most minimalist CMS setup I’d actually need: a persistent Key Value Store to hold the data and a cloud function to retrieve it. Thankfully, Cloudflare provides exactly what I needed for this. I put together a quick, somewhat hacky solution based on Cloudflare’s to-do list example. It’s a lightweight, low-cost approach that perfectly suits small projects like this one, sparing both complexity and subscription fees.

export default {
  async fetch(request, env) {
    const setCache = (key, data) => env.EXAMPLE_TODOS.put(key, data);
    const getCache = key => env.EXAMPLE_TODOS.get(key);

    async function getData(request) {
      const cache = await getCache('data');
      if (!cache) {
        return new Response('Not Found', { status: 404 });
      } else {
        return new Response(cache, {
          headers: { 
            'Content-Type': 'application/json',
            'Access-Control-Allow-Origin': '*'
          }
        });
      }
    }

    async function updateData(request) {
      const requestBody = await request.json();
      if (auth) {
        return new Response('Unauthorized', { 
          status: 401,
          headers: { 
            'Access-Control-Allow-Origin': '*'
          }
        });
      }

      const data = requestBody;
      await setCache('data', JSON.stringify(data));
      return new Response(JSON.stringify(data), { 
        status: 200,
        headers: { 
          'Content-Type': 'application/json',
          'Access-Control-Allow-Origin': '*'
        }
      });
    }

    async function handleOptions(request) {
      return new Response(null, {
        headers: {
          'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
          'Access-Control-Allow-Headers': 'Content-Type, Authorization',
          'Access-Control-Allow-Origin': '*'
        }
      });
    }

    if (request.method === 'GET' && request.url.includes('/data')) {
      return getData(request);
    } else if (request.method === 'POST' && request.url.includes('/data')) {
      return updateData(request);
    } else if (request.method === 'OPTIONS') {
      return handleOptions(request);
    } else {
      return new Response('Not Found', { status: 404 });
    }
  }
};

The code itself is straightforward: a GET request to the API returns the value for a specified key, and a POST request sets a value in Cloudflare’s Key Value Store. I even kept the variable name EXAMPLE_TODOS from the original to-do list example—sometimes you just go with what works! It’s a minimalist setup, but it does exactly what I need: a simple way to update and retrieve content, without the hassle or cost of a full CMS.

Connect the dots

Now, that I have my neat API, I can connect it to my website. I need to connect it twice: once on the website itself and once on the configuration panel which is nothing more than a static page that gets the current data and allows the editor to change these.  

Using Cursor

As I mentioned, I can be a bit lazy, so using Cursor felt like the perfect approach for this project. I just told Cursor I wanted a static page connecting to this API, with each section styled in Tailwind—and it created this masterpiece.

Cursor even understood that each spot for the food truck should have its own storage key and set up the table accordingly.

The result looks like:

 <body class="bg-blue-100 p-6">
    <div class="edit_section bg-white p-4 rounded shadow-md">
      <h1 class="text-2xl font-bold mb-4">Admin Dashboard - Content Editor</h1>
      <h2 class="text-xl font-bold mb-4">Edit Content for Website</h2>
      <form id="editForm">
        <div class="mb-4">
          <h3 class="text-lg font-semibold">Title</h3>
          <textarea id="titleText" rows="10" class="w-full border rounded p-2"></textarea>
        </div>
        <div class="mb-4">
          <h3 class="text-lg font-semibold">Présentation</h3>
          <textarea id="presentationText" rows="10" class="w-full border rounded p-2"></textarea>
        </div>
        <div class="mb-4">
          <h3 class="text-lg font-semibold">Emplacements</h3>
          <div id="emplacementsTextAreas" class="grid grid-cols-2 gap-4">
            <div>
              <label>Lundi - Midi</label>
              <textarea rows="1" class="w-full border rounded p-2"></textarea>
            </div>
            <div>
              <label>Lundi - Soir</label>
              <textarea rows="1" class="w-full border rounded p-2"></textarea>
            </div>
            <div>
              <label>Mardi - Midi</label>
              <textarea rows="1" class="w-full border rounded p-2"></textarea>
            </div>
            <div>
              <label>Mardi - Soir</label>
              <textarea rows="1" class="w-full border rounded p-2"></textarea>
            </div>
            <div>
              <label>Mercredi - Midi</label>
              <textarea rows="1" class="w-full border rounded p-2"></textarea>
            </div>
            <div>
              <label>Mercredi - Soir</label>
              <textarea rows="1" class="w-full border rounded p-2"></textarea>
            </div>
            <div>
              <label>Jeudi - Midi</label>
              <textarea rows="1" class="w-full border rounded p-2"></textarea>
            </div>
            <div>
              <label>Jeudi - Soir</label>
              <textarea rows="1" class="w-full border rounded p-2"></textarea>
            </div>
            <div>
              <label>Vendredi - Midi</label>
              <textarea rows="1" class="w-full border rounded p-2"></textarea>
            </div>
            <div>
              <label>Vendredi - Soir</label>
              <textarea rows="1" class="w-full border rounded p-2"></textarea>
            </div>
            <div>
              <label>Samedi - Midi</label>
              <textarea rows="1" class="w-full border rounded p-2"></textarea>
            </div>
            <div>
              <label>Samedi - Soir</label>
              <textarea rows="1" class="w-full border rounded p-2"></textarea>
            </div>
            <div>
              <label>Dimanche - Midi</label>
              <textarea rows="1" class="w-full border rounded p-2"></textarea>
            </div>
            <div>
              <label>Dimanche - Soir</label>
              <textarea rows="1" class="w-full border rounded p-2"></textarea>
            </div>
          </div>
        </div>
        <div class="mb-4">
          <h3 class="text-lg font-semibold">Carte</h3>
          <textarea id="carteText" rows="10" class="w-full border rounded p-2"></textarea>
        </div>
        <div class="mb-4">
          <h3 class="text-lg font-semibold">Privatisation</h3>
          <textarea id="privatisationText" rows="10" class="w-full border rounded p-2"></textarea>
        </div>
        <div class="mb-4">
          <h3 class="text-lg font-semibold">Contact</h3>
          <textarea id="contactText" rows="8" class="w-full border rounded p-2"></textarea>
        </div>
        <button type="button" id="saveButton" class="w-full bg-blue-500 text-white rounded px-4 py-2">Save</button>
      </form>
    </div>

    <script>
      // Populate text areas with existing content from /data
      fetch('workerendpoint')
        .then(response => response.json())
        .then(data => {
          document.getElementById('titleText').value = data.title; // Existing title text
          document.getElementById('presentationText').value = data.presentation; // Existing presentation text
          document.getElementById('carteText').value = data.carte; // Existing carte text
          document.getElementById('privatisationText').value = data.privatisation; // Existing privatisation text
          document.getElementById('contactText').value = data.contact; // Existing contact text

          // Populate emplacements text areas
          const emplacementsTextAreas = document.querySelectorAll('#emplacementsTextAreas textarea');
          emplacementsTextAreas.forEach((textarea, index) => {
            textarea.value = data.emplacements[index];
          });
        })
        .catch(error => {
          console.error('Error fetching data:', error);
        });

      // Save button functionality
      document.getElementById('saveButton').addEventListener('click', function() {
        const password = prompt("Please enter the password to save changes:");
        if (password) { // Check if a password was entered
          const data = {
            title: document.getElementById('titleText').value,
            presentation: document.getElementById('presentationText').value,
            carte: document.getElementById('carteText').value,
            privatisation: document.getElementById('privatisationText').value,
            contact: document.getElementById('contactText').value,
            emplacements: Array.from(document.querySelectorAll('#emplacementsTextAreas textarea')).map(textarea => textarea.value),
            password
          };

          fetch('workerendpoint', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              'Authentication': 'Basic aaaa'  
            },
            body: JSON.stringify(data)
          })
          .then(response => {
            if (response.status === 200) {
              alert("Changes saved successfully.");
            } else if (response.status === 401) {
              alert("Incorrect password. Changes not saved.");
            } else {
              alert("An error occurred. Please try again.");
            }
          })
          .catch(error => {
            console.error('Error:', error);
            alert("An error occurred. Please try again.");
          });
        } else {
          alert("Password is required to save changes.");
        }
      });
    </script>
  </body>

Then I just had to do some basic gluing on the main HTML page to connect to editable content:

function generateSchedule(data) {
    const days = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche'];
    const times = ['Midi', 'Soir'];

    // Create the container
    let html = `
    <div class="container">
        <h2 class="client_taital">Emplacements</h2>
        <div class="schedule-container">
            <div class="schedule-desktop">
                <div class="schedule-row">
                    <div class="schedule-cell"></div>`;

    // Generate headers for days
    days.forEach(day => {
      html += `<div class="schedule-cell">${day}</div>`;
    });

    html += `</div>`;

    // Generate rows for desktop schedule
    times.forEach((time, timeIndex) => {
      html += `<div class="schedule-row">
                <div class="schedule-cell">${time}</div>`;

      days.forEach((day, dayIndex) => {
        const index = dayIndex * 2 + timeIndex; // Calculate the correct index in the data array
        html += `<div class="schedule-cell">${data[index]}</div>`;
      });

      html += `</div>`;
    });

    html += `</div>
        </div>
        <div class="schedule-mobile">`;

    // Generate schedule for mobile
    days.forEach((day, dayIndex) => {
      html += `<div class="day-schedule">
                <div class="day-title">${day}</div>
                <div>Midi: ${data[dayIndex * 2]}</div>
                <div>Soir: ${data[dayIndex * 2 + 1]}</div>
             </div>`;
    });

    html += `</div>
    </div>`;

    return html;
  }

  const setContent = (data) => {
    Object.entries(data).forEach(([key, value]) => {
      const element = document.getElementById(key);
      if (element) {
        element.innerHTML = value;
      }
    });
    document.getElementById('emplacements').innerHTML = generateSchedule(data.emplacements);
  }


  const overlay = document.createElement('div');
  overlay.style.position = 'fixed';
  overlay.style.top = '0';
  overlay.style.left = '0';
  overlay.style.width = '100%';
  overlay.style.height = '100%';
  overlay.style.backgroundColor = '#d7849f';
  overlay.style.zIndex = '9999';
  overlay.style.display = 'flex';
  overlay.style.color = 'white';
  overlay.style.justifyContent = 'center';
  overlay.style.alignItems = 'center';
  overlay.innerHTML = '<div>Chargement...</div>';
  document.body.appendChild(overlay);

  fetch('https://oma-truck-persistence.laurent-2b0.workers.dev/data')
    .then(response => {
      if (!response.ok) {
        throw new Error('Network response was not ok');
      }
      return response.json();
    })
    .then(data => {
      setContent(data);
      document.body.removeChild(overlay);
    })
    .catch(error => {
      console.error('There was a problem with the fetch operation:', error);
      // Use default content if fetch fails or takes more than 3 seconds
      setTimeout(() => {
        setContent(defaultContent);
        document.body.removeChild(overlay);
      }, 3000);
    });

As you can see, the code is pretty straightforward—ugly, maybe, but easy to follow. It’s also the kind of dry code I hate spending time on; there’s no challenge or learning involved. Sometimes, it’s just easier to let an AI handle that. Cursor took care of the repetitive work, leaving me free to focus on the more interesting parts of the project.

AI and the cost of software

For me, this experiment was mostly a pretext for two things: getting an endless portion of spaetzle from this food truck and testing just how much I could accomplish with AI. I wanted to see how it might shift my perspective on programming—although I code less these days, I still tackle complex problems from time to time.

I have to admit, if code generation becomes this fast, accurate, and easy, I’ll definitely rethink my software choices. Why bother with a tool that has a steep learning curve if I can generate precisely what I need in minutes?

This experiment changed how I approach my work, too. We have Google Analytics data in BigQuery, and I’m usually the one dealing with the tedious SQL queries. Not long ago, a senior dev asked for a walkthrough of the data structure and how to write a query for a low-priority check—just to confirm that our A/B system was working. As I started explaining, I realized he’d actually be better off typing his problem into ChatGPT. Given that GA4 data in BigQuery is already normalized, the AI would produce a solid query to get him started. He’d then be able to iterate on it himself, like pair programming, but more independently. The best part? He’d probably gain a deeper understanding by working through the query himself rather than having me guide him through each step. Half the effort, better results. AI has a bright future!