Building a Travel Blog with Rails 8 and an AI Co-Pilot

Sixty-four commits over twelve days, 64% co-authored with Claude. The full story of building a production travel blog with Rails 8, Hotwire, and an AI pair programmer.

Building a Travel Blog with Rails 8 and an AI Co-Pilot

Sixty-four commits. Twelve days. A full-featured travel blog platform with interactive maps, rich content sections, passwordless authentication, and offline resilience. Forty-one of those commits — 64% — were co-authored with Claude.

This isn't a story about generating a toy app with AI. It's about what happens when you pair-program a real, production-deployed Rails 8 application with an AI agent over nearly two weeks. The good, the messy, and the genuinely surprising.

What We Built

The app is a multi-user travel blog platform. Writers create trips, add posts to those trips, and build each post from a mix of content sections — text (with a rich text editor), photos, and map markers. Every user gets a public blog profile, an interactive adventure map showing all their locations across trips, and a dashboard for managing drafts and published posts.

The stack: Rails 8.1.2, Hotwire (Turbo + Stimulus), Tailwind CSS v4, SQLite, Active Storage, and Kamal for deployment. No React. No Postgres. No external job queue. Rails 8's "one server, no external dependencies" philosophy, taken seriously.

Day One — The Big Bang Commit

The project started on March 3rd with a single commit: d2d2719 — Initial commit with K8s deployment. One hundred and ninety-three files. Six thousand lines. The entire application skeleton — models, controllers, views, authentication, a Kubernetes deployment manifest, and a GitHub Actions CI/CD pipeline — all in one shot.

That initial commit was co-authored with Claude. The prompt was essentially: "I want a travel blog where users can log in with magic links, create trips, write posts with mixed content sections, and deploy it to Kubernetes." Claude scaffolded the Rails app with Hotwire, importmaps, and Tailwind, then built out the data model — Users, Trips, Posts, Sections — and wired up the authentication flow.

One decision shaped everything that followed: SQLite instead of Postgres. Rails 8 introduced Solid Queue, Solid Cache, and Solid Cable — database-backed replacements for Redis. With SQLite, the entire application runs from a single server with no external services. No connection pool tuning, no managed database bills, no separate cache layer. The trade-off is single-writer concurrency, but for a content platform that's read-heavy, it's the right call.

The Trix and Tailwind v4 War

The first real fight wasn't a feature — it was getting the rich text editor to look right.

Action Text ships with Trix, and Trix ships with actiontext.css — a 440-line stylesheet designed for a pre-Tailwind world. When you drop it into a Tailwind v4 project, things break in subtle ways. The link dialog was permanently visible. Toolbar buttons had double borders. The editor had no visible boundaries.

It took four commits to fix:

  1. 554d050 — Fixed the dialog visibility. Trix v2 uses a .trix-active class to toggle dialogs, but neither actiontext.css nor Tailwind provided a rule to hide inactive ones.
  2. 059f5f4 — Fixed a double border seam between the toolbar and editor.
  3. 2b69afb — Traced the remaining border issue to actiontext.css defaults fighting with Tailwind's reset.
  4. 9a9d572 — Gave up on fixing borders piecemeal and wrapped both in a container.

The final CSS tells the story:

/* Action Text / Trix editor styling — override actiontext.css defaults */
trix-editor {
  @apply p-4 min-h-[300px];
  display: block;
  border: none !important;
  border-radius: 0 !important;
  outline: none;
}

trix-toolbar {
  @apply p-2;
  border: none !important;
  background-color: var(--color-cream-dark);
}

trix-toolbar .trix-button-group {
  border: none !important;
  margin-bottom: 0 !important;
}

/* Trix v2 dialog visibility — hidden by default, shown via .trix-active */
trix-toolbar .trix-dialog {
  display: none;
}

The !important declarations are ugly. They're also necessary — actiontext.css loads separately and its specificity wins without them. If you're building with Action Text and Tailwind v4, budget time for this.

The Image Processing Trilogy

With the editor working, I started adding photos. They looked fine in development. In production, nothing rendered.

Three commits, three layers of the same problem:

