Building and Publishing a Page
From an empty folder to a page on the web: file and folder structure, relative paths, checking and validating, performance basics, and publishing on static hosting.
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, notOutage Map.webp. A space in a file name becomes%20in 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 perhapsjs/andfonts/. - 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.
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:
- Validate every page with the Nu Html Checker, and the CSS with the W3C CSS validator.
-
Click every link and look for missing images and
stylesheets (404s in the Network panel), preferably from a local
server, not
file://. - 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.
- Narrow the window to phone width (about 360 CSS pixels) and check for sideways scrolling and text that does not fit.
- Use only the keyboard: can you reach every link and control with Tab, and do you always see where the focus is?
- Zoom to 200%: text must stay readable without overlapping.
- Run Lighthouse and read the concrete problems it lists rather than only the scores.
-
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:
-
Choose the format.
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.
Content Format Why Logos, icons, diagrams SVG Vector drawing: sharp at any size, usually tiny, can use currentColorPhotos 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 -
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
srcsetfor different screens (see links, images and media). - 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.
-
Load what is needed. Give every image
width,heightand meaningfulalttext, and addloading="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.cssmay not reach returning visitors right away. The usual solution is to change the file name or address when the content changes, for examplestyle.css?v=2orstyle.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.