Setup Custom domains for your Notion pages for free (using Cloudflare)
baran
Posted on October 19, 2021
Table of Contents
Things you need
In order to follow this tutorial you need:
- Your own domain name (I'd recommend porkbun.com)
- Access to the domain names DNS settings
- Cloudflare.com account (no credit card or any payment required)
- Notion Page that's set to public
To set your Notion page to public click
Share
at the top of the page and clickShare to web
. ---
Cloudflare Setup and DNS Settings
Cloudflare is often used as a content-delivery network (CDN), essentially it serves content to websites and has tools in place to protect your website, such as if it's under a DDoS attack.
What we need to do on Cloudflare is use the Workers feature which will forward incoming requests from users to your public Notion page, we'll get onto this in a bit.
Assuming you've registered on Cloudflare, you now need to add your domain to it. Here's how to do that:
- Click add site on the top right
- Choose the free plan.
- Copy the nameservers Cloudflare provides. Usually something like
craig.ns.cloudflare.com
(it's important you copy the ones Cloudflare provides for you).
Change the default DNS server of your domain provider
This step will vary depending on your domain provider, however you have to change from your providers default DNS nameservers to the ones Cloudflare provided you with.
For example here's a tutorial for cPanel - link to tutorial be warned it's very wordy but the main bit you need starts with "How Do I Change Nameservers in cPanel?" I'd suggest using CTRL + F
to go straight to that heading.
Note that when you change your DNS servers it can take a while for the changes to take place, worst case scenario it can take up to 24 hours (usually an hour or two maximum though).
Cloudflare worker
This is where the fun begins and it gets a little techinical. On the Cloudflare website click Workers at the top and then Manage Workers, then Create a worker.
There's a website up here (https://fruition-git-fork-benfoxall-patch-1-stephenou.vercel.app/) which generates the code you need for this. It may or may not be available when you're reading this, if not I have put the current working code below.
The code you need is below, you do not need to know how it works or what it's doing. All you need to do it change the domain for MY_DOMAIN
to your domain and put your Notion pages ID into where it says '771ef...'
for example in the image I put of my Notion page above my page ID starts with 79c87fe54...
you need to keep the single quotes (these things - ').
/* CONFIGURATION STARTS HERE */
/* Step 1: enter your domain name like fruitionsite.com */
const MY_DOMAIN = "fruitionsite.com"
/*
* Step 2: enter your URL slug to page ID mapping
* The key on the left is the slug (without the slash)
* The value on the right is the Notion page ID
*/
const SLUG_TO_PAGE = {
"": "771ef38657244c27b9389734a9cbff44",
}
/* Step 3: enter your page title and description for SEO purposes */
const PAGE_TITLE = ""
const PAGE_DESCRIPTION = ""
/* Step 4: enter a Google Font name, you can choose from https://fonts.google.com */
const GOOGLE_FONT = ""
/* Step 5: enter any custom scripts you'd like */
const CUSTOM_SCRIPT = ``
/* CONFIGURATION ENDS HERE */
const PAGE_TO_SLUG = {}
const slugs = []
const pages = []
Object.keys(SLUG_TO_PAGE).forEach(slug => {
const page = SLUG_TO_PAGE[slug]
slugs.push(slug)
pages.push(page)
PAGE_TO_SLUG[page] = slug
})
addEventListener("fetch", event => {
event.respondWith(fetchAndApply(event.request))
})
function generateSitemap() {
let sitemap = '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
slugs.forEach(
slug =>
(sitemap +=
"<url><loc>https://" + MY_DOMAIN + "/" + slug + "</loc></url>")
)
sitemap += "</urlset>"
return sitemap
}
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
}
function handleOptions(request) {
if (
request.headers.get("Origin") !== null &&
request.headers.get("Access-Control-Request-Method") !== null &&
request.headers.get("Access-Control-Request-Headers") !== null
) {
// Handle CORS pre-flight request.
return new Response(null, {
headers: corsHeaders,
})
} else {
// Handle standard OPTIONS request.
return new Response(null, {
headers: {
Allow: "GET, HEAD, POST, PUT, OPTIONS",
},
})
}
}
async function fetchAndApply(request) {
if (request.method === "OPTIONS") {
return handleOptions(request)
}
let url = new URL(request.url)
url.hostname = "www.notion.so"
if (url.pathname === "/robots.txt") {
return new Response("Sitemap: https://" + MY_DOMAIN + "/sitemap.xml")
}
if (url.pathname === "/sitemap.xml") {
let response = new Response(generateSitemap())
response.headers.set("content-type", "application/xml")
return response
}
let response
if (url.pathname.startsWith("/app") && url.pathname.endsWith("js")) {
response = await fetch(url.toString())
let body = await response.text()
response = new Response(
body
.replace(/www.notion.so/g, MY_DOMAIN)
.replace(/notion.so/g, MY_DOMAIN),
response
)
response.headers.set("Content-Type", "application/x-javascript")
return response
} else if (url.pathname.startsWith("/api")) {
// Forward API
response = await fetch(url.toString(), {
body: url.pathname.startsWith("/api/v3/getPublicPageData")
? null
: request.body,
headers: {
"content-type": "application/json;charset=UTF-8",
"user-agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.163 Safari/537.36",
},
method: "POST",
})
response = new Response(response.body, response)
response.headers.set("Access-Control-Allow-Origin", "*")
return response
} else if (slugs.indexOf(url.pathname.slice(1)) > -1) {
const pageId = SLUG_TO_PAGE[url.pathname.slice(1)]
return Response.redirect("https://" + MY_DOMAIN + "/" + pageId, 301)
} else {
response = await fetch(url.toString(), {
body: request.body,
headers: request.headers,
method: request.method,
})
response = new Response(response.body, response)
response.headers.delete("Content-Security-Policy")
response.headers.delete("X-Content-Security-Policy")
}
return appendJavascript(response, SLUG_TO_PAGE)
}
class MetaRewriter {
element(element) {
if (PAGE_TITLE !== "") {
if (
element.getAttribute("property") === "og:title" ||
element.getAttribute("name") === "twitter:title"
) {
element.setAttribute("content", PAGE_TITLE)
}
if (element.tagName === "title") {
element.setInnerContent(PAGE_TITLE)
}
}
if (PAGE_DESCRIPTION !== "") {
if (
element.getAttribute("name") === "description" ||
element.getAttribute("property") === "og:description" ||
element.getAttribute("name") === "twitter:description"
) {
element.setAttribute("content", PAGE_DESCRIPTION)
}
}
if (
element.getAttribute("property") === "og:url" ||
element.getAttribute("name") === "twitter:url"
) {
element.setAttribute("content", MY_DOMAIN)
}
if (element.getAttribute("name") === "apple-itunes-app") {
element.remove()
}
}
}
class HeadRewriter {
element(element) {
if (GOOGLE_FONT !== "") {
element.append(
`<link href="https://fonts.googleapis.com/css?family=${GOOGLE_FONT.replace(
" ",
"+"
)}:Regular,Bold,Italic&display=swap" rel="stylesheet">
<style>* { font-family: "${GOOGLE_FONT}" !important; }</style>`,
{
html: true,
}
)
}
element.append(
`<style>
div.notion-topbar > div > div:nth-child(3) { display: none !important; }
div.notion-topbar > div > div:nth-child(4) { display: none !important; }
div.notion-topbar > div > div:nth-child(5) { display: none !important; }
div.notion-topbar > div > div:nth-child(6) { display: none !important; }
div.notion-topbar-mobile > div:nth-child(3) { display: none !important; }
div.notion-topbar-mobile > div:nth-child(4) { display: none !important; }
div.notion-topbar > div > div:nth-child(1n).toggle-mode { display: block !important; }
div.notion-topbar-mobile > div:nth-child(1n).toggle-mode { display: block !important; }
</style>`,
{
html: true,
}
)
}
}
class BodyRewriter {
constructor(SLUG_TO_PAGE) {
this.SLUG_TO_PAGE = SLUG_TO_PAGE
}
element(element) {
element.append(
`<div style="display:none">Powered by <a href="http://fruitionsite.com">Fruition</a></div>
<script>
window.CONFIG.domainBaseUrl = location.origin;
const SLUG_TO_PAGE = ${JSON.stringify(this.SLUG_TO_PAGE)};
const PAGE_TO_SLUG = {};
const slugs = [];
const pages = [];
const el = document.createElement('div');
let redirected = false;
Object.keys(SLUG_TO_PAGE).forEach(slug => {
const page = SLUG_TO_PAGE[slug];
slugs.push(slug);
pages.push(page);
PAGE_TO_SLUG[page] = slug;
});
function getPage() {
return location.pathname.slice(-32);
}
function getSlug() {
return location.pathname.slice(1);
}
function updateSlug() {
const slug = PAGE_TO_SLUG[getPage()];
if (slug != null) {
history.replaceState(history.state, '', '/' + slug);
}
}
function onDark() {
el.innerHTML = '<div title="Change to Light Mode" style="margin-left: auto; margin-right: 14px; min-width: 0px;"><div role="button" tabindex="0" style="user-select: none; transition: background 120ms ease-in 0s; cursor: pointer; border-radius: 44px;"><div style="display: flex; flex-shrink: 0; height: 14px; width: 26px; border-radius: 44px; padding: 2px; box-sizing: content-box; background: rgb(46, 170, 220); transition: background 200ms ease 0s, box-shadow 200ms ease 0s;"><div style="width: 14px; height: 14px; border-radius: 44px; background: white; transition: transform 200ms ease-out 0s, background 200ms ease-out 0s; transform: translateX(12px) translateY(0px);"></div></div></div></div>';
document.body.classList.add('dark');
__console.environment.ThemeStore.setState({ mode: 'dark' });
};
function onLight() {
el.innerHTML = '<div title="Change to Dark Mode" style="margin-left: auto; margin-right: 14px; min-width: 0px;"><div role="button" tabindex="0" style="user-select: none; transition: background 120ms ease-in 0s; cursor: pointer; border-radius: 44px;"><div style="display: flex; flex-shrink: 0; height: 14px; width: 26px; border-radius: 44px; padding: 2px; box-sizing: content-box; background: rgba(135, 131, 120, 0.3); transition: background 200ms ease 0s, box-shadow 200ms ease 0s;"><div style="width: 14px; height: 14px; border-radius: 44px; background: white; transition: transform 200ms ease-out 0s, background 200ms ease-out 0s; transform: translateX(0px) translateY(0px);"></div></div></div></div>';
document.body.classList.remove('dark');
__console.environment.ThemeStore.setState({ mode: 'light' });
}
function toggle() {
if (document.body.classList.contains('dark')) {
onLight();
} else {
onDark();
}
}
function addDarkModeButton(device) {
const nav = device === 'web' ? document.querySelector('.notion-topbar').firstChild : document.querySelector('.notion-topbar-mobile');
el.className = 'toggle-mode';
el.addEventListener('click', toggle);
nav.appendChild(el);
onLight();
}
const observer = new MutationObserver(function() {
if (redirected) return;
const nav = document.querySelector('.notion-topbar');
const mobileNav = document.querySelector('.notion-topbar-mobile');
if (nav && nav.firstChild && nav.firstChild.firstChild
|| mobileNav && mobileNav.firstChild) {
redirected = true;
updateSlug();
addDarkModeButton(nav ? 'web' : 'mobile');
const onpopstate = window.onpopstate;
window.onpopstate = function() {
if (slugs.includes(getSlug())) {
const page = SLUG_TO_PAGE[getSlug()];
if (page) {
history.replaceState(history.state, 'bypass', '/' + page);
}
}
onpopstate.apply(this, [].slice.call(arguments));
updateSlug();
};
}
});
observer.observe(document.querySelector('#notion-app'), {
childList: true,
subtree: true,
});
const replaceState = window.history.replaceState;
window.history.replaceState = function(state) {
if (arguments[1] !== 'bypass' && slugs.includes(getSlug())) return;
return replaceState.apply(window.history, arguments);
};
const pushState = window.history.pushState;
window.history.pushState = function(state) {
const dest = new URL(location.protocol + location.host + arguments[2]);
const id = dest.pathname.slice(-32);
if (pages.includes(id)) {
arguments[2] = '/' + PAGE_TO_SLUG[id];
}
return pushState.apply(window.history, arguments);
};
const open = window.XMLHttpRequest.prototype.open;
window.XMLHttpRequest.prototype.open = function() {
arguments[1] = arguments[1].replace('${MY_DOMAIN}', 'www.notion.so');
return open.apply(this, [].slice.call(arguments));
};
</script>${CUSTOM_SCRIPT}`,
{
html: true,
}
)
}
}
async function appendJavascript(res, SLUG_TO_PAGE) {
return new HTMLRewriter()
.on("title", new MetaRewriter())
.on("meta", new MetaRewriter())
.on("head", new HeadRewriter())
.on("body", new BodyRewriter(SLUG_TO_PAGE))
.transform(res)
}
After you Copy + Paste the code, click on Save and Deploy.
Setup the route for the worker
We now need to setup the route for the worker we just created. This will tell the worker which URLs it should work on. This time click on Add route and enter your domain like so: DOMAIN.com/*
so if my domain was hello.xyz mine would be hello.xyz/*
.
After you do this, click save and let Cloudflare do it's magic!
If everything worked you should now be able to go to your domain such as hello.xyz and it'll redirect to your Notion page!
For your information Notion is continually updated and new features are released frequently so this may not work in the future or tomorrow, but does as of the 19th October 2021.
Also, this project has a Github repository called "Fruition" here where there's a community of people helping each other debug their issues and get this solution working.
Posted on October 19, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.