Moving an E-Commerce Website to Astro: How I Discovered How to Love Headless Architecture

#astro #headless #e-commerce #woocommerce #tinacms

I took on what appeared to be a simple project last year. Convert a WordPress skincare website to Astro. The client, a business owner who was fed up with her slow website, sells natural cosmetics in Chile; nothing fancy, just quality products.

I estimated that it might take three weeks. Four tops.

Two months passed. However, those two months taught me more than the year I spent working as a freelancer.

The WordPress Issue (Which Had Nothing to Do with WordPress)

The thing about WooCommerce and WordPress is that they are functional. They actually do work. This stack powers millions of online businesses that generate revenue every day. Therefore, the issue was not that it was damaged.

Its slowness was the issue. and difficult to edit. Additionally, the client continued to send me PageSpeed screenshots with the dismal orange score of 45.

Every. unmarried. Week. “Jaime, is this normal?”

No, it’s not typical. I couldn’t tell her that it’s actually kind of for WordPress sites that haven’t been optimized.

However, the speed wasn’t the true agony. It was observing her battle with the WordPress admin. She is a businesswoman who creates amazing skincare products; she is not technical. Why is it necessary for her to comprehend the WordPress block editor in order to modify a headline?

I once saw her. via Zoom. For a sale, she wanted to update the homepage banner. She needed fifteen minutes. It took fifteen minutes to navigate the blocks, find the correct page, click through menus, make the change, preview (which broke the layout), fix the layout, preview again, and then publish.

She once unintentionally erased a whole product section. Simply… gone. because she accidentally hit the editor’s X button.

I realized then that we needed to take a different approach.

The Strategy (which I Modified Three Times)

I initially thought, “Okay, let’s just optimize the theme.” Clean up some code, add caching, load images lazily, and consider using a CDN. Typical performance optimization techniques.

But then I began to wonder: what if we didn’t?

What if we utilized WordPress for its true strengths rather than attempting to make it quick? WooCommerce is excellent at tracking orders, managing inventory, and processing payments. That equates to fifteen years of tried-and-true e-commerce reasoning. Why would I rebuild that?

However, the front end? What do users see? That might be something completely different.

Astro entered the picture at that point. This “partial hydration” approach, in which JavaScript is only shipped for the sections that truly require it, was something I had heard about. In any case, static content makes up the majority of an e-commerce website. The about page, blog entries, and product listings. For a page that essentially consists of text and images, why are we sending 200KB of JavaScript?

Thus, the strategy became:

  • Maintain WooCommerce for inventory and payments.
  • For a quick frontend, use Astro.
  • Include TinaCMS so the client can make visual edits.

To be honest, TinaCMS was a bit of a risk. I had never applied it to an actual project. However, the visual editing demo appeared to be exactly what the client required.

A Timeline of When Everything Broke

Week 1: The Adventure with WooCommerce API

In theory, it seemed easy to import product data from WooCommerce into Astro. There is a REST API for WooCommerce. Astro is able to retrieve data. It should be alright, right?

Voice of the narrator: It wasn’t good.

Yes, the API is documented. However, there are two types: those that are documented and those that “actually works the way you’d expect.” These are not interchangeable.

To reliably retrieve products, I spent two days creating a TypeScript wrapper. The code itself is straightforward: format the price in Chilean pesos and retrieve products by ID. But getting it to function consistently? It required some time.

// Fetch products by ID with type safety
const products = await getProductsByIds([123, 456, 789]);

// Format prices in local currency
formatPrice(21990); // Returns "$21.990"

// Generate add-to-cart URLs
getAddToCartUrl(productId);

Managing failure was the true challenge. Because users see blank product pages when an e-commerce site’s API goes down and you’re unprepared. Thus, there are no sales. This indicates that the customer calls you in a panic.

I therefore created a backup plan. The website displays cached product data in the event that the API fails for any reason, such as a network problem, expired credentials, or WooCommerce choosing to take a nap. Even though the prices are a little out of date, it’s still far better than a blank page.

export async function getProductsByIds(ids: number[]) {
  try {
    const response = await fetch(`${WC_URL}/wp-json/wc/v3/products`, {
      headers: {
        'Authorization': `Basic ${base64Credentials}`
      },
      params: { include: ids.join(',') }
    });

    return await response.json();
  } catch (error) {
    console.error('WooCommerce API error:', error);
    // Return cached/fallback product data
    return getFallbackProducts(ids);
  }
}

Is it flawless? No. Does it stop panic calls at three in the morning? Indeed.