Commit 1 (eccd70d): Images not displaying. Investigation revealed the variant processor was misconfigured.

Commit 2 (d0168c1): The Dockerfile installs libvips, but Rails was configured to use MiniMagick. A one-line fix:

config.active_storage.variant_processor = :vips

This is a classic Rails 8 gotcha. The framework defaults to :mini_magick but the standard Docker images ship with libvips. The error doesn't surface in development if you have both installed locally.

Commit 3 (e9aa06c): Images now processed, but the pod kept getting OOM-killed. The Kubernetes memory limit was 512Mi, and libvips was spiking during variant generation. The fix was bumping to 1Gi:

resources:
  requests:
    cpu: 250m
    memory: 512Mi
  limits:
    cpu: "1"
    memory: 1Gi

The gap between development and production is where the real bugs live. MiniMagick vs vips, memory limits, storage configuration — none of this shows up when you're running rails server locally.

Claude as Security Auditor

One of the most effective uses of the AI pairing was security. After the core features were working, I asked Claude to audit the codebase. It found real issues:

XSS vulnerabilities (75d5629): Trip names and display names were being rendered with .html_safe inside link_to helpers. Claude switched to block-form link_to and added strip_tags sanitization on all model inputs.

Session fixation (9db8c54): The login callback was setting session[:user_id] without resetting the session first. An attacker with a known session ID could hijack a login. The fix:

reset_session
session[:user_id] = user.id
redirect_to dashboard_path, notice: "Welcome back, #{user.display_name}!"

CSP violations (9497295): Trix, Turbo, and Leaflet all inject inline styles dynamically. The Content Security Policy needed 'unsafe-inline' for styles (but not scripts), and Leaflet needed its source map URL allowed.

Rate limiting: The magic link callback got rate limiting — five attempts per minute — to prevent brute-force token guessing.

After the audit, Brakeman reported zero warnings. This is where AI collaboration genuinely shines — security review is tedious, pattern-based work that benefits from comprehensive coverage. Claude checked every view template, every helper, every controller action.

The Feature Sprint

With the foundation solid, features came quickly. Each of these was a single prompt followed by a focused PR:

  • Reading time — calculated from section content, accounting for photos:
def reading_time_minutes
  words = sections.select(&:text?).sum { |s| s.body&.to_plain_text&.split&.size || 0 }
  photo_count = sections.count(&:photo?)
  minutes = (words / 200.0) + (photo_count * 0.2)
  [ minutes.ceil, 1 ].max
