I needed a way to study a stack of notes I had written for a job interview. Eight markdown files, plus some code examples, plus a couple of reference pages. I wanted to read them in a browser with a sidebar, dark mode, and clickable navigation between files. But I did not want to install a static site generator, run a dev server, or maintain a build pipeline for what is basically a personal study folder.
So I built a single-page viewer in one HTML file that reads the markdown directly. The whole site is one index.html plus the markdown files. You open the HTML in a browser, it fetches the markdown, renders it with marked, and you have a real navigable site.
This post walks you through the whole pattern. By the end you will be able to drop the same setup into any folder of notes you want to read like a website.
What we are building
A folder that looks like this:
my-notes/
├── README.md
├── index.html
├── docs/
│ ├── 01-introduction.md
│ ├── 02-setup.md
│ └── 03-deeper-topics.md
└── code-examples/
└── hello.ts
When you open index.html in a browser you get a sidebar on the left listing every doc, and the rest of the page renders the selected markdown file with code highlighting, tables, and proper typography. Click a link in the sidebar, the URL changes to index.html#docs/02-setup.md, and the content swaps. No page reload, no build step, no node_modules.
Why this approach
Most "docs as code" setups are heavier than they need to be for a small personal project. They assume you want a public site, search, versioning, or a custom theme. For a study folder or a small team handbook, you do not need any of that. You need:
- Markdown files you can edit in any editor.
- A way to read them in a browser without typing
cat file.mdin a terminal. - Navigation between files that does not require remembering which folder you are in.
- Something that works the same on Windows, macOS, and Linux.
A single HTML file that renders markdown gives you all four. It is also harder to break than a tooling chain, because there is almost nothing to break.
The folder structure
Before the code, let us talk about the organising principles. The exact names do not matter; what matters is the pattern.
One README at the top, as an index
The top-level README.md is not the first chapter. It is a table of contents. Its job is to tell a new reader where to start and what each file contains.
A good index README has:
- A one-paragraph description of what this folder is.
- A small table linking each file with a short hook ("what you will learn") and an estimated reading time.
- Maybe a "TL;DR" if you only have ten minutes.
Here is a snippet of what mine looks like:
## Read in this order
| # | Document | What you'll learn | Time |
|---|----------|-------------------|------|
| 1 | [Introduction](docs/01-introduction.md) | What this is for | 5 min |
| 2 | [Setup](docs/02-setup.md) | How to install everything | 10 min |
| 3 | [Deeper topics](docs/03-deeper-topics.md) | The interesting bits | 25 min |
A reader who lands on the folder for the first time can immediately tell where to go.
Numbered file names for ordering
Notice the 01-, 02-, 03- prefixes. They serve two purposes:
- The OS sorts the files alphabetically, so the reading order is also the file-listing order.
- The number is a stable handle. You can refer to "doc 04" in conversation and find it instantly.
If you later want to insert a new file between 02 and 03, name it 02b-... or just renumber. Renumbering is annoying but rare in practice.
Group by purpose, not by file type
I see a lot of folders where everything goes in /docs/ because it is markdown. That is fine when you have five files. When you have twenty it stops being fine.
Group by what the content does for the reader, not by file extension:
my-notes/
├── docs/ <- the actual narrative documents
├── code-examples/ <- runnable snippets the docs reference
├── reference/ <- looked-up not read-through (glossary, cheat sheet)
└── assets/ <- images and diagrams
A reader reaches for code-examples/ because they want code, not because it is markdown.
One file per concept, short and focused
If a doc is over 600 lines, split it. Each markdown file should answer one question. The interview prep had eight files, each between 100 and 400 lines. None of them tried to be a textbook.
Writing principles I used in the docs
A quick aside before the HTML viewer.
Lead with a sentence that previews the doc
Every doc opens with a single sentence in italics or quotes that tells you what to expect. That way a reader skimming the index can decide whether to commit five minutes to reading.
Tables for reference, prose for narrative
Tables are great for "look up what this does." They are terrible for "explain this concept." Mixing both keeps a doc readable. The tech-stack page in my prep folder is mostly prose with one or two tables. The glossary is one big table.
Show code with the why
Every code block should be preceded by a sentence that says what it is and why. Code without context is just decoration.
Avoid em dashes and other typography tricks
I deliberately stick to commas, parentheses, and full stops. Em dashes look fancy but they often hide a sentence that should have been split in two.
The single-file HTML viewer
Now the fun part. Here is the whole HTML file in pieces.
The skeleton
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>My Notes</title>
<style>
/* styles go here */
</style>
</head>
<body>
<nav>
<h1>My Notes</h1>
<a href="#README.md">Home</a>
<ol>
<li><a href="#docs/01-introduction.md">Introduction</a></li>
<li><a href="#docs/02-setup.md">Setup</a></li>
<li><a href="#docs/03-deeper-topics.md">Deeper topics</a></li>
</ol>
</nav>
<main id="content">
<div class="loader">Loading...</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/marked@13.0.0/marked.min.js"></script>
<script>
/* router goes here */
</script>
</body>
</html>
That is it for the structure. Two main blocks (a sidebar and a content pane), one CDN script for markdown rendering, one script for the routing logic.
Layout with CSS Grid
The sidebar plus content layout is one CSS Grid declaration:
body {
margin: 0;
font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
display: grid;
grid-template-columns: 280px 1fr;
min-height: 100vh;
}
280px 1fr means the sidebar is a fixed 280 pixels wide, and the rest of the row stretches to fill the remaining space. Three lines, no flexbox, no media queries needed at this stage.
Sticky sidebar
You want the sidebar to stay visible when the content scrolls:
nav {
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
position: sticky plus top: 0 plus height: 100vh makes the sidebar pin itself to the top and become its own scroll container. Three properties, no JavaScript.
Dark mode for free
You can detect the user's system preference with prefers-color-scheme:
:root {
--bg: #0e1116;
--text: #e6edf3;
--link: #58a6ff;
--border: #30363d;
--code-bg: #1c2128;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #ffffff;
--text: #1f2328;
--link: #0969da;
--border: #d1d9e0;
--code-bg: #f6f8fa;
}
}
body {
background: var(--bg);
color: var(--text);
}
Define your colours as CSS custom properties (variables) once. Override them inside a prefers-color-scheme: light block. Use the variables everywhere else. The OS theme switch flips the whole page automatically.
If you want a manual toggle, you can store a class on <html> (light or dark) and override the variables based on the class instead of the media query. But for a personal study folder, following the OS preference is enough.
Mobile responsive in three lines
@media (max-width: 800px) {
body { grid-template-columns: 1fr; }
nav { position: static; height: auto; }
main { padding: 24px; }
}
On narrow screens, collapse the grid to a single column, unstick the sidebar, and shrink padding. Done.
The router (the JavaScript part)
The interesting trick is using the URL hash as the route. When you click <a href="#docs/01-introduction.md">, the browser changes the hash but does not reload the page. We listen for that change and load the right markdown file.
Step 1: load and render
async function load(path) {
if (!path) path = 'README.md';
const content = document.getElementById('content');
content.innerHTML = '<div class="loader">Loading...</div>';
const response = await fetch(path);
if (!response.ok) {
content.innerHTML = `<h2>Could not load ${path}</h2>`;
return;
}
const markdown = await response.text();
content.innerHTML = marked.parse(markdown);
window.scrollTo(0, 0);
}
fetch(path) reads the markdown file from disk (or rather, from wherever the HTML is being served). marked.parse(markdown) turns the markdown text into HTML. We dump that HTML into the content area.
Step 2: react to URL changes
function onHashChange() {
const path = location.hash.slice(1); // strip the leading "#"
load(path);
}
window.addEventListener('hashchange', onHashChange);
onHashChange(); // also run once on initial page load
When the user clicks a sidebar link, location.hash changes, the hashchange event fires, and we reload the content. When they reload the page, the same code runs once and shows the right doc based on the URL.
Step 3: rewrite relative links inside the markdown
This is the bit that trips most people up. If your doc says:
See [setup](02-setup.md) for details.
That link, after rendering, points to 02-setup.md, which the browser tries to fetch as a real page. It will fail (or load the raw markdown), not navigate inside our viewer.
We need to rewrite those links to use the hash:
content.querySelectorAll('a[href]').forEach((anchor) => {
const href = anchor.getAttribute('href');
if (href.startsWith('http') || href.startsWith('#')) return;
if (!href.endsWith('.md') && !href.includes('.md#')) return;
// Resolve relative to the current doc's directory
const currentDir = (location.hash.slice(1) || 'README.md').split('/').slice(0, -1);
const segments = href.split('/');
for (const seg of segments) {
if (seg === '..') currentDir.pop();
else if (seg !== '.') currentDir.push(seg);
}
anchor.setAttribute('href', '#' + currentDir.join('/'));
});
This walks every link the renderer just produced. If it points to a markdown file with a relative path, we resolve it relative to the current doc's directory and prepend a #. Now [setup](02-setup.md) from inside docs/01-introduction.md becomes #docs/02-setup.md, which our router knows how to handle.
You only need this if you write relative links between your docs. If every link in the markdown is absolute (/docs/...), you can skip it.
The one caveat: file:// and CORS
If you double-click index.html in your file manager, the browser opens it from file://. Some browsers (Chrome, in particular) block fetch() from file:// for security reasons. Your viewer will load with the sidebar, then show "Could not load" when it tries to fetch the markdown.
There are two easy fixes.
Fix 1: serve the folder with any tiny HTTP server
# Pick whichever you have
npx http-server .
python -m http.server 8000
Open http://localhost:8080 (or :8000) instead of the file path. Done.
Fix 2: use Firefox
Firefox allows fetch() from file:// by default. If you do not want a server, just use Firefox.
I documented this clearly in the loader's error message so a reader is never confused:
content.innerHTML =
`<h2>Could not load ${path}</h2>` +
`<p>If you opened this file directly via <code>file://</code>, ` +
`your browser may be blocking <code>fetch</code>. Run a tiny ` +
`server in this folder instead:</p>` +
`<pre>npx http-server .</pre>`;
A graceful error message is the best documentation.
Styling tips for nice rendering
Marked outputs plain HTML (<h1>, <table>, <pre>, <code>). You can style them like any other page. A few things make the result look more like GitHub or a real docs site:
main h1, main h2 {
border-bottom: 1px solid var(--border);
padding-bottom: 6px;
}
main pre {
background: var(--code-bg);
padding: 14px 16px;
border-radius: 6px;
border: 1px solid var(--border);
overflow-x: auto;
line-height: 1.5;
}
main code {
background: var(--code-bg);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.92em;
}
main blockquote {
border-left: 4px solid var(--accent);
padding: 4px 16px;
color: var(--muted);
}
main table {
border-collapse: collapse;
width: 100%;
}
main th, main td {
border: 1px solid var(--border);
padding: 8px 12px;
}
Underlined H1 and H2, padded code blocks with rounded corners, blockquotes with a left accent stripe, properly bordered tables. Maybe twenty lines of CSS gets you a result that looks intentional.
When you outgrow this
This pattern is great until you need:
- Search across all docs.
- Build-time syntax highlighting (the marked CDN does not include it, although you can add
highlight.jsthe same way). - Versioning (multiple snapshots of the same docs).
- A public site with a domain and analytics.
At that point, take a look at:
- MkDocs with the Material theme. Best balance of "barely any config" and "production-ready output."
- Docusaurus if you want React components inside your markdown.
- Astro if you want a faster site with more flexibility.
- GitHub Pages plus Jekyll if you just want to publish a folder of markdown to the internet.
For everything else, the no-build approach holds up surprisingly well.
Wrap up
A folder of markdown plus a single HTML viewer gets you most of what a docs site does, with no install step and no maintenance burden. The pattern is:
- Numbered markdown files in a
docs/folder. - A
README.mdat the top that is an index, not a chapter. - One
index.htmlwith CSS Grid layout, a sticky sidebar, and CSS variables for dark mode. - A small router that listens to
hashchange, fetches the markdown, parses it with marked, and rewrites relative links. - A clear error message when
file://blocksfetch.
If you want to study something, document something, or share notes with a small team, this is the smallest amount of effort that gets you a real navigable site. Try it on your next folder of notes.
Happy writing.
Top comments (0)