Skip to main content

Clean Code en la era del Vibe Coding

2026-04-20
Madrid, Spain
Codemotion 2026
Clean CodeIAArchitectureFrontend

On April 20, 2026, I gave a talk again at Codemotion, this time to share my experience with Generative AI and Vibe Coding, and how to use it to differentiate yourself from other developers and overcome the fear of the future of our work. The secret is bringing that touch of code quality, architecture and a solid battery of tests — all put into practice with a simple example: the migration of my personal website from Astro to Next.js. It was an incredible experience to share knowledge with the technical community at one of the most important tech events across different countries.

You can review the process in more detail in the post on my blog.

Below I share the content I discussed in the talk.


1. Intro: The Promise vs. The Reality

You've surely seen the term on Twitter: Vibe Coding. Andrej Karpathy defined it as that flow state where "you just say things and things happen". Sounds like the future, right? Almost like engineers are superfluous.

I wanted to put it to the test. My mission: migrate my personal portfolio (ascinfo.dev) from Astro to Next.js. I wanted a modern, dark, complex "Bento Grid" design. As a developer who hasn't touched frontend in a while, designing that CSS from scratch would take me weeks of fighting with Tailwind.

Why Next.js? If I'm going to use AI to generate UI, I need to be in the ecosystem where it performs best. And that ecosystem today is React with Next.js. There are component libraries (Shadcn, Framer Motion) designed for this ecosystem that speed up development much more than staying on my previous stack.

So I organized my team of modern developers — 3 AI tools:

  1. Gemini: The Architect (High-level planning and Prompt Engineering).

  2. v0 (Vercel): The UX/UI Designer (JSX generation).

  3. Claude: The Developer (Technical planning, Refactoring and Clean Code).

The visual result? Incredible in 5 minutes. The code? Well... today we're going to see what no one shows on Twitter: the code behind the "Vibe".


2. The "Before": Astro's Innocence

Astro is wonderful. It's HTML on steroids with zero JavaScript by default. My old website was simple and clean. Look at this Layout:

javascript
// File: src/layouts/Layout.astro
---
import {ViewTransitions} from 'astro:transitions';
import NavBar from "@components/astro/NavBar.astro";

const {title, description} = Astro.props;
---

<!DOCTYPE html>
<html lang="en">
<head>
    <meta property="og:title" content={title}/>
    <ViewTransitions/>
</head>
<body class="bg-black text-white">
    <NavBar/>
    <slot/>
</body>
</html>

I even had an abstraction attempt for the data:

javascript
// File: src/pages/proyectos/index.astro
---
import {GetAllProjects} from "@application/project/getAllProjects";
import {ProjectAstroContentAdapter} from "@infrastructure/adapter/ProjectAstroContentAdapter";

const allProjects = await new GetAllProjects(new ProjectAstroContentAdapter()).execute()
---

<section>
{ allProjects.map(project => <ProjectCard title={project.title} />) }
</section>

Conclusion: The data architecture was solid — in fact, the Use Cases and adapters I was able to reuse almost as-is. What was rigid was the presentation layer: .astro components without a rich UI ecosystem, without Framer Motion, without Shadcn. For the visual leap I wanted, rewriting the frontend was inevitable. And if you're going to rewrite anyway, it makes sense to do it in the ecosystem where AI performs best: React.


3. The Workflow: Orchestrating AIs

That said, here's how the dynamic worked. It wasn't launching random prompts. It was an engineering process, and the key is to understand one thing: each AI has a sweet spot, and if you ask it to go beyond that, quality drops. That's why I didn't use just Claude for everything.

  1. Initial conversation with Gemini: I explained my situation. I wanted a minimalist, modern touch and a Bento Grid.

  2. Implementation plan: Gemini offered the plan, we decided on the technologies and I asked it to act as my "Prompt Engineer". It drafted the specific prompts for v0.

  3. v0 to the rescue: With Gemini's prompts, v0 generated the UI. It came out on the first try. It was wonderful.

  4. Claude as quality filter: I opened the project locally and used Claude to, with the context of the old website, iterate on v0's results and shape them for production.

4. The "Vibe": The v0 Mirage

I asked v0: "Generate a Bento Grid portfolio, dark, for a software architect, with orange accents". Boom! In 30 seconds I had a UI that would have taken me days to build. It looked beautiful.

