Building with

This tutorial will show you how to start a project.


  1. You have gone through the Getting started tutorial and successfully created a project with a subdomain.
  2. You have basic web developer knowledge.
  3. You have a GitHub account, and understand Git basics.
  4. You have Node and npm installed for local development.
  5. You have a Cloudflare account.

This tutorial is designed for use with macOS, Chrome, and IntelliJ as the development environment. The screenshots and instructions provided are based on this setup. If you choose to use a different operating system, web browser, or code editor, please be aware that the user interface and required steps may differ accordingly.

Getting started#

The fastest way to get started is to use the GitHub demo repository as a template which shows how landing pages and blog-like pages can be created and rendered with the service.


Next,  install the doc2 Command Line Interface (CLI) with npm i -g @doc2/cli and clone your repo locally. Then run the development server within the root of the project with doc2 live --dev.

This starts the server at http://localhost:3000. Finally, open the existing demo example in your browser at http://localhost:3000. The server can be stopped with cmd+c or ctrl+c. Currently, the content is coming from the demo subdomain project.

Repository configuration#

To use your own subdomain, open the repository, open the file and update the subdomain property with the subdomain of your project for development. At this point, when you run the development server, the content will be proxied from your project subdomain https://dev– which is currently empty.

Bring your own CDN#

Let’s add some content and to simplify the process, we can simply copy it from the project Google Drive folder at Copy the and all documents by keeping the folder structure from the drive folder into your own project drive folder.

Next we’ll link the theme to our GitHub Repository but we need to deploy our repository first. For that, we’ll use Cloudflare Pages to serve the static files. Go to your Cloudflare dashboard under Workers & Pages and create a new Pages application:

Connect your GitHub repository:

There’s no build setup required so you can just use the default deployment configuration and deploy your repository. It will then be available under , with PROJECT being the name of the GitHub repository by default. A new deployment is triggered anytime a commit is pushed to the GitHub repository.

Next, open your spreadsheet in your drive folder and update the preview to point to your Cloudflare Pages root location. Then click the tooling Extension to publish the sheet in the Preview workspaces and then in the Live workspace. Please note that the configuration is tied to the workspace it was published to. Finally, preview all documents.

Congratulations, you can now visit the GitHub example on your own subdomain at If you have published the content on the Live workspace as well, you can visit the Live site at

Custom preview theme#

Cloudflare Pages supports preview deployments by default.  

With Cloudflare Pages, every branch will be made available as a preview URL if configured as such. To configure your project to support custom previews, you will have to update the theme sheet. Simply rename the location of the preview to point to the Cloudflare Pages project location and prefix the location with a wildcard asterisk * for example https://*

Now that your project is correctly configured, you can make changes to the documents and preview them again to see the reflected changes. Similar to the content, you can make code changes to update the look and feel. For that let’s have a closer look at how the project is structured.


The head.html file serves as an entry point for loading common JavaScript and CSS files. It is injected as is server side within the <head> HTML tag of the page and blends with the metadata provided by the content.

In the template, it preloads because images are served from the Image API. It also loads a common JS script file and component file which will be used as the baseline of the web components.  

<link rel="preconnect" href="">

<link rel="stylesheet" href="/styles/styles.css">

<script src="/scripts/scripts.js" type="module"></script>

<script src="/scripts/component.js" type="module"></script>

<link rel="icon" type="image/png" href="/" />

Header and Footer#

By default, returns the published content wrapped inside a <main> element inside the <body>. A <header> and <footer> are also injected next to the <main> element:

<!DOCTYPE html>
<html lang="en" dir="ltr">

The header and footer source is looked up by default at the following path /fragments/header and /fragments/footer and injected in the HTML response.

A custom header and footer can be specified via the meta component with the value header or footer corresponding to the header or footer document location.

Page Template#

The main content can be wrapped inside a custom component if required which allows nesting of components. Usually this is helpful to build custom reusable layouts. To create a page Template, add a template property to the meta component in the document for example:

In this example, the blog component will wrap the main content as follows:

<!DOCTYPE html>
<html lang="en" dir="ltr">

Fragment document reference#

The header and footer fragments are the only fragments that are resolved by default in the HTML response.

Optionally, other document fragments can be added to the document to be resolved and injected in the HTML. Use the fragment component with the reference property to include document fragments in a page as the following example:

We recommend storing all document fragments in a dedicated fragments folder at the root of the project.

Please note that nested fragments are only resolved up to 1 level.

Optimized Images#

Published images are rendered using the picture element with multiple sources to support different screen sizes and image formats. This helps to provide users with the best image without impacting performance. Refer to the Media documentation to learn about the supported formats and variants.