Week 3: The Story of the Shopping Cart

The cart was… intriguing.

I wanted it to have a contemporary feel. Are you familiar with those lovely e-commerce websites where adding items to your cart is a seamless process? Just a lovely drawer that displays your items, without any janky behavior or page reloads? I wanted that.

However, WooCommerce checkout was still required. Because payments, once more. I’m not going to reconstruct a payment system.

My solution—a React cart drawer that stores everything in localStorage while you browse and then encodes all the cart data into the URL and sends you to WordPress—was probably not the most elegant thing I’ve ever created, but it works.

Yes. URL specifications. for cart information.

Developers are already yelling. “URLs shouldn’t be that long!” Listen, it functions properly for 95% of orders (1-3 items). We may have problems with the infrequent customer who purchases twenty items at once. However, that hasn’t yet occurred.

https://example.com/?cart_data=PRODUCT_ID:QTY,PRODUCT_ID:QTY&auth_token=SIGNED_TOKEN

I created a plugin for WordPress that takes those URL parameters, parses the cart contents, adds them to the WooCommerce cart, and then reroutes to the checkout page.

<?php
// WordPress plugin: Cart transfer handler
add_action('init', function() {
    if (isset($_GET['cart_data'])) {
        $items = explode(',', sanitize_text_field($_GET['cart_data']));

        // Clear existing cart
        WC()->cart->empty_cart();

        // Add all items
        foreach ($items as $item) {
            list($product_id, $quantity) = explode(':', $item);
            WC()->cart->add_to_cart(
                intval($product_id),
                intval($quantity)
            );
        }

        // Redirect to checkout
        wp_safe_redirect(wc_get_checkout_url());
        exit;
    }
});

The user sees that their cart has already been filled. Enchantment. (The client doesn’t need to know that it’s hacky URL parameters; it’s not magic.)

Week 4: The SSO Catastrophe

It shouldn’t be necessary for users to log in twice. That is simply poor UX. When a user registers on the Astro frontend, they ought to be logged in automatically when they reach the WooCommerce checkout.

The obvious answer seemed to be JWT tokens. It’s simple: create a signed token on Astro, send it to WordPress, verify it, and log them in.

Except for a Thursday afternoon when nothing worked for two hours.

Token validation continued to fail. I looked over everything. Yes, the signing algorithm is SHA256. The payload structure is fine, including the timestamps and user ID. Wait, the secret key.

There was a hash symbol in the secret key. Additionally, everything that came after the hash was being treated as a comment in my.env file.

# Wrong - everything after # is ignored
JWT_SECRET=abc123#def456

# Correct - quoted to preserve special chars
JWT_SECRET="abc123#def456"

Thus, only half of the secret was actually being used. The incorrect key was being used to sign the tokens. Validation failed, of course.

Debugging took two full hours. because I didn’t enclose the secret in quotes.

This still irritates me.

The token generation on the Astro side:

import { createHmac } from 'crypto';

const generateSSOToken = (userId: string, secret: string): string => {
  const payload = {
    sub: userId,
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + 300 // 5 minutes
  };

  const signature = createHmac('sha256', secret)
    .update(JSON.stringify(payload))
    .digest('hex');

  return `${Buffer.from(JSON.stringify(payload)).toString('base64')}.${signature}`;
};

WordPress validates the token and authenticates the user:

<?php
// WordPress plugin: SSO token validation
function validate_sso_token($token) {
    list($payload_encoded, $signature) = explode('.', $token);

    $payload = json_decode(base64_decode($payload_encoded), true);

    // Verify signature
    $expected_signature = hash_hmac(
        'sha256',
        json_encode($payload),
        JWT_SECRET
    );

    if (!hash_equals($expected_signature, $signature)) {
        return false;
    }

    // Check expiration
    if ($payload['exp'] < time()) {
        return false;
    }

    // Log user in
    $user = get_user_by('id', $payload['sub']);
    wp_set_auth_cookie($user->ID);

    return true;
}

Weeks 5–6: Learning Curve for TinaCMS

TinaCMS fulfills its promise of visual editing. However, there was no obvious way to get there.

Astro components are static, which is the issue. They only render once during the build process. For live editing to function, TinaCMS requires React components. Therefore, I had to use their useTina hook to wrap each editable section in a React component.

// tina/pages/SectionWrapper.tsx
import { useTina, tinaField } from 'tinacms/dist/react';

