DEV Community

Cover image for 🌐 Using the Notion page as a personal website with your domain on Cloudflare
Vic Shóstak
Vic Shóstak

Posted on • Updated on

🌐 Using the Notion page as a personal website with your domain on Cloudflare

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?

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.

↑ Table of contents

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.

↑ Table of contents

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.

↑ Table of contents

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.

↑ Table of contents

Cloudflare Worker IDE

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);
}
Enter fullscreen mode Exit fullscreen mode
  • 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

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

Photos and videos by

P.S.

If you want more articles (like this) on this blog, then post a comment below and subscribe to me. Thanks! 😻

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 projects that need your help (and stars) 👇

  • 🔥 gowebly: A next-generation CLI tool for easily build amazing web applications with Go on the backend, using htmx & hyperscript and the most popular atomic/utility-first CSS frameworks on the frontend.
  • create-go-app: Create a new production-ready project with Go backend, frontend and deploy automation by running one CLI command.
  • 🏃 yatr: Yet Another Task Runner allows you to organize and automate your routine operations that you normally do in Makefile (or else) for each project.
  • 📚 gosl: The Go Snippet Library provides snippets collection for working with routine operations in your Go programs with a super user-friendly API and the most efficient performance.
  • 🏄‍♂️ csv2api: The parser reads the CSV file with the raw data, filters the records, identifies fields to be changed, and sends a request to update the data to the specified endpoint of your REST API.
  • 🚴 json2csv: The parser can read given folder with JSON files, filtering and qualifying input data with intent & stop words dictionaries and save results to CSV files by given chunk size.

Top comments (23)

Collapse
 
mysterydigital profile image
MysteryDigital

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?

Collapse
 
koddr profile image
Vic Shóstak

Hi,

Did you try to follow this instruction on Reddit?

You need to go in to Cloudflare workers and edit your script. Put your domain on line 4 and enter as "sitedomain.com"

Replacing sitedomain with your own domain. Click save and deploy.

Then go back and edit route. Change to ".sitedomain.com/" save it.

Then make sure your SSL is set to Flexible.

Collapse
 
krishkanabar profile image
Krishna kanabar • Edited

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
Image description

when I open wew.perpetualbeta.io/fc3e5efb96dc4... It shows me this site can't be reached

Collapse
 
ianpalomaress profile image
ian?

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.

Collapse
 
koddr profile image
Vic Shóstak

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).

Collapse
 
jair profile image
jair

excellent guide, when entering your website you have a common error these days called: dev

Collapse
 
koddr profile image
Vic Shóstak • Edited

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.

Collapse
 
jair profile image
jair

Yes that's right, thank you (Vic) very much for the information.

Collapse
 
connorforsyth profile image
Connor Forsyth

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:

Image description

The error reads:

Hello, we've noticed an issue with your iOS app. Please delete this app and re-install it from the App Store.

Any ideas?

Collapse
 
connorforsyth profile image
Connor Forsyth

Just in case anyone is poking around for the answer, here is the solution... add the following to your code:

github.com/stephenou/fruitionsite/...

Collapse
 
persistventure profile image
Persist Venture

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.

Collapse
 
drmzio profile image
Danny ✪

Doesn't work anymore 🙁 Shows a message "Continue to external site by following the link below" when I go to the custom domain.

Collapse
 
koddr profile image
Vic Shóstak

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.

Collapse
 
motorleague profile image
motorleague • Edited

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.

  1. I've been able to enable Flexible SSL/TLS from the SSL/TLS section.
  2. In the Page Rules tab, I can set up Always Use HTTPS OR Auto Minify (but not both - the use of HTTPS prevents aplying any other rules), and Brotli seems to be missing completely).
    1. On the Workers tab I can create a Route to the subdomain I'm planning to redirect first (I'm not sure if I would be setting up an A or CNAME Record first before I do this - maybe I should be setting up a URI record?), but it says "Workers are disabled on this route" and I can't see where I'm supposed to add the script?

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.

Collapse
 
motorleague profile image
motorleague

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.

Collapse
 
truongoi profile image
Truong Nguyen

how do you put in A-record for DNS Management page in Cloudflare?

Collapse
 
koddr profile image
Vic Shóstak

Hi,

As always, go to DNS page and click button "Add record":

dns

Collapse
 
akshaybhasin profile image
Akshay Bhasin

Worked like a charm! Thanks, you saved me a lot of time!

Collapse
 
koddr profile image
Vic Shóstak

You're welcome 🤗

Collapse
 
huksley profile image
Ruslan Gainutdinov • Edited

Here is a fix for "Continue to external site" github.com/stephenou/fruitionsite/...

Collapse
 
rutik_k_jadhav profile image
Rutik K. Jadhav

hey!

i tried, its sort of working. but it is showing my notion page as an external website. what do i do?

it says: Continue to external site by following the link below

Collapse
 
koddr profile image
Vic Shóstak

Hi,

Can you tell me more about what you're getting and what you expect to get?

Collapse
 
pavelkatz profile image
Pavel Katz

Hi, is this solution working? For some reason I did everything as written, but my login window opens in Notion. Not the landing page. Do you know what could be the problem?