A folder for your site

A static website is nothing more than a folder of files that a web server hands out as they are. The way you arrange that folder is called the project structure. It does not change how a single page looks, but it decides how easy the site is to extend, how short its paths are, and how many links break when you move something.

Kestrel Energy's portal team publishes a small status page for outages. It is deliberately separate from the customer portal, so customers can still read it when the portal itself is down. Ravi Kumar's team keeps it as simple as a site can be:

kestrel-status/
├── index.html            the home page
├── about/
│   └── index.html        the "About this page" page
├── css/
│   └── style.css
├── images/
│   ├── logo.svg
│   └── outage-map.webp
├── favicon.ico
└── README.md             notes for the team, not linked from the site

A few conventions save a lot of trouble later:

  • Lowercase, no spaces. Write outage-map.webp, not Outage Map.webp. A space in a file name becomes %20 in the address (see URLs and percent-encoding), and mixed case causes the most common "it works on my machine" bug (see below).
  • Hyphens between words, which also give readable addresses: /planned-maintenance/.
  • One kind of file per folder: css/, images/, and later perhaps js/ and fonts/.
  • Everything in version control. Put the folder in a Git repository from day one; it is also how most static hosts receive your files.

index.html, the default page

When an address ends in a folder rather than a file, such as https://status.example.com/ or https://status.example.com/about/, the web server looks in that folder for a default file and sends it. By convention, that file is called index.html. It is not part of any standard, but a setting of the server (the DirectoryIndex directive in Apache, index in Nginx; see web servers and reverse proxies), and virtually every server and static host uses index.html as its default.

How files in the site folder map to addresses. index.html in the site folder is served for https://status.example.com/. about/index.html is served for /about/. css/style.css is served at /css/style.css, and images/logo.svg at /images/logo.svg. file in the folder address in the browser index.html status.example.com/ about/index.html status.example.com/about/ css/style.css status.example.com/css/style.css images/logo.svg status.example.com/images/logo.svg
Folders become parts of the address. A folder address (ending in a slash) gets that folder's index.html.

That gives you clean addresses for free: put each page in its own folder as index.html and link to the folder (about/) instead of the file (about.html). If you later replace the static page with something generated, the address stays the same. Both styles work; pick one and stay with it.

Linking files with relative paths

Every link, image and stylesheet in your HTML points to another file. There are three ways to write that address (see links, images and media for absolute and relative URLs in general). From the file about/index.html, the stylesheet can be reached as:

Written as Kind Means
../css/style.css Relative From the folder of this file, go up one level (..), then into css/
/css/style.css Root-relative From the root of the website, whatever page you are on
https://status.example.com/css/style.css Absolute The full address, including scheme and domain

Relative paths work the same way as relative paths in a file system. They are the most portable choice: the site works in any folder, on any domain, and even when you open the files directly from your disk. Root-relative paths are convenient on a large site, but they assume the site sits at the root of its domain, which is not always true (see the GitHub Pages note below). Absolute addresses are for links to other sites.

<!-- in about/index.html -->
<link rel="stylesheet" href="../css/style.css">
<img src="../images/logo.svg" alt="Kestrel Energy" width="160" height="40">
<a href="../">Back to the status overview</a>

Opening files versus a local server

Double-clicking index.html opens it from disk, with an address that starts with file://. Plain HTML and CSS work fine that way, as long as you use relative paths. But a root-relative path such as /css/style.css then points to the root of your disk, a folder address such as about/ shows a file listing instead of the page, and browsers block some features for file:// pages. To see the site the way a visitor will, run a small local web server in the site folder. If Python is installed, one command is enough:

cd kestrel-status
python3 -m http.server 8000

Then open http://localhost:8000/ in the browser. Stop the server with Ctrl+C. Many code editors have a similar built-in or add-on "live preview" server.

A complete starter page

Every page of the status site starts from the same skeleton, combining what earlier pages of this topic explained: the doctype, the language, the character encoding, the viewport, a title and a description (see HTML document structure and metadata, sharing and search basics), and a semantic layout with landmarks (see accessibility basics). In the real file, the <head> also links the stylesheet and the icon:

<link rel="stylesheet" href="css/style.css">
<link rel="icon" href="favicon.ico">

The result frame cannot load other files, so in the live version below the CSS block plays the role of css/style.css:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Service status — Kestrel Energy</title>
  <meta name="description" content="Current power outages and planned maintenance in the Kestrel Energy network.">