But as engineers, our responsibility is to open the hood. And what I found was "Spaghetti UI". Look at this LatestArticleBlock component:

typescript
// File: components/bento/latest-article-block.tsx (Generated by v0)
"use client" // ❌ Unnecessary for static content

import Link from "next/link"

export function LatestArticleBlock() {
  return (
    <article className="flex h-full min-h-[280px] flex-col rounded-xl border border-white/5 bg-[#222222] p-6 transition-all duration-300 hover:-translate-y-1">
        <div className="mt-4 flex-1">
          {/* 🚨 HARDCODED DATA - The cardinal sin of Vibe Coding */}
          <span className="bg-[#FCA311]/10 text-[#FCA311] px-3 py-1 rounded-full">
            Spring Boot
          </span>

          <h2 className="text-xl font-semibold mt-4">
            Hexagonal Architecture in Spring  {/* 👈 Fixed title! */}
          </h2>

          <p className="text-sm text-muted-foreground mt-3">
            How to decouple your domain from the framework...
          </p>
        </div>
    </article>
  )
}

Analysis:

  1. Hardcoded Data: If I want to write a new post, do I have to edit the code and redeploy? Unacceptable.

  2. No Props: This component is a "drawing", not reusable.

  3. Fixed state: In other blocks, like the projects one, the "Active" status was nailed into the HTML.

Conclusion: "Vibe Coding" is great for prototyping (0 to 1), but terrible for maintenance (1 to N). This is where the "Designer's" work ends and the "Architect's" begins.


5. Hallucination Corner: When Agents Lose the Plot

Before moving to the final result, let's talk about the flip side. Because if you think having 4 agents working for you is paradise, you haven't seen a Reviewer Agent and a Dev Agent enter an infinite loop of mutual blame.

1. The Hydration Disaster

v0 has an obsession with real-time. It generated a component that did this:

typescript
// What v0 thought was a good idea
export function PostDate() {
  return <span>Published on: {new Date().toLocaleDateString()}</span>
  // 🚨 ERROR: Textbook Hydration Mismatch
}

The result: The website rendered one date on the server and another on the client, breaking everywhere. The AI "vibed" so hard it forgot how Next.js rendering works. This is the kind of error that seems obvious when you read it, but isn't so obvious when you blindly trust what's generated.

2. The Phantom Library

The Backend Agent, in a fit of optimism, decided the best way to manage metadata was to use a library called next-super-mdx-optimizer.

  • Problem: That library doesn't exist. It made it up by mixing three different names.

  • Lesson: "Trust, but verify". If the AI proposes a new dependency, search for it on NPM before running npm install.

Conclusion: Working with agents is like directing a team of geniuses with amnesia. They're incredibly brilliant, but if you take your eyes off them for a second, they'll try to build a skyscraper starting from the roof, or using materials that don't exist in this universe.


6. The Engineering: Clean Architecture

This is where Claude comes in (guided by me). The objective: Refactor the v0 chaos using Clean Architecture and SOLID principles.

The "War Room": The Agent Setup

To fix what v0 generated and migrate the logic, I didn't use a single Claude chat. I set up a Dedicated Agent Setup:

  1. Planner (Planning Agent): Analyzed the Astro and v0 code and defined the step-by-step approach.

  2. Backend Dev (Logic Agent): In charge of Use Cases and Repositories.

  3. Frontend Dev (UI Refactor Agent): In charge of receiving v0's JSX and "cleaning it up".

  4. Reviewer (Code Reviewer): Sole mission: find "bad smells" and remind the Dev to use TypeScript types.

The flow was:

  1. The planners analyzed the situation and context to develop the step-by-step approach from each side.

  2. If the plan didn't satisfy me, I iterated until I had a solid plan.

  3. With the plan approved, the developers got to work.

  4. Once finished, the reviewer wrote up the code smells or things to improve, and back to the beginning until reaching a valid point.


Practical Example

Let's see how the component we saw earlier turned out.

First, we convert the drawing into a smart, typed component. We extract the props. Now the component doesn't care whether the post is about Spring or .NET.

typescript
// File: components/bento/latest-article-block.tsx (Refactored)

// ✅ Explicit Type Definitions
type LatestArticleBlockProps = {
  slug: string
  title: string
  excerpt: string
  tags: string[]
}

