En Reboot somos muy fans de Notion y lo usamos para realizar múltiples funciones como ya hemos comentado en otros artículos. Hoy vamos a explicar cómo hemos creado una web con Notion para nuestro Playbook usando un dominio personalizado y el contenido disponible en diferentes idiomas de forma completamente gratuita.

Por qué Notion

A la hora de crear el Playbook queríamos poder actualizar el contenido de forma rápida y sencilla ya que este evoluciona a la vez que lo hace la empresa. Además necesitábamos algo que no nos llevara demasiado tiempo de desarrollo para centrar los esfuerzos en la landing de la empresa.

Notion es perfecto para esto ya que nos permite crear páginas públicas donde actualizar el contenido desde un entorno que nos gusta y al cual accedemos a diario. De hecho, si lo que necesitáis es justamente esto, compartiendo de forma pública vuestro directorio de Notion ya estaríais.

Pero como no nos gustaba tener el dominio de Notion con urls poco amigables decidimos ir un paso más allá y habilitar el acceso mediante un dominio personalizado. Para ello hacen falta un par de cosas más, pero en este post os enseñaremos a hacerlo de forma simple.

Herramientas utilizadas

Para poder crear nuestra web en Notion y acceder desde un dominio propio necesitamos las siguientes herramientas:

  1. Tener un dominio comprado con el provedor que más os guste.
  2. Una cuenta gratuita en Cloudflare.
  3. Una cuenta gratuita en Notion.

Una vez tenemos las cuentas creadas podemos empezar a configurarlas.

Configuración de Notion

Lo primero que tenemos que hacer es crear una página de Notion pública. Para ello solo tenemos que pulsar en la opción de Share y habilitar la opción de "Share to web". Esto nos dará una url que, si disponemos de una cuenta PRO, también podremos habilitar la opción de indexación en buscadores.

A partir de aquí el diseño y el contenido que crees en la página ya es cosa tuya. Eso sí, una pequeña recomendación es la de utilizar la estructura de tabla para la organización de contenidos ya que escala mucho mejor en pantallas con diferentes resoluciones que otras estructuras de datos más libres.

Compartir página de Notion de forma pública

En nuestro caso particular no solo tenemos el Playbook en español sino que también está en inglés, para ello tenemos una versión duplicada en otra página pública separada. Ambas están dentro de una página llamada Public simplemente para tener claro qué contenidos son de acceso público dentro de nuestro proyecto.

Nuestro directorio público en Notion

Configuración de Cloudflare

Una vez tenemos nuestra página en Notion es el momento de configurar la cuenta de Cloudflare ya que aquí es donde ocurre la magia. En este artículo no vamos a ver cómo crear una cuenta de Cloudflare y enlazarla a nuestro proveedor de dominios, pero en la guía de Fruitionsite lo explican de forma sencilla a través de capturas. Si lo preferís también lo tenéis en vídeo a partir del minuto 3:36.

Cuando tenemos nuestra cuenta de Cloudflare necesitamos un worker para poder apuntar nuestro dominio a la url generada por Notion, así podremos modificar las solicitudes y respuestas HTTP mediante el uso de scripts de JavaScript.

Para crear el worker tenemos que acceder al menú Workers accesible desde la barra superior de iconos en nuestra cuenta. Dentro de la página de Workers pulsaremos en "Manage Workers".

Página de workers en Cloudflare

Para crear el nuevo worker pulsamos en "Create a Worker".

Crear un nuevo worker en Cloudflare

Ahora que tenemos nuestro worker creado tenemos que remplazar el código Javascript que nos dan de serie por uno propio que es el que nos permitirá gestionar la lógica de visualización de  nuestra página de Notion.

/* 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);
  }

Simplemente tenemos que copiar y pegar el script superior y modificarlo con nuestros datos y preferencias.

Configuración del script del worker

Hay 5 modificaciones principales que podemos realizar al script para adaptarlo a lo que necesitemos, estas están comentadas en el código como "Step X" pero vamos a verlas de una en una.

  1. Modificar el nombre del dominio al que apuntamos en la constante MY_DOMAIN. En nuestro caso utilizamos el subdominio playbook.reboot.studio ya que el dominio principal apunta a nuestro sitio web.
  2. Añadimos los slugs de la página o páginas de Notion que queremos que se muestren en cada ruta. En nuestro caso en la ruta raíz hemos mapeado el slug de la página de Notion con el Playbook en inglés y en la ruta /es la página de notion con el Playbook en español. En función de lo que prefiramos podemos mapear más o menos páginas, en nuestro caso solo mapeamos las "home" las interiores dejamos que utilicen el slug que da notion de por sí.
  3. Añadimos el título y la descripción que queramos a cada página, estos se añadirán a los metas para que aparezcan en las SERPS cuando nuestra página esté indexada.
  4. Añadimos el nombre de una fuente disponible en Google Fonts si queremos reemplazar la fuente por defecto de Notion. Este paso es opcional pero está bien para darla un toque más personalizado.
  5. Finalmente podemos añadir scripts personalizados a nuestra página para añadir herramientas adicionales tipo Google Tag Manager o Intercom.

Podéis modificar el script acorde a las necesidades que tengáis. De hecho, el script que utilizamos en nuestro caso es exactamente el que hemos añadido en el artículo.

Finalmente para poder tener nuestro worker activo lo tenemos que enlazar a una ruta, esto lo podemos hacer desde la pantalla principal de Workers pulsando en "Add Route" donde nos aparecerá el siguiente menú.

Añadir ruta al worker

Aquí tenemos que seleccionar el worker que acabamos de crear, y escribir la ruta donde queremos que se ejecute. En la mayoría de los casos la ruta es la misma que hemos indicado en la const MY_DOMAIN pero con un asterisco al final tal que así playbook.reboot.studio/* para indicar que se ejecute en todas las rutas que contenga ese dominio y no solo en la ruta raíz.

Añadir ruta al worker

Una vez guardemos ya podremos acceder a nuestro sitio web.

Nuestro Playbook público en Notion

Conclusiones

Como podemos ver gracias a Cloudflare y Notion podemos crear webs de forma sencilla y rápida. A nosotros nos encanta este stack para páginas estilo FAQs, documentación o directorios pero con imaginación y creatividad estamos seguros de que se pueden crear muchos otros tipos de páginas.

Cabe destacar que si queremos ahorrarnos la configuración de Clouflare hay servicios de pago mensual como Super.so o hostnotion.co que nos permiten hacerlo plug and play y, en el caso de Super, añade algunas funcionalidades extra de personalización que puede ser interesantes.