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 shostak.dev 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 support me by donating at LiberaPay. Each donation will be used to write new articles and develop non-profit open-source projects for the community.

Support author at LiberaPay

Discussion (18)

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 Author

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
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 Author

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
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 Author • Edited on

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
akshaybhasin profile image
Akshay Bhasin

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

Collapse
koddr profile image
Vic Shóstak Author

You're welcome 🤗

Collapse
motorleague profile image
motorleague • Edited on

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 Author

Hi,

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

dns

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 Author

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
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 Author

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?