end
  • Autosave — draft posts saved to localStorage every 30 seconds
  • Trip date ranges — with smart formatting ("March 5 - 18, 2025")
  • User bios — optional taglines with sanitization
  • Admin panel — user management with enable/disable
  • Database backups — pre-deploy snapshots and a daily CronJob
  • Share buttons — reusable partial across all public pages (followed immediately by a Brakeman XSS fix when the text params weren't escaped)

The section-based content model was the key architectural decision that made all of this clean. A post isn't a single blob of HTML — it's an ordered list of typed sections. Text sections use Action Text. Photo sections use Active Storage. Map sections store coordinates. This means reading_time_minutes can count words across only text sections, cover_photo can find the first photo section, and the adventure map can extract all map sections across all posts.

The Adventure Map

The capstone feature was an interactive map showing all of a user's travel locations across all trips. Built with Leaflet.js and a Stimulus controller, it renders color-coded routes connecting locations within each trip, with clickable markers linking back to individual posts.

The data flows from Rails to JavaScript through Stimulus values:

const COLORS = ["#3B82F6", "#EF4444", "#10B981", "#F59E0B",
                "#8B5CF6", "#EC4899", "#06B6D4", "#F97316"]

trips.forEach((trip, index) => {
  const color = COLORS[index % COLORS.length]
  const layerGroup = L.layerGroup().addTo(this.map)

  trip.locations.forEach((loc) => {
    L.marker([loc.lat, loc.lng], { icon: markerIcon })
      .bindPopup(popupHtml)
      .addTo(layerGroup)
  })

  // Dashed route line connecting locations
  L.polyline(routePoints, {
    color: color,
    weight: 2.5,
    opacity: 0.7,
    dashArray: "8, 6",
    smoothFactor: 1.5
  }).addTo(layerGroup)
})

The legend is interactive — click a trip name to toggle its markers and route on and off. A home base marker shows the user's approximate location (rounded to ~11km for privacy).

One subtle bug emerged here: strip_tags was HTML-encoding ampersands in location names, producing & in the output. The fix was switching to Loofah's scrub method for plain text extraction.

Offline Resilience — The Final Feature

The last feature addressed a real pain point: during Kamal deploys, the server is briefly unavailable. If a user is mid-post, their work vanishes.

The form_persistence_controller — a 405-line Stimulus controller — intercepts Turbo submit failures and saves form state to localStorage:

handleFetchRequestError(event) {
  event.preventDefault()
  this.saveToLocalStorage()
  this.showBanner(
    "Could not reach the server — your work has been saved locally.",
    "Click \"Retry save\" to try again when the server is back.",
    "error"
  )
}

It also autosaves every 30 seconds in the background. When the user returns after a failed save, they see a recovery banner with three options: restore their cached content, retry the save, or discard. Photos are excluded from caching (binary data doesn't belong in localStorage), and users are warned to re-attach them after restoring.

This was the most complex Stimulus controller in the app, handling edge cases like serializing Trix editor content, matching restored sections to their DOM templates, and injecting hidden form fields for the draft/publish state. It was also the feature where Claude's implementation was closest to production-ready on the first pass — the controller structure, event handling, and UX copy all landed well.

The Collaboration Pattern

So what does "64% co-authored" actually mean in practice?

It doesn't mean Claude wrote 64% of the code. It means that for 41 of 64 commits, the workflow was: I described what I wanted, Claude implemented it, I reviewed and course-corrected, and we iterated until it was right.

Where AI worked best:

  • Security audits — comprehensive, pattern-based, tedious for humans
  • CSS battles — Trix/Tailwind conflicts, responsive layouts
  • Boilerplate — Kubernetes manifests, CI/CD pipelines, database migrations
  • Stimulus controllers — well-structured, event-driven code with clear inputs and outputs

Where human judgment was essential:

  • Architecture decisions — SQLite over Postgres, sections-based content model, Hotwire over React
  • Feature prioritization — what to build next, what to skip
  • UX decisions — what the recovery banner should say, how the map legend should behave
  • Knowing when to stop — Claude will happily add features forever

The most effective pattern was using plan mode for complex features. I'd describe what I wanted, Claude would explore the codebase, ask targeted questions, and propose an implementation plan. I'd correct the plan, then Claude would execute it. The corrections were the most valuable part — "use vips not MiniMagick," "don't store secrets in chat," "that should be a Stimulus controller, not inline JS."

Why This Stack

Every choice was deliberate:

  • Rails 8 — convention over configuration, batteries included. Action Text, Active Storage, Turbo, Stimulus — everything in-framework.
  • SQLite — no external services. Solid Queue, Solid Cache, and Solid Cable mean the entire app runs from one process.
  • Hotwire — server-rendered HTML with targeted interactivity. No build step for JavaScript. No client-side routing. The form persistence controller proves you can build complex UX with Stimulus alone.
  • Tailwind v4 — despite the Trix conflicts, the utility-first approach made the editorial design system straightforward. Custom theme tokens (--color-brand-*, --font-heading) keep the design cohesive.
  • Kamal — Docker-based deployment without Kubernetes complexity. (The irony of deploying from a project that started with K8s manifests is not lost on me.)

What's Next

The app is deployed and functional. There's a backlog of ideas — image galleries, GPX track import, email notifications for new posts — but the core is solid. Twelve days from zero to a production travel blog with maps, rich content, and offline resilience.

The real takeaway isn't about the app itself. It's about the collaboration pattern. An AI co-pilot doesn't replace architectural thinking or product judgment. But it does compress the distance between "I want this feature" and "it's deployed" in a way that makes ambitious side projects actually achievable.

Sixty-four commits. Forty-one co-authored. One working travel blog.