I migrated a website in about an hour. Here's how I did it.

By Greg L. TurnquistPublishedUpdated4 min read

I always wanted to own my OWN bit.ly service. If you’ve been living under a rock, bit.ly is a link shortening service. And if you want ANY value out of that, it starts at $10/month (but can quickly scale to $199/month).

I decided a few years ago that I wanted my OWN shortening service. And so I bought trnq.st (Turnquist without the vowels!)

I spun up a Wordpress website to initially host this, but it NEVER performed very well. It was always so dang slow!

So I decided to have a little chat with ChatGPT earlier this year. And pretty quickly we had put together this:

import json
import os
import hashlib
from urllib.parse import urlparse

# Load redirects
with open("redirects.json", "r") as f:
    redirects = json.load(f)

# Ensure output directory exists
output_dir = "public"
os.makedirs(output_dir, exist_ok=True)

# Google Analytics Measurement ID (Replace with your actual ID)
GA_MEASUREMENT_ID = "<<redacted>>"

# Function to create a short hash if needed
def get_hash(url):
    return hashlib.sha256(url.encode()).hexdigest()[:6]  # 6-character hash

# HTML Redirect Template (307 Temporary Redirect) with Google Analytics
html_template = """<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="refresh" content="2; url={url}">
    <script async src="https://www.googletagmanager.com/gtag/js?id={ga_id}"></script>
    <script>
        window.dataLayer = window.dataLayer || [];
        function gtag(){{dataLayer.push(arguments);}}
        gtag('js', new Date());

        gtag('config', '{ga_id}');

        setTimeout(function() {{
            window.location.replace("{url}");
        }}, 150);
    </script>
    <title>{page_title}</title>
</head>
<body>
    <p>Redirecting... <a href="{url}">Click here if not redirected.</a></p>
</body>
</html>"""

# Generate redirect pages
for slug, url in redirects.items():
    slug_hash = slug if slug else get_hash(url)  # Use slug if provided, else hash
    redirect_dir = os.path.join(output_dir, slug_hash)
    os.makedirs(redirect_dir, exist_ok=True)

    # Extract domain and path for page title
    parsed_url = urlparse(url)
    page_title = f"{parsed_url.netloc}{parsed_url.path}"

    with open(os.path.join(redirect_dir, "index.html"), "w") as f:
        f.write(html_template.format(url=url, ga_id=GA_MEASUREMENT_ID, page_title=page_title))

print(f"Generated {len(redirects)} redirect pages in '{output_dir}' with Google Analytics tracking!")

This combined with a data file that looks like this:

{
	"history-of-spring": "https://springbootlearning.com/history-of-spring",
	"junit-history": "https://springbootlearning.com/junit-history",
	"mockito-book": "https://springbootlearning.com/mockito-book",
	"book": "https://springbootlearning.com/book",
	"FMonAils0J": "https://partner.canva.com/springbootlearning",
	"hosting": "http://click.dreamhost.com/aff_c?offer_id=8&aff_id=2043&source=medium",
	"app-servers-are-dead": "https://speakerdeck.com/gregturn/njug-app-servers-are-dead",
	"9lbr": "https://twitter.com/dstainer/status/788428727046680576",
	"fqjm": "https://twitter.com/JAXenter/status/785897525488254976",
	"a2nb": "https://twitter.com/amirsedighi/status/782464630811656192",
	......
}

…and a little Github Action magic, and you’ve got yourself a fully-functional bit.ly clone!

I pushed the envelope hard, and had ChatGPT help by adding a workflow that would let me add new entires simply by opening a GitHub issue and auto-generate hashed values (or override with a custom slug) and entering a comment of “/approve” in a comment. However, to properly keep this repo private and away from prying eyes, I rigged ANOTHER GitHub repo to be the target of running this build job.

And that’s where things got sticky.

I got it working, but I faced the issue of expiring tokens. Given I’ve really pushed ProCoder.io far in building a personal platform (tech articles, newsletters, ability to sell/ship e-books), I wanted to migrate this half-baked Python script to a fully-fledged Astro.js statically generated site!

So tonight, I asked Claude Code to help me do just that.

And one hour later, we were done.

Basically, I was able to tell Claude Code to go and read all the details about ProCoder.io. This not only coached Claude exactly the way I like to use Astro.js, it also let me tell it how I prefer to format my commits. Additionally, it helped move the redirects.json data file to a proper idiomatic location.

And finally, it was able to break away from ProCoder.io and ensure trnq.st was completely, totally, ecumenically, statically generated.

The final polishing was having it update that GitHub workflow to handle creating new redirects through issue management. When it would fail, I simply fed the ENTIRE log to Claude Code, and it fixed stuff!

With that workflow polished up and operational, the website deploying quite nicely to Cloudflare Workers, I was able to boot GitHub Pages and buff up my DNS entries.

Doing all that in just one hour is quite impressive. It’s like sculpting…but with code.

Whining about AI not being “general intelligence” or that AI is nothing but “garbage in/garbage out” and not worth your time is ignoring the big picture.

AI is here. Everyone is speculating where it’s going. Well I don’t know “where it’s going” and neither does anyone else.

But you can be sure I’m going to leverage tools like Claude Code to the hilt to build all the assets I’ve wanted to build in the past but were deemed “too hard”.

AI tools have lowered the bar so much, I’m contemplating what SaaS tools I really need. I already ditched one that was costing me $150/year.

I’m quite excited to see what I can build. Because when you boil it all down, AI is just another tool. Kind of like compilers, code completers, and linters. It’s just that THIS tool packs a real whollop!

—Greg

P.S. Articles are now posted on Substack. Subscribe today!

© 2025 Greg L. Turnquist. All Rights Reserved

Related articles