</head>
<body>
  <header>
    <p class="brand">Kestrel Energy · Service status</p>
  </header>
  <main>
    <h1>Current outages</h1>
    <ul class="outages">
      <li><strong>Utrecht-Oost</strong> since 09:40, expected back 12:00</li>
      <li><strong>Amersfoort-Noord</strong> since 10:15, crew on the way</li>
    </ul>
    <p><a href="#">About this page</a></p>
  </main>
  <footer>
    <p>Updated every five minutes.</p>
  </footer>
</body>
</html>
:root {
  color-scheme: light;
  font-family: system-ui, sans-serif;
  line-height: 1.5;
  color: #1f2937;
}

body {
  max-width: 40rem;
  margin: 0 auto;
  padding: 1rem;
}

.brand {
  font-weight: 700;
  border-bottom: 2px solid #1f2937;
  padding-bottom: 0.5rem;
}

.outages {
  padding-left: 0;
  list-style: none;
}

.outages li {
  padding: 0.75rem 1rem;
  margin-bottom: 0.5rem;
  border-left: 4px solid #b91c1c;
  background: #fef2f2;
}

a {
  color: #1d4ed8;
}

footer {
  color: #4b5563;
  font-size: 0.875rem;
}

The page uses the reader's system font (no font files to download), limits the text column to 40rem (roughly 75 characters per line, comfortable to read), and is readable at any width without a single media query. That is a good starting point: add complexity only when the content asks for it.

Check before you publish

A short, fixed checklist catches most problems before a visitor does. Most items take a minute with the tools from debugging with developer tools:

  1. Validate every page with the Nu Html Checker, and the CSS with the W3C CSS validator.
  2. Click every link and look for missing images and stylesheets (404s in the Network panel), preferably from a local server, not file://.
  3. Test in more than one browser engine: a Chromium-based browser (Chrome or Edge), Firefox, and Safari if you can. Chromium has by far the largest share, but Safari is the engine of every iPhone browser, and these engines do differ.
  4. Narrow the window to phone width (about 360 CSS pixels) and check for sideways scrolling and text that does not fit.
  5. Use only the keyboard: can you reach every link and control with Tab, and do you always see where the focus is?
  6. Zoom to 200%: text must stay readable without overlapping.
  7. Run Lighthouse and read the concrete problems it lists rather than only the scores.
  8. Read the page once more: title, description, lang, alternative texts, spelling.

Performance basics

A page that loads slowly loses readers, especially on phones with a weak connection. Google defines three measurements of what a page feels like to use, the Core Web Vitals. Each has a "good" threshold that at least 75% of page visits should meet, counted separately for mobile and desktop:

Metric Measures Good
LCP, Largest Contentful Paint Loading: when the largest image or text block in view has appeared 2.5 seconds or less
INP, Interaction to Next Paint Responsiveness: how long the page takes to visibly react to clicks, taps and key presses 200 milliseconds or less
CLS, Cumulative Layout Shift Visual stability: how much content jumps around 0.1 or less

INP replaced the older First Input Delay (FID) as a Core Web Vital on 12 March 2024; older articles still mention FID. INP mostly depends on JavaScript, so a page of plain HTML and CSS usually scores well on it. The other two depend directly on how you write HTML and CSS.

Getting the main content on screen fast

  • Keep CSS small. The browser waits for the stylesheets in <head> before it paints anything, so a large stylesheet delays the whole page. One small file is better than several large ones you only partly use.
  • Make the main image light (see the next section) and do not give the image at the top of the page loading="lazy": lazy loading delays exactly the image that should come first. Lazy loading is for images further down.
  • Be careful with web fonts. Every font file is an extra download before the text looks right. Self-host a small number of WOFF2 files, or use system fonts (see colors, units and typography).

Stopping the page from jumping

Cumulative Layout Shift (CLS) adds up how far visible content moves unexpectedly while the page is in use, weighted by how much of the screen moves. Everyone knows the effect: you are about to tap a link, an image above it finishes loading, the text drops down and you tap the wrong thing. The most common cause is an image without dimensions: until it has loaded, the browser does not know how much space to reserve, so it reserves none.

The fix is to always give <img> its width and height attributes, in pixels of the original image. With CSS that makes the image flexible, the browser uses the two numbers for the aspect ratio and reserves the right space before a single byte of the image has arrived:

<h2>Outage area</h2>
<img
  src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 360'%3E%3Crect width='640' height='360' fill='%23dbeafe'/%3E%3Ccircle cx='330' cy='170' r='70' fill='none' stroke='%23b91c1c' stroke-width='8'/%3E%3C/svg%3E"
  alt="Map with the outage area in Utrecht-Oost circled in red"
  width="640" height="360">
<p>The text below the map does not move while the map loads.</p>
img {
  max-width: 100%;
  height: auto;
}