export default function SectionWrapper({ query, variables, data }) {
  const { data: tinaData } = useTina({ query, variables, data });
  const section = tinaData?.section;

  return (
    <section data-tina-field={tinaField(section, 'title')}>
      <h2>{section.title}</h2>
      <div data-tina-field={tinaField(section, 'description')}>
        {section.description}
      </div>
    </section>
  );
}

Then in the Astro page:

---
import SectionWrapper from '@/tina/pages/SectionWrapper';
import { client } from '@/tina/__generated__/client';

const { data, query, variables } = await client.queries.section({
  relativePath: 'section.json'
});
---

<SectionWrapper
  client:load
  {query}
  {variables}
  {data}
/>

Once you grasp the pattern, it’s not difficult. However, it took me longer than I’d like to acknowledge to figure out the pattern. Although there is still a learning curve, the documents are good.

But now? The customer adores it. She has the ability to click on any text, edit it directly on the page, view her changes instantly, and then hit save. WordPress admin is no longer needed. No more inadvertently erasing parts.

The learning curve is worthwhile.

Bilingual Assistance (The Easy Way)

Both Spanish and English versions were required for the website. I examined i18n libraries. All of them seemed excessive for essentially “show different content based on URL.”

I decided to keep things simple by creating distinct folders for the content of each language. One folder contains blog entries in Spanish, while another contains those in English. Separate JSON files are sent to pages.

content/
├── blog/           # Spanish posts
├── blog-en/        # English posts
└── pages/
    ├── home.json
    └── home-en.json

The prefix /en/ is applied to English routes. That’s all.

// src/pages/blog/[slug].astro (Spanish)
// src/pages/en/blog/[slug].astro (English)

const { slug } = Astro.params;
const locale = Astro.url.pathname.startsWith('/en') ? 'en' : 'es';
const posts = await getCollection(locale === 'en' ? 'blog-en' : 'blog');

Is this the internationalization strategy used by large corporations? Most likely not. Does it function flawlessly for this project? Of course.

The straightforward solution is sometimes the best one.

Making It Quick (The Fun Part)

Do you recall that 45 PageSpeed score? We reached 92.

This is what genuinely changed things:

Self-Hosted Fonts

Google Fonts can be replaced with self-hosting. The biggest victory was this one. Rendering is blocked by an external request made by Google Fonts. First Contentful Paint was reduced by 1.4 seconds by switching to Fontsource, which uses the same fonts but serves WOFF2 files from my own domain. One modification, enormous effect.

// astro.config.mjs
import fontsource from '@fontsource/source-sans-pro';

export default defineConfig({
  // Fonts bundled at build time, no external requests
});

Lazy-Loaded Video

This beautiful background video is on the homepage. 4.3MB at first. I converted it to WebM (1.3MB) and only loaded it when the viewport was open. Why force them to download it when the majority of mobile users never even scroll far enough to see it?

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const video = entry.target as HTMLVideoElement;
      video.src = video.dataset.src!;
      video.load();
      observer.unobserve(video);
    }
  });
});

observer.observe(videoElement);

Responsive Images

Three sizes of responsive images. Images on mobile devices are 300 pixels wide. Tablets receive 600 pixels. The desktop receives 1200 pixels. Not revolutionary, but very effective.

<img
  src={image.url}
  srcset={`
    ${image.url}?w=300 300w,
    ${image.url}?w=600 600w,
    ${image.url}?w=1200 1200w
  `}
  sizes="(max-width: 768px) 300px, (max-width: 1200px) 600px, 1200px"
  alt={image.alt}
/>

Strategic Preloading

<link rel="preload" as="image" href={heroImage} fetchpriority="high" />

Code splitting was the optimization that failed. Before realizing that the majority of Astro’s pages don’t ship JavaScript at all, I tried to optimize JavaScript bundle sizes for half a day. I was making the wrong kind of optimization.

SEO Without the Need for Training Wheels

Yoast was lost during the WordPress migration. This resulted in the loss of automatic schema generation. which required doing everything by hand.

JSON-LD structured data for blog entries, local business information, and organization details. It’s tiresome but essential. You’re losing SEO points if you don’t give Google this information.

---
const organizationSchema = {
  "@context": "https://schema.org",
  "@type": "Organization",
  "name": "Brand Name",
  "url": Astro.site,
  "logo": `${Astro.site}logo.png`,
  "sameAs": [
    "https://facebook.com/page",
    "https://instagram.com/account"
  ]
};

const localBusinessSchema = {
  "@context": "https://schema.org",
  "@type": "BeautySalon",
  "name": "Business Name",
  "address": {
    "@type": "PostalAddress",
    "streetAddress": "Street Address",
    "addressLocality": "City",
    "addressCountry": "CL"
  },
  "openingHours": "Mo-Fr 09:00-18:00"
};
---

