Why I Moved a Client Site Off an AI Builder and Rebuilt It From Scratch

LandingSite AI got Griffin Renovation online fast, but SEO limits and vendor lock-in pushed me to rebuild the whole thing in plain HTML, CSS, and JavaScript.

· 6 min read

A few weeks ago I wrote about shipping Griffin Renovation’s website using LandingSite AI. The turnaround was fast, the client was happy, and the site looked good. That should have been the end of the story.

It wasn’t.

The site was ranking poorly. Griffin Renovation serves Cache Valley, Utah, and for local search queries they were barely showing up. I started digging into why, and the answer was straightforward: I didn’t have enough control over the SEO. Meta tags, structured data, canonical URLs, sitemap configuration. LandingSite handles all of that for you, which is great until you need to change something specific and can’t.

On top of that, the monthly hosting fee was adding up for what amounted to a static three-page site. The client was paying a recurring cost for infrastructure I could replace with a free Netlify deploy. Between the SEO ceiling and the ongoing cost, it made sense to rebuild.

The hardest part wasn’t the code

Rebuilding a three-page site in HTML and Tailwind is not complicated. The hardest part was actually getting the domain moved. LandingSite has the hosting locked down, so the only way to transfer is by contacting their support and going back and forth. It’s not a one-click DNS change. That process took longer than writing the entire site.

Once I had the domain sorted, the actual build was fast. No framework, no build step, no npm. Just HTML files, a CSS file, and two JavaScript files. Deployed to Netlify with a _headers file for caching and security.

Taking control of SEO

The whole point of the migration was better SEO, so I went deep on structured data. LandingSite gave me basic meta tags but nothing like proper JSON-LD for a local business. Here’s what I added to the homepage:

{
  "@context": "https://schema.org",
  "@type": "HomeAndConstructionBusiness",
  "name": "Griffin Renovation",
  "telephone": "+1-435-881-7791",
  "areaServed": [
    { "@type": "City", "name": "Logan", "containedInPlace": { "@type": "State", "name": "Utah" } },
    { "@type": "City", "name": "Smithfield", "containedInPlace": { "@type": "State", "name": "Utah" } },
    { "@type": "City", "name": "Hyrum", "containedInPlace": { "@type": "State", "name": "Utah" } },
    { "@type": "City", "name": "North Logan", "containedInPlace": { "@type": "State", "name": "Utah" } }
  ],
  "hasOfferCatalog": {
    "@type": "OfferCatalog",
    "name": "Renovation Services",
    "itemListElement": [
      { "@type": "Offer", "itemOffered": { "@type": "Service", "name": "Home Remodels" } },
      { "@type": "Offer", "itemOffered": { "@type": "Service", "name": "Drywall Installation & Repair" } }
    ]
  }
}

That’s a trimmed version. The full schema includes all eight cities in Cache Valley, all five service types, opening hours, aggregate ratings with individual reviews, and social links. The HomeAndConstructionBusiness schema type is exactly what Google expects for a local contractor. LandingSite had none of this.

I also added proper canonical URLs, Open Graph tags, and Twitter Card meta on every page. Plus a real sitemap with priorities and change frequencies instead of whatever LandingSite was generating behind the scenes.

Reusable components without a framework

With only three pages, I didn’t need React or Astro. But I also didn’t want to copy-paste the header and footer into each HTML file. So I went with a simple JavaScript injection pattern:

document.addEventListener('DOMContentLoaded', () => {
  injectHeader();
  injectFooter();
  initMobileMenu();
  document.body.classList.add('loaded');
});

The header and footer are functions that write HTML into placeholder <div> elements. Active nav states are determined from window.location.pathname. It’s not fancy, but it means updating the navigation is a single-file change instead of editing three HTML files.

The tradeoff is that search engine crawlers running without JavaScript won’t see the nav. I added a <noscript> fallback on every page with a plain HTML nav so crawlers and accessibility tools still get the full link structure.

Preventing the flash of unstyled content

Since I’m using Tailwind CSS v4 via the browser CDN (no build step), there’s a moment before Tailwind processes the styles where the page looks broken. A small CSS trick handles it:

body {
  opacity: 0;
  transition: opacity 0.15s ease-in;
}

body.loaded {
  opacity: 1;
}

The loaded class gets added by JavaScript after the components inject. The page fades in smoothly instead of flashing raw HTML for a split second.

The contact form

LandingSite’s built-in contact form was one of its best features. Replacing it meant finding a solution that didn’t require a backend. I went with EmailJS, which sends emails directly from the browser using their API:

form.addEventListener('submit', async (e) => {
  e.preventDefault();

  const name = form.querySelector('#full-name').value.trim();
  const email = form.querySelector('#email').value.trim();
  const message = form.querySelector('#message').value.trim();

  if (!name || !email || !message) {
    statusMsg.textContent = 'Please fill in all required fields.';
    statusMsg.className = 'text-red-500 text-sm mt-4';
    return;
  }

  submitBtn.disabled = true;
  submitBtn.textContent = 'Sending...';

  try {
    await emailjs.send(EMAILJS_SERVICE_ID, EMAILJS_TEMPLATE_ID, {
      from_name: name, from_email: email, phone, message
    });
    statusMsg.textContent = "Message sent successfully! We'll be in touch soon.";
    form.reset();
  } catch (error) {
    statusMsg.textContent = 'Something went wrong. Please call us directly at (435) 881-7791.';
  }
});

The error fallback displays the phone number directly. For a renovation company, if the form breaks, the client still needs a way to reach them. That matters more than a retry button.

Where it is now

The site is live at griffinrenovation.com. Three pages, no build process, deployed free on Netlify. Images are served through Cloudflare’s image delivery CDN for automatic compression and WebP conversion. Security headers and aggressive caching are handled by a _headers file. The client stopped paying a monthly fee and got better SEO in the process.

What I’d do differently

Skip the AI builder entirely. I wrote about LandingSite positively in the first post, and I stand by that for certain use cases. But for a client where SEO matters and you know you’ll want full control eventually, the AI builder is just an extra step. The time I saved on the initial build, I spent on the migration. If I’d written the HTML from the start, the total hours would have been about the same, and I wouldn’t have had to deal with the domain transfer.

Start with structured data from day one. The JSON-LD schema was the single biggest SEO improvement. I should have had that on the LandingSite version too, even if it meant injecting it through their advanced editor. Local business schema with area served, services, and reviews is table stakes for a contractor competing in local search.

Use a static site generator for anything beyond three pages. Plain HTML worked here because the site is tiny. If Griffin Renovation wanted a blog, a project portfolio, or more service pages, I’d reach for Astro. The JavaScript component injection pattern is fine for three pages but wouldn’t scale well.

Stay in the loop

Get notified when I publish new posts. No spam, unsubscribe anytime.

Built as part of

View the project →