Skip to main content

Bringing Back the 90s Guestbook with Modern JAMstack: How I Added Dynamic Comments to My Static 11ty Site

One of the first things I wanted to do when I began building my personal site and IndieWeb blog was to add a guestbook. There are services, particularly for NeoCities sites, but some have shut down. I wanted a solution that would work long-term without relying on third-party services.

An issue is that static sites inherently can't handle dynamic content without a backend of some sort. How do you accept user submissions on a site that doesn't run server code?

I decided that this would be easiest to tackle with Netlify forms. This guide is meant for others on the IndieWeb looking to build in similar functionality to their site!

You can visit my guestbook page to see it in action, or read on to learn how I built it.

The Architecture

The solution I found consists of three interconnected components:

1. The Form (Frontend)

A simple HTML form that leverages Netlify Forms:

<form name="guestbook" method="POST" data-netlify="true" action="/guestbook-success" class="guestbook-form">
  <div class="form-group">
    <label for="name">Name *</label>
    <input type="text" id="name" name="name" required>
  </div>
  
  <div class="form-group">
    <label for="message">Message *</label>
    <textarea id="message" name="message" rows="4" required></textarea>
  </div>
  
  <button type="submit" class="btn">Sign Guestbook</button>
</form>

data-netlify="true" is an attribute that tells Netlify to intercept form submissions and store them, eliminating the need for a backend.

2. The Webhook (Serverless Function)

This way, when someone submits the form, Netlify triggers a webhook that rebuilds the site with the new entry:

// netlify/functions/guestbook-webhook.js
const fetch = require('node-fetch');

exports.handler = async function(event, context) {
  if (event.httpMethod !== 'POST') {
    return { statusCode: 405, body: 'Method Not Allowed' };
  }

  try {
    const payload = JSON.parse(event.body);
    
    if (payload.type === 'submission' && payload.data?.name === 'guestbook') {
      console.log('New guestbook submission received:', payload.data);
      
      // Wait a bit before triggering rebuild
      console.log('Waiting 5 seconds before triggering rebuild...');
      await new Promise(resolve => setTimeout(resolve, 5000));
      
      const buildHookUrl = process.env.NETLIFY_BUILD_HOOK_URL;
      
      if (buildHookUrl) {
        const response = await fetch(buildHookUrl, {
          method: 'POST',
          body: JSON.stringify({ trigger: 'guestbook_submission' }),
          headers: { 'Content-Type': 'application/json' }
        });
        
        if (response.ok) {
          console.log('Build triggered successfully');
        }
      }
    }
    
    return {
      statusCode: 200,
      body: JSON.stringify({ received: true })
    };
  } catch (error) {
    console.error('Webhook error:', error);
    return {
      statusCode: 500,
      body: JSON.stringify({ error: 'Internal Server Error' })
    };
  }
};

3. The Data Fetcher (11ty Data File)

During build time, 11ty fetches all submissions from Netlify's API:

// src/_data/guestbook.js
const fetch = require("node-fetch");

module.exports = async function() {
  const siteId = process.env.NETLIFY_SITE_ID;
  const token = process.env.NETLIFY_FORMS_ACCESS_TOKEN;
  
  if (!token || !siteId) {
    console.warn("No Netlify API credentials found. Using sample data.");
    return getSampleEntries();
  }
  
  // Get form ID first
  const formsUrl = `https://api.netlify.com/api/v1/sites/${siteId}/forms`;
  const formsResponse = await fetch(formsUrl, {
    headers: {
      "Authorization": `Bearer ${token}`,
      "User-Agent": "curl/7.79.1"
    }
  });
  
  const forms = await formsResponse.json();
  const guestbookForm = forms.find(form => form.name === 'guestbook');
  
  // Fetch submissions with retry logic
  const url = `https://api.netlify.com/api/v1/sites/${siteId}/forms/${guestbookForm.id}/submissions`;
  
  let response;
  let retries = 3;
  let delay = 2000;
  
  while (retries > 0) {
    const submissionsResponse = await fetch(url, {
      headers: {
        "Authorization": `Bearer ${token}`,
        "User-Agent": "curl/7.79.1"
      }
    });
    
    if (submissionsResponse.ok) {
      response = await submissionsResponse.json();
      break;
    } else if (retries > 1) {
      console.log(`Retrying in ${delay}ms... (${retries} attempts left)`);
      await new Promise(resolve => setTimeout(resolve, delay));
      delay *= 2;
      retries--;
    }
  }
  
  // Transform and return entries
  return response.map(submission => ({
    name: submission.data.name,
    message: submission.data.message,
    website: submission.data.website || "",
    date: new Date(submission.created_at),
    id: submission.id
  }));
};

Debugging for Mobile

The guestbook worked when I tested it from my laptop. But when my partner tried signing from her phone, there would be a loading spinner that seemed to go on forever. Eventually, she would land on the success page, and... no new entry was in the guestbook. The message would appear in Netlify's form dashboard, but never on the live site.

I spent a good amount of time debugging this. Was it a mobile browser issue? A network problem? A CSS bug hiding the messages?

The real culprit was something more subtle, the eventual consistency in distributed systems. Here's what was happening:

  1. User submits form → Netlify stores it immediately
  2. Webhook triggers instantly → Site rebuild starts
  3. Site queries Netlify API for submissions → Too soon!
  4. API returns old data (submission not yet indexed)
  5. Site rebuilds without the new entry

The submission existed, but Netlify's API needed a moment to index it. On desktop, I was usually lucky with timing. On mobile networks with variable latency, the race condition was exposed.

I implemented a two-pronged fix:

  1. Delay the webhook: Wait 5 seconds before triggering the rebuild
  2. Add retry logic: If the API fails, retry with exponential backoff

This added robustness to the timing-dependent system, making it consistently reliable across all devices.

Why This Matters

The guestbook is nostalgic as hell, it embodies the IndieWeb principle that you should own your content. Unlike Disqus or other third-party comment systems, all data lives on my Netlify account. I control it, I can export it, and I'm not locked into anyone's platform.

Lessons Learned

  1. Static doesn't mean static: With serverless functions, static sites can have dynamic features
  2. Timing matters: Distributed systems aren't instantaneous. Always consider race conditions
  3. Test on real networks: Desktop WiFi is not the same as mobile 4G
  4. Simple is powerful: Three small files replace an entire backend infrastructure (thank God)

The Current Flow

  1. User fills form → Netlify stores submission
  2. Netlify sends webhook → Serverless function receives it
  3. Function waits 5 seconds → Triggers build hook
  4. 11ty builds site → Fetches submissions with retry logic
  5. Site deploys → New message appears automatically

Try It Yourself!

Want to add a guestbook to your 11ty site? Here's what you need:

  1. A Netlify site with Forms enabled
  2. A build hook URL (Site settings > Build & deploy > Build hooks)
  3. A Netlify Personal Access Token with forms:read permission
  4. The three code files above

Set these environment variables in Netlify:

  • NETLIFY_FORMS_ACCESS_TOKEN
  • NETLIFY_SITE_ID
  • NETLIFY_BUILD_HOOK_URL

That's it. No database, no server, no maintenance. Just JAMstack.

I love the ability for people to comment on my site so easily without any backend complexity.

Please try it out and let me know what you think!


Webmentions

No webmentions yet. Be the first to send one!


Related Posts

↑ TOP