Migrating from HubSpot to a Static Site on AWS
The SEO pitfalls that silently kill organic traffic during a CMS migration — and how to avoid them when moving from HubSpot to a static site on S3 and CloudFront.
Migrating from a CMS like HubSpot to a modern static stack seems straightforward. Export your content, rebuild the frontend, deploy. From a project perspective, it looks like a simple rebuild.
In reality, a single misstep can quietly erase years of SEO progress. Traffic drops that do not show up until weeks later. Pages that Google silently deindexes. Domain authority that splits in half without warning.
We have seen this happen firsthand. During an earlier migration within our own ecosystem, organic traffic dropped over 60% within a month — entirely due to preventable technical SEO failures. Since then, we have identified the patterns that cause this and built safeguards to prevent it. We applied those same learnings to keyq.cloud before launch.
This article walks through every pitfall we found, so you do not have to learn them the hard way.
Who This Is For
- Teams migrating off HubSpot or a similar CMS platform
- Developers building static sites with Astro, Next.js, or similar frameworks
- Companies that rely on SEO-driven inbound leads and cannot afford organic traffic loss
If inbound leads are part of your revenue pipeline, the stakes here are real. Lost rankings translate directly into lost pipeline within weeks.
Why Teams Leave HubSpot
HubSpot is a powerful platform, but it is designed to be an all-in-one solution: CMS, email marketing, CRM, payments, documents, and more. If you are using all of those features, it can be worth the investment. But many teams — including ours — end up using only the CMS and paying for a platform full of capabilities they never touch.
Common reasons to migrate:
- Paying for what you do not use — HubSpot bundles a broad feature set, and even on the free tier, the CMS comes with limitations that push you toward paid plans. If you only need a website and a blog, you are carrying a lot of overhead.
- Content limitations — no native syntax highlighting for technical content, limited control over page structure, a templating system that adds complexity without flexibility
- Performance — HubSpot injects tracking scripts, stylesheets, and markup you cannot optimize away
- Cost — a static site on S3/CloudFront costs as low as a few dollars per month at moderate traffic levels, versus hundreds for a CMS subscription
Moving to a static site on CloudFront can give you sub-100ms page loads in most regions and full control over every byte served. The migration itself is not hard. The danger is what you forget to do afterward.
The Stack
- Astro for static site generation — fast builds, Markdown-native for blog content
- Tailwind CSS v4 for styling
- S3 for hosting the built output
- CloudFront for CDN and HTTPS
- GitHub Actions for CI/CD — push to main triggers a build, S3 sync, and cache invalidation
The deploy workflow is simple:
- name: Build
run: npm run build
- name: Sync to S3
run: aws s3 sync ./dist s3://${{ secrets.S3_BUCKET }} --delete
- name: Invalidate CloudFront cache
run: aws cloudfront create-invalidation --distribution-id ${{ secrets.CF_DISTRIBUTION_ID }} --paths "/*"
The Blog Migration
If you have years of blog content in HubSpot, the migration process looks like this:
- Export from HubSpot — HubSpot’s site export gives you raw HTML files
- Convert to Markdown — Write a migration script that extracts article content, strips HubSpot markup, and generates Markdown files with proper frontmatter
- Handle images — Blog images hosted on HubSpot’s CDN can be referenced as external URLs initially, then gradually moved to your own hosting
The content schema in Astro is clean:
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
slug: z.string(),
tags: z.array(z.string()).default([]),
image: z.string().optional(),
}),
});
Where Most Migrations Fail
The migration itself is the easy part. The failures are silent, technical, and cumulative. By the time you notice them, the damage is already done. Here are the eight issues that will damage your organic traffic if you do not address them before launch.
1. Missing 301 Redirects from Old URLs
If your URL structure changes and you do not implement 301 redirects, every indexed URL in Google starts returning 404 errors. Google begins deindexing those pages, treating them as removed content. Each page carried accumulated authority from years of backlinks and search impressions — much of that authority is lost.
HubSpot typically serves blog posts at paths like /en/blog/{slug}.html. Your new site probably serves them at /blog/{slug}/. Without redirects, these are completely different URLs to Google.
The fix is a CloudFront Function that maps old URL patterns to new ones:
// /en/blog/{slug}.html → /blog/{slug}/
var blogMatch = uri.match(/^\/en\/blog\/([^\/]+?)(\.html)?$/);
if (blogMatch) {
return {
statusCode: 301,
statusDescription: 'Moved Permanently',
headers: {
location: { value: 'https://www.example.com/blog/' + blogMatch[1] + '/' }
}
};
}
You also need to map old root pages (.html extensions), redirect defunct product pages to the closest current equivalent, and add a catch-all .html stripper for anything you missed.
This is the single highest-impact item on this list. Get this wrong and everything else is academic.
2. www vs. Non-www Domain Split
If both example.com and www.example.com resolve to your site without one redirecting to the other, Google treats them as separate sites. Your domain authority splits between two competing versions — neither as strong as the whole.
Pick one as canonical and 301 redirect the other. Then update every reference in your codebase:
- Site configuration (
astro.config.mjsor equivalent) robots.txtsitemap URL- JSON-LD structured data (Organization, WebSite, BlogPosting schemas)
- Canonical link tags
- Open Graph URLs
Missing even one of these creates conflicting signals. Search engines see www.example.com in your canonical tag but example.com in your sitemap and do not know which to trust.
3. Relative Image URLs in JSON-LD
If your BlogPosting structured data passes image paths directly from frontmatter, you may be using relative URLs:
// Bad — relative URL in JSON-LD
...(post.data.image && { image: [post.data.image] }),
JSON-LD requires absolute URLs. Google’s structured data validator will silently ignore relative paths, which means your blog posts lose rich snippet eligibility — featured images in search results, knowledge panels, and discover feeds. The fix:
...(post.data.image && {
image: [post.data.image.startsWith('http')
? post.data.image
: new URL(post.data.image, 'https://www.example.com').toString()]
}),
4. Sitemap Without lastmod
The @astrojs/sitemap plugin generates a sitemap, but by default it does not include <lastmod> dates. Without lastmod, search engines have no signal for when content was last updated. This is not a ranking factor, but it improves crawl efficiency and freshness signals — Google can prioritize recently updated pages during crawls.
One line in the serialize function fixes it:
serialize(item) {
item.lastmod = new Date().toISOString();
return item;
}
5. Empty Organization sameAs
If your Organization JSON-LD has sameAs: [], you are missing an opportunity to connect your site to your social profiles in Google’s knowledge graph. Populate it with your LinkedIn, Clutch, GitHub, or other authoritative profiles.
6. No Custom 404 Page
When users hit a dead link on a static S3 site, they see a generic XML error page. This is a terrible user experience and a missed opportunity to guide users to useful content. Create a branded 404 page with clear navigation back to key pages — homepage, blog, contact.
7. dateModified Always Equaling datePublished
If your BlogPosting JSON-LD hardcodes dateModified to the publish date, you are telling Google that your content has never been updated. Add an optional updatedDate field to your content schema and use it in the structured data:
dateModified: (post.data.updatedDate || post.data.pubDate).toISOString(),
When you update a post, set updatedDate in the frontmatter. Google treats this as a freshness signal.
8. Cookie Consent and Analytics
If Google Analytics loads unconditionally on every page regardless of consent, this can create GDPR compliance issues depending on your audience’s jurisdiction. Gate the GA4 script behind a localStorage check and build a cookie consent banner that only loads analytics after explicit acceptance.
Before You Launch
These issues show up in almost every CMS migration, regardless of stack. Do not go live until you have verified every item on this list. Each one is silent — you will not see the damage in your analytics for weeks, and by then the recovery timeline is months, not days.
- 301 redirects for every old URL that Google has indexed
- Canonical domain chosen and consistently enforced (www or bare)
- JSON-LD uses absolute URLs for all properties
- Sitemap includes lastmod dates
- Organization schema has populated sameAs
- Custom 404 page exists
- Blog structured data supports dateModified
- Analytics gated behind cookie consent
The migration itself takes a weekend. Finding and fixing these issues after launch — after Google has already started deindexing your pages — takes much longer.
If you are planning a migration, we can help you avoid the silent failures that do not show up until your traffic disappears. We have done this across multiple sites and caught these issues before they became costly — often before launch, when fixes are still cheap. Reach out if you want a second set of eyes before you launch.