At Reboot we are huge fans of Notion and we use it internally for many things as we have already showed in other articles. Today we are going to explain how we have created a website with Notion for our Playbook using a custom domain, multi-language content and, the best of all, completely free.

Why Notion

When creating our Playbook we wanted to be able to update the content quickly and easily since it is not written in stone and it evolves as the company does. We also needed something that did not take us too long to develop in order to focus our efforts on other parts of the webpage.

Notion is perfect for this since it allows us to create public pages where we can update content from an environment that we like and access on a daily basis. In fact, if this is the only thing you need, you can just share publicly your Notion directory and you will be done.

But since we didn't like having the Notion domain with unfriendly urls, we decided to go one step further and enable access through a custom domain. This requires a couple of extra steps, but in this post we will show you how to do it simply.

Used tools

In order to create our website in Notion and access from our own domain we need the following tools:

  1. Have a domain purchased with whatever provider you prefer.
  2. A free account at Cloudflare.
  3. A free account at Notion.

Once we have the accounts created we can start configuring them.

Notion configuration

The first thing we have to do is creating a public Notion page. To do this we just have to click on the Share option and enable the "Share to web" option. This will give us a url that, if we have a PRO account, we can also enable the search engine indexing option.

From here on, the design and content you create on the page is up to you. However, a small recommendation is to use the table structure for content organization since it scales much better on screens of different sizes.

Sharing publicly a Notion page

In our particular case we not only have the Playbook in Spanish but also in English, so we have a duplicated version on a separate public page. Both are within a page called Public simply to be clear about what content is publicly accessible within our workspace.

Our public directory on Notion

Cloudflare configuration

Once we have our page in Notion, it is time to configure the Cloudflare account since this is where the magic happens. In this article we are not going to cover how to create a Cloudflare account and link it to our domain provider, but in the Fruitionsite guide they explain it in a simple way through screenshots.

If you prefer, you also have it on video starting at minute 3:36 https://www.youtube.com/watch?v=aw0x54PzCaI.

When we have our Cloudflare account we need a worker to point our domain to the url generated by Notion, so we can modify HTTP requests and responses by using JavaScript.

To create the worker we have to access the Workers menu accessible from the top icon bar in our account. Inside the Workers page we will click on "Manage Workers".

Workers page on Cloudflare

To create the new worker, click on "Create a Worker".

Create a new worker on Cloudflare

Now that we have our worker created we have to replace the default Javascript code with our own one, which is the one that will allow us to manage the display logic of our Notion page.

/* CONFIGURATION STARTS HERE */

  /* Step 1: enter your domain name*/
  const MY_DOMAIN = 'example.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 = {
    '': 'xxxxxxxxxxxxxxxxxxxxxxxx',
    'es': 'xxxxxxxxxxxxxxxxxxxxxx',
  };
  
  /* Step 3: enter your page title and description for SEO purposes */
  const PAGE_TITLE = 'Playbook';
  const PAGE_DESCRIPTION = 'How we work';
  
  /* Step 4: enter a Google Font name, you can choose from <https://fonts.google.com> */
  const GOOGLE_FONT = 'Inter';
  
  /* 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 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(`<div style="display:none">Powered by <a href="<http://fruitionsite.com>">Fruition</a></div>
      <script>
      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: 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 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);
  }

We simply have to copy and paste the upper script and modify it with our data and preferences.

Worker's script configuration

There are 5 main modifications that we can apply to the script to adapt it to what we need, these are quoted in the code as "Step X", but we are going to see them one by one.

  1. Modify the domain name that we refer in the constant MY_DOMAIN. In our case we use the subdomain playbook.reboot.studio since the main domain points to our website.
  2. We add the slugs of the Notion pages that we want to display in each route. In our case, in the root path we have mapped the slug of the Notion page with the Playbook in English and in the path /es we find the Playbook in Spanish. Depending on what you prefer, you can map more or fewer pages, in our case we only map the first level navigation and the rest use the default's Notion slug.
  3. We add the title and description that we want for each page, these will be added to the meta tags so that they appear in the SERPS when our page is indexed.
  4. Add the name of a font available in Google Fonts if we want to replace Notion's default font. This step is optional but highly recommended if we want to achieve a more custom touch.
  5. Finally we can add custom scripts to our page to add additional tools such as Google Tag Manager or Intercom.

You can modify the script according to the needs you have. In fact, the script that we use in our webpage is exactly the one that we have added in the article.

Finally, in order to have our worker active we have to link it to a route, we can do this from the main Workers screen by clicking on "Add Route" where the following menu will appear.

Add route to worker

Here we have to select the worker that we just created, and write the path where we want it to run. In most cases the path is the same as the one we have included in the MY_DOMAIN const but with an asterisk at the end such that playbook.reboot.studio/* to indicate that it is executed on all paths that domain contains and not only in the root path.

Add route to worker

Once we save we can access our website.

Our public Playbook on Notion

Conclusions

As we can see, thanks to Cloudflare and Notion we can create websites easily and quickly. We love this stack for pages such as FAQs, documentation or directories but with imagination and creativity we are sure that many other types of pages can be created.

If we want to skip the configuration of Clouflare, there are different services such as Super.so or hostnotion.co that allow us to do so out-of-the-box. In the case of Super, it adds some extra customization features that may be interesting.