The <picture> also renders with a custom css property --aspect-ratio corresponding to the image aspect ratio. The aspect ratio value is calculated as following: calc(image_height / image_width * 100%).

By using the --aspect-ratio property, it is possible to reserve appropriate spacing for responsive images by adding the following CSS:

picture[style*="--aspect-ratio"] {

    padding-top: var(--aspect-ratio);

    position: relative;

    display: block;


picture[style*="--aspect-ratio"] > img {

    position: absolute;

    top: 0;

    left: 0;

    width: 100%;

    height: 100%;


Images can be made accessibility friendly via the alt text and title properties which can be set by the author directly in the document. The values are reflected in the <img> element.

Images are loaded lazily by default for better loading performance.

All published images are provided by the Image API via

An example of a fully rendered image:

<picture style="--aspect-ratio:calc(500/300*100%)">

  <source type="image/avif" media="(max-width:300px)"


  <source type="image/avif" media="(max-width:600px)"


  <source type="image/avif" media="(max-width:900px)"


  <source type="image/avif" media="(max-width:1200px)"


  <source type="image/avif" media="(max-width:1400px)"


  <source type="image/avif" media=""


  <source type="image/webp" media="(max-width:300px)"


  <source type="image/webp" media="(max-width:600px)"


  <source type="image/webp" media="(max-width:900px)"


  <source type="image/webp" media="(max-width:1200px)"


  <source type="image/webp" media="(max-width:1400px)"


  <source type="image/webp" media=""


  <source type="image/png" media="(max-width:300px)"


  <source type="image/png" media="(max-width:600px)"


  <source type="image/png" media="(max-width:900px)"


  <source type="image/png" media="(max-width:1200px)"


  <source type="image/png" media="(max-width:1400px)"


  <source type="image/png" media=""


  <img alt="lorem ipsum" src="" loading="lazy" height="500" width="300"></picture>

<img> elements provided in component HTML templates  sourced from the Image API are automatically converted in <picture> elements as the example above.

In the repository, we have the file which is used to define the subdomain for development. There’s also another property in the configuration named ssr (short for Server Side Rendering), which allows us to decorate the markup of the page. The ssr accepts an array of objects to select one or multiple DOM elements from the page and update their properties for example:


  "ssr": [


      "pathname": "/",

      "select": {

        "body": {

          "class": "landing"





      "urlRegExp": "/stories/",

      "select": {

        "body": {

          "class": "story"





      "select": {

        "header img": {

          "width": 24,

          "height": 24,

          "loading": "eager"


        "header > p": {

          "tagName": "a",

          "href": ""


        "header ul li:last-child a": {

          "target": "_blank"



      "selectAll": {

        "script[src*='columns'], script[src*='stories'], script[src*='contact'], script[src*='youtube']": {

          "tagName": "template"


        "web-columns-item > h2": {

          "slot": "heading"


        "web-columns-item > p": {

          "slot": "text"


        "web-columns-item > p:has(picture)": {

          "slot": "illustration"


        "web-columns-item > p:has(a)": {

          "slot": "action"






Each object of ssr starts with a DOM selector either select or selectAll.  Read the docs about the Unified Ecosystem to learn more about hast utilities.

The selection of elements is executed in the order in which they are placed in the ssr array starting with the first entry. The object of a selector starts with the selector itself and contains the properties to be updated. Since we’re using web components,  we can select component names that are prefixed with web-. 

Selectors can optionally be limited to certain pages by using the pathname or urlRegExp properties. The latter one will test the whole page URL against the provided Regular Expression.

There are special properties that can be used to alter the element altogether:

  • tagName can be used to change the tagName of a selected element.
  • remove can be used to remove the element effectively not rendering it at all.

Try updating the body style to background:red then refresh the http://localhost:3000/github page to see the reflected changes.

The benefit of using web components is to make use of slotted elements and slots which are placeholders inside a web component that you can fill with your own markup, which lets you create separate DOM trees and present them together. Using the ssr config, we can define which elements inside web components should be slotted as shown in the example.  

Web components#

Components are written as Web Components which consist of three main technologies: custom elements, shadow DOM and HTML templates. Components on the page are automatically loaded from /components/COMPONENT_NAME/COMPONENT_NAME.js, /components/COMPONENT_NAME/COMPONENT_NAME.css and optionally /components/COMPONENT_NAME/COMPONENT_NAME.html by default. Component items are loaded from the parent component folder e.g. /components/COMPONENT_NAME/COMPONENT_NAME-item.js.

The component JS script file is loaded as a module which allows using import and export statements. Let’s have a closer look at one of the example components: the hero. The component JS file has no special behavior, it is just registering the custom element <web-hero> and extending the base component.

import Component from "../../scripts/component.js";

window.customElements.define('web-hero', class extends Component {

    constructor() {




The base component includes a temporary polyfill to support Declarative Shadow Dom (DSD) on Firefox. DSD is extensively used in the component templates even if it’s just used as an element wrapper with a default slot.

Component templates can hold any HTML which will be injected server side inside of the component before its main content which means that it’s not solely reserved for DSD but can be used for light DOM as well or a mix of DSD and slotted light DOM for more advanced use cases.

The combination of DSD slotted elements and setting slot properties via the ssr config allows for flexible and performant edge server-side streamed content rendering.

Let’s take a look at the <web-columns-item> which uses DSD with named slots.

<template shadowrootmode="open">

    <div part="aside">

        <slot name="illustration"></slot>


    <div part="main">

        <slot name="heading"></slot>

        <slot name="text"></slot>

        <slot name="action"></slot>



The hydration of web components can be executed in a surgical manner to add interactivity or custom behaviors.

Tip: component hydration can be delayed or performed only when the component enters the viewport if required. This technique can improve performance as it reduces the rendering critical rendering path.

Styling is another aspect of web components that is particular as styles can either be global or encapsulated. To style light DOM elements of the component, use  /components/COMPONENT_NAME/COMPONENT_NAME.css.  Shadow DOM elements can be styled as well in this stylesheet by using the part attribute which allows CSS to select and style specific elements in a shadow tree via the ::part pseudo-element.

Another option is to include styles as part of DSD either as inline stylesheet or as an external stylesheet to style shadow dom elements. An example of that can be found in the contact component template.

The Web Components features are performant, flexible, and native to the Web Platform making them the ideal candidates to build modern and fast Web Experiences that will last.


Sometimes, you’ll have to integrate a 3rd party API which means managing secrets to access it. This can be done within the apis sheet in the spreadsheet which will allow proxying any specified API via the reserved /_apis endpoint if it matches the configuration. The apis configuration requires setting the API host, path, method and authorization header which is redacted by default. Any extra column will be added as an additional header to the proxied request.

You can then call the 3rd party API client-side by adding the x-forwarded-api header to the request for example:

fetch('/_apis/PATH', {

  headers: {

    'x-forwarded-api': HOST




Custom routes#

It is common to serve multiple URLs from a single document, displaying dynamic content for large portions of the page without requiring authors to create each page individually. This approach streamlines content management and allows for efficient scaling of websites with dynamic content.


  • URL:
  • Dynamic Content: Fetch product details for product ID 12345 and display using a product template.

Routes can be added to the spreadsheet via the routes sheet. The routes sheet supports the columns:

  • from which accepts a pathname to route from.
  • to which accepts a pathname to a published document.

The from property supports capturing named parameters as following:

  • :name to capture from the route up to / or end of string.
  • *splat  to capture from the route up to end of string.
  • () optional group that doesn't have to be part of the query. Can contain nested optional groups, params, and splats.

Some examples:

  • /products/:id with the path /products/12345 will return the route parameter id:'12345'
  • /posts/*path with the path /posts/trips/tokyo will return the route parameter path:'trips/tokyo'
  • /dashboard/:project/(:section) with the path /dashboard/my-shop/settings will return the route parameters project:'my-shop', section:'settings'

Only the first matching route will be used and following matching routes are ignored.

An example from the pokemon component on the preview branch which is a custom route defined as /pokemons/:name pointing to another published document.

Dynamic templates#

Another component template functionality is the built-in support of APIs. It allows server-side rendering of document, sheet data or 3rd party API JSON data. Currently, the dynamic templates support Mustache, a logic-less template engine.

To use dynamic templates in a component template, the following conditions have to be met:

  • Use the <template> element.
  • Use the doc2-source attribute on the template and set the value to either a API, a proxied API which starts with /_apis/ or an internal JSON file /myfile.json. Requests will timeout after 5 seconds by default.
  • Use the doc2-template-engine attribute set to mustache.
  • Use the doc2-method attribute, if omitted the default value is GET.

The output will be automatically rendered next to the template inside the component.

Optionally, the following attributes are supported to allow for advanced use cases:

  • Use doc2-headers to submit custom headers.
  • Use doc2-body to submit data along.
  • Use doc2-query-engine attribute set to JMESPath.
  • Use the doc2-query and set the value to a JMESPath query to filter the returned data from the request.

Here’s an example from the stories component in where an index of the latest published documents is rendered. The stories are filtered by path sorted by publishedDate.





    doc2-body="engine=JMESPath&query=reverse(sort_by([?starts_with(path, '/stories')], %26publishedAt)[*].{path: path, title: meta.title, publishedAt: publishedAt})">


        <a href="{{path}}">





And the resulting HTML:

<a href="/stories/leverage-chat-gpt-and-dall-e">

  <h3>Leverage Chat GPT and Dall-E to increase your content velocity</h3>


<a href="/stories/enhancing-seo-driven-content-creation">

  <h3>The Power of AI: Enhancing SEO-Driven Content Creation</h3>


<a href="/stories/building-websites-with-google-docs-and-sheets">

  <h3>Building Websites with Google Docs and Sheets: A Powerful Collaboration Tool</h3>


Another example from the contact component in where we render the latest submission stored in a sheet:








      <strong>Latest submission by {{name}}</strong>




The doc2- attributes support the following variables:

  • Dynamic workspace with SPACE (Preview or Live ).
  • Component properties with PROPERTIES.key. This allows authors setting data spreadsheets as data source for example.
  • Route params properties for matching custom routes via ROUTE_PARAMS.key. This allows pagination or dynamic pages use cases which should render content referenced by id for example.
  • Similar to route params, search params properties defined in the URL can be targeted with SEARCH_PARAMS.key.

Here is an example from the pokemon component on the preview branch  where we render the content from a proxied API using SEARCH_PARAMS and a JMESPath query on the returned data:





        doc2-headers='{"x-forwarded-api": ""}'


        doc2-query="{name: name, sprites: {front: sprites.front_default, back: sprites.back_default}} ">













            <td><img alt="{{name}} front" height="96" width="96" src="{{sprites.front}}"/></td>

            <td><img alt="{{name}} back" height="96" width="96" src="{{sprites.back}}"/></td>






    <h2>Pokemon not found</h2>




You can enable experimentations per page which will give you access to more detailed visitor information if available under the form of <meta> elements including:

  • user-agent
  • connecting-ip
  • ipcountry
  • ipcity
  • ipcountry
  • ipcontinent
  • iplongitude
  • iplatitude
  • region
  • region-code
  • metro-code
  • postal-code
  • timezone
  • sec-ch-ua-mobile
  • sec-ch-ua-platform
  • referer
  • accept-language

To add experiments, simply create an experiments sheet in the spreadsheet and add the following columns:

  • name of the experiment. Multiple experiments can coexist for the same page.
  • path to match the experiment with the exact page pathname or URLRegExp to match a page URL with a regular expression.

Use experiments to better identify your audience and create different experiences to personalize and optimize the content. For example in the demo ssr config, the stories table of content is hidden for Firefox users via the following selector:


  "urlRegExp": "/stories/",

  "select": {

    "html:has(meta[name='user-agent'][content*='Firefox']) web-blog": {

      "class": "no-toc"



We highly recommend connecting the data to which is an open-source platform allowing you to track custom events and create custom dashboards to monitor your user experiences.


Redirects can be added to the spreadsheet. The sheet has to be named redirects. The redirect sheet supports the columns:

  • from which accepts a pathname to redirect from.
  • to which accepts a pathname or a fully qualified URL to redirect to.

The configuration example below redirects to  

Default Metadata#

Default metadata is metadata that serves as fallback metadata to published documents missing the metadata or to define global metadata that is applicable to every published document.

It can be defined in the in the meta sheet using key and value columns. Conditional metadata can be specified by adding a 3rd column urlRegExp to only apply the metadata key:value to the pages whose URL matches the provided Regular Expression.

In the example below, the metadata og:site_name is defined for all pages.

Special metadata will be injected to the page by default if not explicitly specified:





Page language



Page text direction



HTML encoding



Page visible area

width=device-width, initial-scale=1


Page title

Page URL


Page description


Components on the page


Page hashtags


Publication date


Author name


Page experiment names if defined


Matching custom route parameters if defined


Open Graph page canonical URL


Open Graph page title


Open Graph page description


Open Graph page thumbnail


Twitter page title


Twitter page description


Twitter page thumbnail


If needed, values for variables can be defined by authors in the in the placeholder sheet. In the example below, the variable TOOLING will be replaced with the value tooling in every published document which contains it.


The robots.txt file is automatically generated for subdomains requested on and hides all content by default for example  

A custom robots file can be provided to replace the default generated one. Simply push the custom robots file to the root of the repository to make it available at /robots.txt.

This only works for sites behind a custom domain.


The sitemap file is automatically generated as XML per workspace see for example. The default sitemap uses the published pages information path and publishedAt but excludes the following paths:

  • /404
  • /fragments
  • /drafts

A custom sitemap can be produced via script using the Search API to replace the default generated one for example to support multi language sites. Simply push the custom sitemap to the root of the repository to make it available at /sitemap.xml.