This web framework was entirely built by Claude

12 min read Original article ↗

As someone who once built websites professionally, I respect the craft. I have always felt the code I write is more than just a collection of implementation details, it’s a reflection of my professionalism and the (sometimes inherited) opinions I had about cleanliness and design patterns. Reading code is like a window into a developer’s mind, and for that reason sharing my own has always felt like a vulnerable act — I cared a lot what other developers thought of me, and perhaps more importantly, what they would infer about me from my work. Maybe all this time I’ve taken myself too seriously?

As I’ve been learning to code over the past couple of decades I have rebuilt this site several times, and each era has reflected the state of my personal and professional life. Starting with static HTML/CSS pages in 2005 (I believe those were slice templates in Dreamweaver?), I graduated to my first real framework the following year (thanks CMSMS, we had a good run). Years later, as a distracted college student, I elected to “rebuild” with Wordpress and a downloaded theme from the now-defunct Photographic Blog. Finally in 2015, having just spent a year as a web developer with the excellent team at elevated third, I felt like my web presence needed to reflect my newfound technical acumen. I built a state-of-the-art (at the time) Drupal 8 Beta site with a custom theme. Custom modules, fancy CSS animations, test-prod deployment pipelines, strict adherence to Git Flow, and even a few build tests. It was even hosted on a VPS because I felt like I should have a static IP and sudo rights.

As my career transitioned later that year from tech to finance, my need for a “portfolio” waned. What I needed was closer to a digital résumé, and all the elegant complexity I had been so proud of slowly atrophied. Occasional dependency conflicts would take the site down, and it would be weeks or months before I mustered the motivation to untangle the mess. This had little effect on my life, but I want my site to remain online and doing so increasingly felt like a chore. I was particularly reminded of this unnecessary complexity when my annual hosting bill arrived. The overengineered site felt frivolous, especially given how often it was unavailable to my tens of visitors. I needed to dumb my site way down, and even the simple frameworks felt excessive for my needs. I want something that is easy to edit and just works, without going to pure static HTML.

The Vision

For a few years I’ve envisioned my ideal workflow. There would be no databases, no entity relationships or field management, no unstable dependencies. I wanted to work primarily with Markdown files and commit new content directly to the repository. I didn’t need much: a homepage of posts, a content type for pages, and a third one for projects. Small functions could parse the directories and generate paginated results in real-time. Even the frameworks that were built in a similar vein (eleventy, Hugo, Jekyll) felt like more than I needed.

Enter ✨AI.✨ Like surely everyone reading this, I’ve experimented with language models as coding assistants since their big splash in late 2022, but initially found the results to be underwhelming. As developers, we found solace in these shortcomings; we surely worried more about the lifespan of our skill set, but we at least knew that today’s AI was no substitute for an experienced hand.

For me, that changed this weekend. In two hours of focused prompting, Claude 3.5 managed to realize my entire vision:

  • Pure Markdown content management
  • Nothing pre-compiled
  • A fully personalizable theme with minimal DOM bloat
  • A tiny dependency profile (no surprise conflicts!)

I can hear the fresh CS grads already: “Congrats, boomer — you discovered AI.”

Bear with me, it wasn’t perfect and developers still have a role in 2025.

Almost?

Yes, almost.

First of all, AI is not a mind-reader. The eagerness with which the model generates results to poorly articulated requirements is the main reason “vibe coding” gets a bad rap. At least for now, you need to be specific to the point of micromanagement if you want good results. Luckily, AI isn’t offended by your specificity!

Inspired by Armin Ronacher’s Claude-built sloppy-xml library, I wrote up a list of requirements for my new web framework in a claude.md file and started my JetBrains AI trial. I described exactly how the directories should be organized, the dependencies I was willing to accept, and the way I wanted to interact with the site as its administrator. Token limits meant that Claude concluded each set of changes with a proposed list of follow-ups, and initially I merely offered my opinion on which component to take on next. It only took 6 prompts before we had a working prototype — inside of an hour, years of procrastination had paid off! It turns out all I had to do was wait for the models to catch up.