export function LatestArticleBlock({
  slug, title, excerpt, tags
}: LatestArticleBlockProps) {
  return (
    <div className="flex flex-col rounded-xl border border-white/5 bg-[#222222] transition-all duration-300 hover:-translate-y-1">
      <Link href={`/blog/${slug}`}>
        <h2 className="text-xl font-semibold">{title}</h2> {/* ✅ Dynamic */}
        <p className="mt-3 text-muted-foreground">{excerpt}</p>

        <div className="mt-4 flex gap-2">
            {tags.map(tag => (
                <span key={tag} className="rounded-full bg-orange-500/10">
                    {tag}
                </span>
            ))}
        </div>
      </Link>
    </div>
  )
}

Where does the data come from? We create an agnostic Use Case that doesn't know anything about Next.js or v0.

typescript
// File: src/lib/content/application/use-cases/posts/GetAllPosts.ts
import type { ContentRepository } from "@/content/domain/repositories/ContentRepository"

export class GetAllPosts {
  // ✅ Dependency Injection
  constructor(private readonly contentRepository: ContentRepository) {}

  async execute(): Promise<PostDto[]> {
    const rawPosts = await this.contentRepository.readAll(POSTS_DIR)
    return rawPosts.map(p => p.toDto())
  }
}

We define the contract (Repository) and use a Container to resolve dependencies. This allows me to switch from local MDX to the Notion API simply by changing an environment variable.

typescript
// File: src/lib/content/infrastructure/Container.ts
function createContentRepository(): ContentRepository {
  return process.env.CMS_PROVIDER === "notion"
    ? new NotionContentRepository()
    : new MDXContentRepository()
}

export const posts = {
  getAll: new GetAllPosts(createContentRepository()),
}

A technical note: this is a factory pattern rather than pure dependency injection. For a personal portfolio, the complexity of a real DI container doesn't add value. What matters is that the principle holds: use cases don't know where the data comes from.


7. Final Result: The New Role of the Dev

This is what the home page looks like in the end. Clean, async and safe.

typescript
// File: app/page.tsx (Final)
import { posts } from "@/src/lib/content"
import { LatestArticleBlock } from "@/components/bento/latest-article-block"

export default async function Home() {
  const featuredPost = await posts.getFeatured.execute()
  const featuredPostDto = featuredPost?.toDto()

  return (
    <main>
      {featuredPostDto && (
        <LatestArticleBlock
          slug={featuredPostDto.slug}
          title={featuredPostDto.title}
          excerpt={featuredPostDto.excerpt}
          tags={featuredPostDto.tags}
        />
      )}
    </main>
  )
}

Folder Structure

javascript
📦 ascinfo.dev
├── 📂 app/                # Next.js App Router (UI & Glue Code)
├── 📂 components/ui/       # "Dumb" Components (v0)
└── 📂 src/lib/content/     # 🏛️ The Core (Hexagonal)
    ├── 📂 domain/          # Interfaces
    ├── 📂 application/     # Use Cases
    └── 📂 infrastructure/  # MDX / Notion

The final balance? AI saved me about 20 hours of CSS, boilerplate and content migration. But of those 20 hours, I probably invested 8 in reviewing, refactoring and fixing what the AI generated. The net saving is huge, but it's not free. "Vibe Coding" without engineering behind it is technical debt on credit.

8. Pro-tips for Surviving AI

Before finishing, I'd like to share a couple of tips I wish I'd known before starting:

  1. Don't accept the first code that works. The v0 LatestArticleBlock worked, but it was a drawing with hardcoded data. If I had accepted it as-is, every new post would require a redeploy.

  2. Architecture is your shield against hallucinations. If you have a well-defined domain layer, when the AI generates strange code, you detect it instantly because it doesn't fit with your contracts.

  3. Divide the AI's roles. Don't ask it to be a supreme master that designs, programs and reviews all at once. Ask it to be an expert in just one thing. One agent plans, another executes, another reviews. Just like a real team.


Final Conclusion

AI saved me 20 hours of CSS and boilerplate. But the architecture was my contribution. Don't settle for being Vibe Coders. Use AI to go fast, but use your engineering experience to go far.

This is what turns a dev into someone who multiplies their impact with AI, instead of being replaced by it. It's not about competing against the machine, but about elevating your level of abstraction: moving from code bricklayer to an architect who orchestrates tools.