Your first site
This page takes you from an empty directory to a built static site. You create
the site with npm create pagedeck, change a page while the dev server runs,
add a page of your own, and build the result. At the end, three of the four
pages ship no JavaScript, and one ships a single island.
Pagedeck needs Node 22.18 or later. On Node 23, it needs 23.7 or later.
1. Create the site
npm create pagedeck@latest my-siteThis writes a new site into my-site, which must not exist yet or must be
empty. The site has a config, three markdown pages in content/, a layout
component and a counter component in components/, and a package.json that
depends on the Pagedeck packages and React. npm create pagedeck then prints
the next steps, which the rest of this page follows.
Pass --host vercel, --host cloudflare-pages or --host netlify to also set
the site up to deploy there — Deploy a site covers
each host's build settings. This tutorial leaves it at the default,
--host none. On a terminal, leaving out the directory or the host prompts
for it instead of failing or defaulting silently.
2. Install and sync
cd my-site
npm install
npx pagedeck syncpagedeck sync reads every page in content/ and writes it into the content
store, content.db. It prints one line per collection,
pages: 3 changed, 0 deleted, cursor <n>. Every other verb reads the store and never reads
content/ directly, so run a sync before anything else.
3. Read the config
The starter's config is pagedeck.config.ts:
import { defineCollection } from "@pagedeck/content";
import { defineMarkdownLoader } from "@pagedeck/markdown-loader";
import { defineConfig, fromCollection, SECURITY_HEADERS } from "@pagedeck/core";
const pages = defineCollection({
name: "pages",
loader: defineMarkdownLoader({ root: "./content", locale: "en", languages: ["ts"] }),
schema: false,
});
export default defineConfig({
collections: [pages],
build: {
pages: [fromCollection(pages, { layout: "layout" })],
components: {
layout: "./components/layout.tsx",
counter: "./components/counter.tsx",
},
routing: { headers: [{ prefix: "/", set: [...SECURITY_HEADERS] }] },
},
});A collection names a set of entries and the loader that fills it. The loader
is the only code that reads your content source, and everything after a sync
reads the store it wrote. Here it is the markdown loader, which reads one entry per .md file under ./content. A CMS
or an API you already have would take a loader of its own.
schema has no default, and that is deliberate. It is either a schema that
every entry must satisfy or the literal false, which means "store what the
loader hands over, unchecked". No third option skips validation because nobody
thought about it.
A collection in the store is not yet a site. fromCollection makes each entry
a page. It has no route here, so each entry routes at its own path, and
content/about.md becomes /about/. An entry named index routes at its
directory, so content/index.md is the home page at /. Write a route when
the URL is not the path, such as a date prefix.
The config leaves four settings at their defaults. The store is ./content.db
and the build writes to ./site, both beside the config file. The site has one
locale, en, written left to right, and its routes carry a trailing slash. The
site config reference lists each default and how to
override it.
layout: "layout" renders every entry into the component registered as
layout. The config names each component by its whole file name, .tsx
included, because Pagedeck does not guess extensions. It compiles .tsx and
.jsx as it loads them, so a component needs no build step of its own. The
layout is components/layout.tsx:
import type { ReactNode } from "react";
interface Props {
title: string;
html: string;
children?: ReactNode;
}
export default function Layout({ title, html, children }: Props) {
return (
<>
<title>{title}</title>
<article>
<h1>{title}</h1>
<div dangerouslySetInnerHTML={{ __html: html }} />
{children}
</article>
</>
);
}The markdown loader takes each page's title from a title in its frontmatter,
or else from its first # heading. It renders the rest of the body as html.
The layout gets both as props. children holds the components a page names in
its frontmatter, which the last section comes back to.
routing.headers spreads SECURITY_HEADERS into a rule over /, so every
page this site builds ships those three headers.
4. Start the dev server
npx pagedeck devIt prints the address it serves, http://127.0.0.1:5173. Open it. The home
page links to the other two, /about/ and /counter/. The server renders each
page from the store when you request it, and runs until you stop it with
Ctrl-C.
5. Edit a page
The dev server reads the store, so an edit to content/ shows only after a
sync. Leave the dev server running, open a second terminal in my-site, and
start a sync that runs again five seconds after each sync ends:
npx pagedeck sync --watchOpen content/about.md, change its first line from # About to
# About this site, and save it. pagedeck sync --watch reads the edit on its
next sync, five seconds after the last one ended. Reload /about/ in the browser, and the new heading is there. The browser does
not reload by itself after a content edit.
6. Add a page
Every .md file under content/ is a page. Save this as content/hello.md:
# Hello
This page was added by hand, and it ships no JavaScript either.
[Back home](/)
pagedeck sync --watch picks the file up on its next sync. Open /hello/. Its route comes
from its file name, as step 3 described, and the layout renders it like the
other pages.
7. Build
Stop pagedeck sync --watch and pagedeck dev with Ctrl-C in each
terminal, then build:
npx pagedeck buildpagedeck build reads the store, renders every page, bundles the JavaScript
the islands need, and writes the finished site to site/. It does not read
content/, so a build never depends on your content source being reachable. That is why sync and build are separate verbs.
What you have
site/ holds four pages. Three of them, /, /about/ and /hello/, are plain
HTML with no <script> tag. Open site/about/index.html to check.
/counter/ is the one page with a <script> tag. It loads the counter and the
React code that runs it. Its page is content/counter.md:
---
components: [counter]
---
# Counter
The `components` line at the top of this file puts `components/counter.tsx`
below this text. Its first line, `"use client"`, makes it an island: this page
loads its JavaScript, and no other page does.
[Back home](/)
Each name in the components list is a key of build.components in the
config, and the layout renders it as a child. The counter is an island because
components/counter.tsx starts with "use client". The build bundles an
island's JavaScript for the pages that name it and for no other page, which is
why adding /hello/ cost nothing. A
JavaScript budget keeps it that way, by
failing the build when a page ships more than you allow.
Add an island and hold it to a budget sets one up.
What happens next
- Add an island and hold it to a budget writes an island of your own, chooses when it hydrates, and reads what each page ships.
- Site config covers layouts, frontmatter components, routes and every default this page relied on.
- The pagedeck command lists each verb and its flags,
including
pagedeck sync --incremental, which reads only what changed. - Write a loader points the framework at a content source of your own.
- Connect a CMS walks through a site whose pages come from a headless CMS, from the loader to the JavaScript each page ships.
- Deploy a site uploads
site/to a host, then only what changed, and keeps the content store between CI runs. - Why this site ships no JavaScript explains where a script tag comes from.