So you created a cool website hosted on Github Pages, and it somehow got popular. You decide, hey, let's buy a custom domain so instead of myuser.github.io/cool-website
, it lives at coolwebsite.com
!
Not so fast!
If your site saves user data to Browser Storage, such as Local Storage or Indexed DB, then suddenly changing the domain will cause your users to lose their data.
I personally had to go through this process, and because I couldn't find any single "here's how to do it" articles, I'm writing one myself, just for you!
The Strategy: Popup and postMessage
Permalink to "The Strategy: Popup and postMessage"Here's the strat:
- On the new site, show the user a message saying the website has a new domain, and data needs to be transfered.
- In that message, show a button that says it clicking it will transfer the user's data to the new site.
- When the button is clicked, open a popup window to the old site.
- The old site gets its data in local storage, then uses
postMessage
to send it to the new site. It also closes itself. - The new site receives the message, and stores the data into its own local storage!
1) Create the Popup Site
Permalink to "1) Create the Popup Site"The goal of the popup window is to send data from the old domain to the new domain. It will do this by calling postMessage, a function that enables different domains to talk to each other, from the old domain.
Create another Github Pages website, which doesn't have a custom domain. Let's call it storage-migration
, so that it lives at https://username.github.io/storage-migration
. We're taking advantage of the fact that github pages websites share the same domain, namely username.github.io
, if they don't specify a custom domain. Same origin, same storage!
Create a page called cool-website/popup.html
, and have it run this code when the page is opened:
/// https://username.github.io/storage-migration/cool-website/popup
function send(message) {
// Specify the origin; don't accidentally send your data anywhere else
opener.postMessage(message, "https://coolwebsite.com")
}
function automaticallyTransfer() {
try {
// You may optionally filter for specific items in storage,
// since all sites at auroratide.github.io share the same storage
send(Object.entries(localStorage))
} catch (e) {
send("failed")
} finally {
// Always close the popup window, even if it failed
window.close()
}
}
if (opener != null) {
automaticallyTransfer()
}
When this popup window opens, it will attempt to send everything in localStorage
to the new domain, or if that fails for whatever reason, it'll send the string "failed"
. Note that it is important anytime you use postMessage
to include the intended recipient domain, to prevent other websites from opening your popup and stealing your users' data!
2) Respond to the message
Permalink to "2) Respond to the message"The other half of the postMessage
function is the "message" event. The "message" event carries the data that was sent with postMessage
, along with the domain of the sender.
Before opening the popup window from our new domain, we should start listening for this event. Put this code on the site using your new custom domain:
/// https://coolwebsite.com
function respondToMessage(event) {
// Protect yourself: don't respond to messages from anywhere else
if (event.origin !== "https://username.github.io") return
// If not an array of entries, the popup must have failed
if (!Array.isArray(event.data)) {
setMigrationStatus(event.data)
// Otherwise, move things into localStorage on the new site!
} else {
event.data.forEach(([key, value]) => {
localStorage.setItem(key, value)
})
setMigrationStatus("done")
}
}
// Listen for the message, before opening the popup
// Only if not started, though. getMigrationStatus() is defined later.
if (getMigrationStatus() === "not started") {
window.addEventListener("message", respondToMessage)
}
3) Open the popup
Permalink to "3) Open the popup"Your new domain must open a popup window to the original domain. We'll do this by attaching a handler to a button that the user clicks to start the transfer.
It's up to you where you want to put this button. Place this code on your new domain:
/// https://coolwebsite.com
<button id="transfer-button">Start Data Transfer</button>
/// https://coolwebsite.com
function startDataTransfer() {
const wasOpened = window.open(
"https://auroratide.github.io/cool-website",
"datatransfer",
"popup"
)
// Won't open if the user blocks popups
if (!wasOpened) {
setMigrationStatus("failed")
}
}
document.querySelector("#transfer-button")
.addEventListener("click", startDataTransfer)
4) React to Migration Status
Permalink to "4) React to Migration Status"We want the UI to show one of three things depending on where the user is in the migration process:
- "not started": Show the Transfer button
- "failed": Show an error message and what to do next
- "done": Show the site like normal
Additionally, we need to remember the migration status so the user isn't presented with a transfer button every time they load the site! Therefore, we'll store this status into local storage.
function getMigrationStatus() {
return localStorage.getItem("migrationStatus") ?? "not started"
}
function setMigrationStatus(status) {
localStorage.setItem("migrationStatus", status)
// Put any UI code here to react to either "not started", "failed", or "done"
}
It's up to you what UI makes sense. In my case, I personally used a modal dialog to force the user to either start the transfer to dismiss it, since modals block interaction with the rest of the site.
- If not started, open the modal and show the transfer button in it.
- If failed, show the error in the modal and instructions for a manual transfer.
- If done, show a success message in the modal.
Have a backup plan!
Permalink to "Have a backup plan!"As developers, we are responsible for handling as many errors or weird scenarios as we can think of:
- What if the user has a popup blocker?
- What if the javascript in the popup fails for some reason, maybe due to a security exception?
- What if due to a security setting, local storage exists in the popup but is empty?
- What if the user refuses to open the popup?
If for whatever reason we can't automatically transfer the user's data, we have to provide an alternative; we can't just say sorry and leave them with nothing!
In my case, I found it sufficient to provide a way to manually transfer the data. Here's a good strategy:
- If migration fails, provide a link to a page on the old website.
- The old website displays a button which copies data into the person's clipboard.
- The new website displays a button which takes the data from the person's clipboard, and performs the transfer.
A Lesson in Progressive Enhancement
Permalink to "A Lesson in Progressive Enhancement"Why spend all this effort building a backup page when most users will succeed with the popup? Or conversely, why bother with a popup at all and just have everyone go through the copy-paste flow?
As a general rule of thumb, we want to follow the principle of progressive enhancement:
Give the best possible experience for the person's situation.
In other words, we want our code to work at a minimum for people with heavy restrictions, and to feel seamless for people with less restrictions.
Unless the development effort is very large, we don't want to neglect people with disabilities, people locked into old browsers, people who are security-conscious, people in low-internet places, and so on. If a small investment on my part makes a lot more people happy, I find it worth the effort.
Just more stuff if you're interested
Permalink to "Just more stuff if you're interested"That's the end of the article basically! But here's more content if you're curious or just like reading my word soup.
Real-life Example
Permalink to "Real-life Example"I recently moved my Pokemon 5e Reference website to a new domain, poke5e.app, honestly just because I liked it more than auroratide.github.io/poke5e
. Since I architected the entire app to be "accountless" (no login required!), I heavily relied on Local Storage, and therefore I absolutely had to migrate people's data.
As I suspected, it was important to include a backup page! I coded some rudimentary analytics to track how successful people were in transfering their data, and while most people succeed, I've proved that some people visit the generic fallback page.
Here are links to production code conducting the strategy outlined on this page.
- On New Domain:
- Transfer Modal (opens the popup)
- Message Listener (responds to postMessage)
- Manual Transfer Page (links to the fallback page on the old domain)
- On Old Domain:
- Popup Page (initiates postMessage)
- Fallback Page (lists data for manual transfer)
What about Storage Access API?
Permalink to "What about Storage Access API?"Popup windows are gross. But I used them instead of an iframe due to a thing called State Partitioning.
[Browsers] double-key all client-side state by the origin of the resource being loaded and by the top-level site.
In other words:
fruit.com
stores its stuff atfruit
.vegetable.com
stores its stuff atvegetable
.fruit.com
iframed invegetable.com
stores its stuff atfruit-vegetable
, notfruit
. Therefore, it cannot access data created at top-levelfruit.com
.
The Storage Access API allows iframed content to access unpartitioned state. By requesting access, the iframed window can properly get local storage data and send it to its parent window.
Unfortunately, at the time I was implementing all this, the API only works for cookies and not any other kinds of storage. See Can I Use for browser compatibility!
Somewhat unfortunately, I implemented an entire solution using the Storage Access API, only to find out the hard way that I had accidentally neglected every browser but Chrome 🙃