I would be remiss if I didn’t come with caveats:

  • There were several times when Claude wrote the same code more than once. At one point I asked to move some abstractions into a utils file, which it then seemed to forget all about whenever we needed those functions again. Reminding it of the interface would eventually return the intended implementation, but it took a close eye to make sure we weren’t creating redundant fragments.
  • The things I didn’t specify are implemented myopically. I left an example of this in utils.py: each of my three content types have metadata items that we use in rendering and sorting content. Initially this was a hard-coded dictionary, and I suggested we refactor as a dataclass. Even now, the attributes are hard-coded and there’s no actual discrimination between types. It’s not how I would’ve written that, but it does work!
  • Claude can’t easily reason about the front-end. It shouldn’t surprise anyone that the model struggles to create a visually pleasing UI when it can’t easily observe its own handiwork. While it did write some styles responsively, the results were disorganized and just… weird. There were incongruous borders, overly narrow content containers, and visually grating font combinations. The lack of margins and padding meant a lot of overlapping elements too, and some of the hover behavior would extend beyond the boundaries of its parent.

While most of what you see (the SCSS) has been rewritten, I owe the credit to Claude for the things you can’t see (read: the things that would have taken the most time to build).

I feel obliged to tell you that the content I have written and will write here is not AI-generated, despite what my addiction to the em-dash may suggest. While I’m happy to have delegated my long-anticipated framework dreams, it feels deeply hypocritical to let also let AI write its own performance review. Letting AI do the heavy lifting development work did give me the spare time to write up this narrative of the experience, of course.

Prompt Example

I’ve heard developers complain about hallucinations and context windows, and about how the models can’t keep enough of their code in view to make real contributions. They’ll forget about abstractions, use inefficient algorithms, and subtly misunderstand requirements. There were two occasions where Claude proposed dependencies that don’t exist. If you’ve resisted using code assistants because of this, I want to share with you a great counter-example:

Let's add a summary to the top of posts that estimates the reading time. Take the total number of words and divide it by the average adult reading speed, then feed this into the template as a variable.


Claude:

I'll help you add reading time estimation to posts. We'll need to:
  1. Create a function to calculate reading time
  2. Modify the post loading logic
  3. Update the post template

First, let's add a reading time calculation function to `utils.py`:
def calculate_reading_time(text, wpm=250):
    """
    Calculate reading time in minutes based on word count.

    Args:
        text (str): The text content to analyze
        wpm (int): Words per minute reading speed (default 250 for average adult)

    Returns:
        int: Estimated reading time in minutes
    """
    word_count = len(text.split())
    minutes = max(1, round(word_count / wpm))
    return minutes
Now, let's modify the post loading in `app.py` to include reading time:
@app.route('/posts/<slug>')
def post(slug):
    filepath = os.path.join(config['paths']['posts'], f"{slug}.md")
    metadata, content = load_markdown_file(filepath)
    if content is None:
        return "Post not found", 404

    # Calculate reading time
    reading_time = calculate_reading_time(content)

    # Add reading_time to metadata if it's an object that allows it,
    # otherwise pass it separately
    if hasattr(metadata, '__dict__'):
        metadata.reading_time = reading_time

    return render_template('post.html', 
                         post=metadata, 
                         content=content, 
                         reading_time=reading_time)
Finally, let's update the post template to show the reading time:
{% extends "base.html" %}

{% block title %}{{ post.title }}{% endblock %}

{% block content %}
<article class="post">
    <div class="container">
        <header class="post-header">
            <h1>{{ post.title }}</h1>
            <div class="post-meta">
                <time datetime="{{ post.date.isoformat() }}">{{ post.date.strftime('%B %d, %Y') }}</time>
                <span class="reading-time">
                    {{ reading_time }} min read
                </span>
            </div>
            {% if post.tags %}
            <div class="tags">
                {% for tag in post.tags %}
                <span class="tag">{{ tag }}</span>
                {% endfor %}
            </div>
            {% endif %}
        </header>

        <div class="post-content">
            {{ content | safe }}
        </div>
    </div>