Here the image is a tiny inline drawing, so it loads at once; with a real photo on a slow connection, the reserved box is what keeps the paragraph below it in place. The same applies to anything that arrives late: embedded videos and maps, banners and notices inserted by scripts. Reserve their space in CSS (for example with aspect-ratio or a min-height), or add them below the content the reader is looking at, not above it.

Optimizing images

On most pages, images are by far the largest part of what is downloaded. Image optimization means delivering each image in a suitable format, at the size it is shown, compressed as far as it still looks good. Four decisions:

  1. Choose the format.
    Content Format Why
    Logos, icons, diagrams SVG Vector drawing: sharp at any size, usually tiny, can use currentColor
    Photos AVIF or WebP, JPEG as the safe classic AVIF and WebP are often much smaller than JPEG at the same quality
    Screenshots, images with sharp edges or transparency PNG or WebP Lossless: no blurry edges around text
    As of October 2026, WebP (since 2023) and AVIF (since July 2026) are both Baseline widely available, so they work in every current browser without a fallback.
  2. Resize before you upload. A photo straight from a phone is often 4000 pixels wide. Shown in a column of 800 pixels, that is five times the width and up to 25 times the pixels the reader needs. Export it at the size it is displayed, or a few sizes with srcset for different screens (see links, images and media).
  3. Compress. Export with a quality setting around the middle of the scale and compare by eye; the difference is usually invisible while the file shrinks a lot. Image editors and free tools such as Squoosh (a web app) show the before and after side by side.
  4. Load what is needed. Give every image width, height and meaningful alt text, and add loading="lazy" to images further down the page, so they only load when the reader scrolls toward them.

Publishing on static hosting

A site made of HTML, CSS, images and perhaps some JavaScript that runs in the browser does not need a server that runs code: the server only has to send the files as they are. Static hosting is a service that does exactly that, which makes it cheap (often free for small sites), fast, and low in maintenance, because there is no application, database or server software of yours to keep up to date. Most static hosts also put your files on a content delivery network and arrange HTTPS certificates automatically.

Option How you publish Good to know (as of 2026)
GitHub Pages Push to a GitHub repository Free for public repositories; site at most 1 GB, a soft limit of 100 GB traffic per month; not meant for running an online business or shop
Cloudflare Connect a repository or upload files Pages still works, but Cloudflare now points new projects to Workers with static assets
Netlify and similar platforms Connect a repository, or drag a folder onto the site Free tiers exist; plans and limits change, so check the current pricing page
Your own web server Copy the folder with rsync, SFTP or a pipeline Full control, but you maintain the server and its certificates yourself (see web servers)

Example: GitHub Pages

With the site in a local Git repository, publishing takes three steps. First, create an empty repository on GitHub and push the site to it:

git remote add origin https://github.com/kestrel-example/kestrel-status.git
git push -u origin main

Second, on GitHub open the repository's Settings, then Pages, and under "Build and deployment" choose Deploy from a branch with the branch main and the folder / (root). Third, wait a minute: the site appears at https://kestrel-example.github.io/kestrel-status/, with HTTPS. Every later push to main publishes the new version automatically. For more control, such as running the validator first, the site can be published from a GitHub Actions workflow instead.

Two more points. GitHub Pages from a private repository needs a paid GitHub plan, and even then the published site itself is normally public. In general, everything in a static site's folder can be downloaded by anyone who guesses the address, so never put passwords, API keys or personal data in it (see what never belongs in Git).

Your own domain name

Instead of the host's address, you can use your own domain name, such as status.example.com. You enter the name in the host's settings and create a DNS record at your domain registrar that points to the host: usually a CNAME record for a subdomain, or the A/AAAA records the host lists for the bare domain (see DNS record types). DNS changes can take a while to reach everyone, after which the host issues an HTTPS certificate for the new name (see automated certificates).

After publishing: updates and caching

Publishing is not the end. You will fix a typo, add an outage notice, change a color. Three habits keep updates painless:

  • Check the live site after every change. Open it in a private window (so nothing is served from your own cache), look at the Network panel for 404s, and click through the pages you changed.
  • Expect caching. Browsers and content delivery networks keep copies of your files to load the site faster. A changed style.css may not reach returning visitors right away. The usual solution is to change the file name or address when the content changes, for example style.css?v=2 or style.2.css (see cache busting).
  • Look at real-world numbers. Lighthouse measures one visit in a lab. Google's PageSpeed Insights and Search Console also show Core Web Vitals measured on real visitors' devices (for sites with enough traffic), which is what the "75% of visits" thresholds refer to.

Because every version is in Git, a broken update is easy to undo: revert the commit and push (see undoing things), and the host publishes the previous version again.