<script type="application/ld+json" set:html={JSON.stringify(organizationSchema)} />
<script type="application/ld+json" set:html={JSON.stringify(localBusinessSchema)} />

For blog posts:

---
const blogPostingSchema = {
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": post.title,
  "datePublished": post.date,
  "author": {
    "@type": "Person",
    "name": post.author
  },
  "image": post.image
};
---

Because Astro has a plugin that manages the sitemap automatically, including hreflang tags for the bilingual setup, it was simpler.

// astro.config.mjs
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  site: 'https://example.com',
  integrations: [
    sitemap({
      i18n: {
        defaultLocale: 'es',
        locales: {
          es: 'es',
          en: 'en'
        }
      }
    })
  ]
});

Security (The Stuff Nobody Talks About)

Rate limiting login attempts to stop brute force attacks is part of security (the stuff no one discusses). exponential backoff for a maximum of thirty seconds.

const loginAttempts = new Map<string, { count: number, lastAttempt: number }>();

async function handleLogin(email: string, password: string) {
  const attempts = loginAttempts.get(email) || { count: 0, lastAttempt: 0 };

  // Exponential backoff: 2^attempts seconds
  const delay = Math.min(Math.pow(2, attempts.count) * 1000, 30000);
  const timeSinceLastAttempt = Date.now() - attempts.lastAttempt;

  if (timeSinceLastAttempt < delay) {
    throw new Error(`Too many attempts. Wait ${Math.ceil((delay - timeSinceLastAttempt) / 1000)}s`);
  }

  // Attempt login...
  attempts.count++;
  attempts.lastAttempt = Date.now();
  loginAttempts.set(email, attempts);
}

For auth tokens, use HttpOnly cookies to prevent JavaScript from accessing them. To stop CSRF, SameSite=Strict.

export function setAuthCookie(token: string) {
  return new Response(null, {
    headers: {
      'Set-Cookie': `auth_token=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=86400`
    }
  });
}

validation of environment variables during startup. The application won’t launch if important variables aren’t present. No “it’ll probably be fine” backup plan.

const requiredEnvVars = ['JWT_SECRET', 'WC_CONSUMER_KEY', 'WC_CONSUMER_SECRET'];

requiredEnvVars.forEach(varName => {
  if (!import.meta.env[varName]) {
    throw new Error(`Missing required environment variable: ${varName}`);
  }
});

Because it’s not enjoyable to have production incidents at two in the morning.

Deployment (The Simple Part)

Deployment is nearly boring with Vercel. It deploys automatically when you push to main. Changes to the content are committed to Git by TinaCMS, which then initiates a rebuild and deployment.

The client is unaware that any of this is taking place. After making changes to the content and clicking “Save,” it goes live in a matter of minutes. That’s how things ought to be.

To maintain SEO, set up 301 redirects for all outdated WordPress URLs. Blog posts had to be redirected, and product URLs had to change their structure.

{
  "redirects": [
    {
      "source": "/product/:slug",
      "destination": "/producto/:slug",
      "permanent": true
    },
    {
      "source": "/:slug",
      "destination": "/blog/:slug",
      "permanent": true
    }
  ]
}

What I Would Do Differently

Make an earlier plan for the cart mechanism. I had to refactor it twice because I didn’t realize how complicated it would be.

Set aside more time to become familiar with TinaCMS. The setup took longer than anticipated, but once it works, the visual editing is fantastic.

Use special characters to test environment variables right away. It was possible to avoid that hash symbol bug.

The Outcomes

On mobile, PageSpeed increased from 45 to 92.

Without contacting me, the client is able to edit content on her own.

Payments are still reliably handled by WooCommerce.

I gained a great deal of knowledge about headless architecture.

Would I repeat the action? Yes, most likely. However, I would raise the price.

Concluding Remarks

Headless isn’t about utilizing the newest, most advanced technology. Using the appropriate tool for each task is crucial.

WooCommerce is an excellent e-commerce platform. Hold onto it.

The WordPress admin is cumbersome. Change it out.

Themes for WordPress are sluggish. Change them out.

You don’t have to start from scratch. All you have to do is figure out what isn’t functioning and replace it.

Begin modestly. Start Astro up. Include TinaCMS in a single section. Determine how to integrate WooCommerce. Construct gradually.

Additionally, if your environment variables contain special characters, please quote them for everyone’s sanity.

Have faith in me.