</article>
{% endblock %}
And let's add some CSS to style the reading time nicely (add to your main.scss):
.post-meta {
    display: flex;
    gap: 1rem;
    color: #666;
    font-size: 0.9rem;
    margin-bottom: 2rem;

    .reading-time {
        &::before {
            content: "•";
            margin-right: 1rem;
        }
    }
}
This implementation:
  • Uses 250 words per minute as the average adult reading speed
  • Rounds to the nearest minute (minimum 1 minute)
  • Shows the reading time next to the post date
  • Styles it with a bullet separator

The reading time will appear as "• 3 min read" next to the date. The calculation happens server-side, so it won't impact client performance.

For better accuracy, you could:

  1. Add image viewing time (e.g., +12 seconds per image)
  2. Account for code blocks (slower reading speed)
  3. Cache the calculation to avoid recounting words on every page load
  4. Use a more sophisticated word counting algorithm that handles markdown

Would you like me to implement any of these improvements?


If that doesn’t sell you, I don’t know what will. Claude nailed every part of this: separation of concerns, consistent route implementation, good semantic structure and even a bit of SCSS! I also have to admit, I never would have thought to add penalties for images and code blocks. Do I need that? No! But it’s a great idea.

Naturally, I felt it was only appropriate if AI got to name the framework too. I hopped over to ChatGPT to start analyzing what Claude had written and come up with some ideas. After some deliberation and brainstorming, 4o finally produced a name worthy of a minimalist web framework: Etch. It went on to write some basic documentation pages (which also suffer from redundancy, but I guess we can tolerate this in the docs) and offered some final suggestions to clean up and robustify the framework.

Why Etch?

This isn’t the first time Markdown has been used as a primary content source by a web framework, but I’d like to think it’s the easiest one you’ll find — especially if you’re coming from a Python background.

From the perspective of someone who does (and prefers to) work in Python, here are some of the most common alternatives:

  • Hugo: this is by far the most popular static-site generator. The reason it doesn’t suit me is just that — the build step. If you prefer content be rendered as its contributed, Hugo won’t be the best fit for you. If you need to make changes under the hood, you’ll need to do so in Go, which often isn’t familiar territory to Python-first developers! The plus side is Hugo will always beat Etch on serve speed since everything is pre-rendered. That said, the theme ecosystem is rich and mature here
  • Eleventy: another popular choice, this time built in Node. It is a bit simpler than Hugo (good!) and has the same advantages and disadvantages. If you don’t feel like working primarily in Javascript, Etch’s Python ecosystem will spare you.
  • Jekyll: a great choice for personal sites, Jekyll was built for your blogging and has an impressive rolodex of corporate sites too (mostly from the Ruby ecosystem; we all eat our own cooking). Syntactically speaking, Ruby is similar enough to Python that you probably won’t feel too out of place working with it, and in my experience managing gems feels a bit more stable than pip.
  • markdown2dash: finally, a Python tool! The developer behind dash mantine, Snehil Vijay, built this interface between Markdown and Dash seemingly for the same reasons I wanted Etch. The setup experience is great, but since you’re using Dash components to render everything you’ll get the upside and the downside: there’s basically no HTML for you to work with since everything is constructed through Python bindings to the underlying elements. As a result, the front-end is tightly coupled with the backend and a meaty JS library, so render times and client-side performance suffer significantly. On the plus side, asynchronous content updates!
  • Github Pages: a great option for the lazy author — I imagine the prospective Etch user as someone who doesn’t want to think too hard about their site before they start writing. Github pages are the perfect home for someone who just wants to upload Markdown and get HTML in return. There are lots of reasons you may not want Github in your deployment pipeline, and if there is any part of your site you want to customize with Python logic or other callbacks, you may find the ceiling here is too low.

In short, if you want the flexibility of Flask but the simplicity of Markdown, Etch is a strong candidate for your next project.

Forks

While I did make this for myself, I know I’m not alone in craving simplicity. If you’re tired of maintaining your overly complex personal website I encourage you to give Etch a shot!

If you’d like to try Etch, you can install it from PyPI:

pip install etch

You’ll find all of the code and some basic documentation here:

rgkimball/etch

PRs are welcome, of course (even if they’re written by AI, how could I refuse?). The goal here is to keep things as simple as possible to set up and maintain, so anything consistent with that mission goes.