Introduction
Hi, DEV people! 🙂 Today, I give you a handy step-by-step guide to help you set up a Notion page on your domain with free serve by Cloudflare reverse-proxy.
A nice bonus out of the box is protection against DDoS attacks, secure access to resources, automatically renew a SSL cert by Cloudflare for your domain and DNS servers.
⚠️ This method was published on this webite. In this article I want to give you a more compact instructions and a ready to copy-paste script worker with some CSS edits.
📝 Table of contents
- What needs to be prepared?
- Create a script for Cloudflare Worker
- An example of how it worked out for me
What needs to be prepared?
The whole process will not take more than 10 minutes if you follow the instructions below. But be careful, some settings will affect your domain.
Public Notion page
- Create a new Notion page.
- Make the design to your liking.
- Click the Share button (in the upper right corner).
- Copy the ID of your public page.
🤔 How do I know the ID of a public page in Notion? The ID is a set of letters and numbers after the domain
notion.so/<PAGE ID HERE>
.
You can create multiple pages and link them however you want.
On the side of your domain registrar
- Buy a new domain or choose existing.
- Go to NS records settings of chosen domain.
- Don't close this page and open
cloudflare.com
in another browser tab. We'll be back here again as we configure Cloudflare.
On the Cloudflare side
- Login or create a new Cloudflare account.
- Click Add a Site button and enter your chosen domain name.
☝️ Attention! If you would like to use a subdomain, you should still enter your root domain name here. Setting up subdomains will be described later.
- Select the Free plan.
- Click Continue on the DNS Record page.
- Copy two NS (nameservers), which end with
.ns.cloudflare.com
. - Paste copied NS to the domain records settings page (at your registrar).
- Return to the Cloudflare page.
- Wait for a minute, then click Done.
- Select Flexible SSL/TLS encryption mode.
- Turn on Always Use HTTPS, Auto Minify, and Brotli.
- Click Done.
- Go to the Workers page and then click Manage Workers.
- Choose any available subdomain for your worker.
- Click Set Up and then click to Confirm.
- Choose the Free plan.
Create a script for Cloudflare Worker
- Click Create a Worker.
- Paste this code to the Worker IDE.
👇 Don't forget to fill the config variables with your data!
/* CONFIGURATION STARTS HERE */
/*
* Step 1: enter your domain name
*/
const MY_DOMAIN = "your-domain.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 = {
"": "<NOTION PAGE ID>", // main page
"about": "<NOTION PAGE ID>", // page with about info
"blog": "<NOTION PAGE ID>", // your blog page
"contact": "<NOTION PAGE ID>", // feedback form
// ...
};
/*
* Step 3: enter your page title and description
* for SEO purposes
*/
const PAGE_TITLE = "My Page Title";
const PAGE_DESCRIPTION = "Page desctiption";
/*
* Step 4: enter a Google Font name, you can choose from
* https://fonts.google.com
*/
const GOOGLE_FONT = "Roboto";
/*
* 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";
// Create robots.txt file for SEO.
if (url.pathname === "/robots.txt") {
return new Response("Sitemap: https://" + MY_DOMAIN + "/sitemap.xml");
}
// Create sitemap.xml file for SEO.
if (url.pathname === "/sitemap.xml") {
let response = new Response(generateSitemap());
response.headers.set("content-type", "application/xml");
return response;
}
let fullPathname = request.url.replace("https://" + MY_DOMAIN, "");
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: 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(`<script>
window.CONFIG.domainBaseUrl = "https://${MY_DOMAIN}";
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 style="margin-left: 10px; 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 style="margin-left: 10px; 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);
}
```
- Click **Save and Deploy** button.
- Go to the **Workers** page and select **Add Route**.
- Place `your-domain.com/*` (or `subdomain.your-domain.com/*`, if you would like to use a subdomain) as the **Route** and select the **Worker** you just created.
> 👌 Please note, if you would like to enable other all subdomains like `www`, place `*your-domain.com/*`.
- Click to **Save** button.
You can now visit your website! 🎉
[↑ Table of contents](#toc)
<a name="#an-example-of-how-it-worked-out-for-me"></a>
## An example of how it worked out for me
So, I moved my personal resume website and now editing or adding information happens at the speed of light.
Here is a short video that demonstrates this:
> 😉 As a reminder, this is a standard Notion page in a standard layout, but slightly improved through the Cloudflare Worker script.
[↑ Table of contents](#toc)
<a name=""></a>
## Photos and videos by
- Vic Shóstak https://github.com/koddr
<a name=""></a>
## P.S.
If you want more articles (like this) on this blog, then post a comment below and subscribe to me. Thanks! 😻
> ❗️ You can support me on [Boosty][boosty_url], both on a _permanent_ and on a _one-time_ basis. All proceeds from this way will go to **support** my OSS projects and will energize me to **create** new products and articles for the community.
>
> <a href="https://boosty.to/koddr/donate" target="_blank"><img width="356" alt="support me on Boosty" src="https://raw.githubusercontent.com/koddr/.github/main/images/boosty-badge.svg"/></a>
And of course, you can help me make developers' lives even better! Just connect to one of my projects as a contributor. It's easy!
My main projects that need your help (and stars) 👇
- 🔥 [gowebly][gowebly_url]: A next-generation CLI tool that makes it easy to create amazing web applications with Go on the backend, using htmx, hyperscript or Alpine.js and the most popular CSS frameworks on the frontend.
- ✨ [create-go-app][cgapp_url]: Create a new production-ready project with Go backend, frontend and deploy automation by running one CLI command.
Other my small projects: [yatr][yatr_url], [gosl][gosl_url], [json2csv][json2csv_url], [csv2api][csv2api_url].
<!-- Footer links -->
Top comments (23)
hiya, I created an account specifically to record an issue recently popping up which is the infamous "Mismatch between origin and baseUrl (dev)."
I tried following the instructions described when I googled the error here and the problem is persisting.
Could you provide any additional insight?
Hi,
Did you try to follow this instruction on Reddit?
I am getting the same error
My notion page link is: notion.so/test-fc3e5efb96dc46d182d...
Inside worker script:
const MY_DOMAIN = "wew.perpetualbeta.io";
const SLUG_TO_PAGE = {
"": "test-fc3e5efb96dc46d182d4dd7cc9028ec1", // main page
"about": "test-fc3e5efb96dc46d182d4dd7cc9028ec1", // page with about info
"blog": "test-fc3e5efb96dc46d182d4dd7cc9028ec1", // your blog page
"contact": "test-fc3e5efb96dc46d182d4dd7cc9028ec1", // feedback form
};
and so on...(all other code is same)
Inside Triggers/Add route
.perpetualbeta.io/
It's showing me error
when I open wew.perpetualbeta.io/fc3e5efb96dc4... It shows me this site can't be reached
Hi Great content! Can you help me with something I'm trying hard to do? Do you know how can I style only the landing page? for example, I was trying to style the notion-page-content of the landing page, I turned the background into an image, but all the other pages inside the site was also affected.
I would be happy with your help.
Hi there!
Unfortunately, Notion doesn't allow for that much customization of its default pages. It can be done with additional JS code, as far as I know, but it will increase the time of opening your site and will not always look adequate before rendering.
It's better to use other tools for UI customization, and use Notion "as is" (IMHO).
excellent guide, when entering your website you have a common error these days called: dev
Thanks for this reply! This happened because some changes applied and now you have to write the base URL of your site in the
BodyRewriter
class (as described here).I added fix for this error to my article.
Yes that's right, thank you (Vic) very much for the information.
Hey there,
I've created a starter website with this, but it seems to be having issues when opening on a mobile browser, I've noticed a few other sites using the same method who are experiencing similar issues.
Here's what I see when opening on mobile:
The error reads:
Any ideas?
Just in case anyone is poking around for the answer, here is the solution... add the following to your code:
github.com/stephenou/fruitionsite/...
I have domain and hosting plan on namecheap.
I want to host main domain files on namecheap and want to subdomain on cloudflrare. Is it possible?
like,
maindomain.com - hosted on namecheap
sub.maindomain.com - on cloudflare.
Thanks.
Doesn't work anymore 🙁 Shows a message "Continue to external site by following the link below" when I go to the custom domain.
Hi,
Describe in more detail what you are doing. I checked the whole process described in my article and it works the same as it did before your post.
That is, the information in the article is up to date at the moment.
Hey, thanks very much for writing this guide.
I'm not sure if maybe Cloudflare have changed their user interface recently, but I'm really struggling to find where some of these settings live. I added a domain a couple of weeks ago, and have now got my Name Server repointed.
Apologies for the stream of questions, have I missed something or have Cloudflare changed things around since this was written?
Edit: I've since been able to switch on both Always Use HTTPS AND Auto Minify, for the domain as a whole at least, from the Recommendation setting on domain Overview page.
Edit 2: Apologies, I misunderstood more than I thought - I thought the worked process had to be created against a sub-domain on my own domain, rather than just any random domain on **.workers.dev, so I chose a random one and was able to proceed further to adding the script.
I've added the worker process and I think assigned it to my own sub-domain - I'm not sure if it's redirecting correctly yet, but I may need to wait for DNS to propagate a bit more before I can fully troubleshoot it further as Cloudflare's NS don't seem to be responding for any DNS queries against it..
Please disregard the above and thanks once more for the guide.
how do you put in A-record for DNS Management page in Cloudflare?
Hi,
As always, go to DNS page and click button "Add record":
Worked like a charm! Thanks, you saved me a lot of time!
You're welcome 🤗
Here is a fix for "Continue to external site" github.com/stephenou/fruitionsite/...