Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

HTML  Module 1 – Getting started with HTML

Set up an editor, a local server and DevTools

Save HTML files the way servers expect, preview them over http://localhost with Python, inspect the DOM in DevTools and check pages with two validators.

  • Beginner
  • 20 minutes
  • Examples run with Node.js 24.21.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Create, name and save an HTML file as UTF-8 and open it in a browser
  • Run Python's built-in web server to serve a folder over http://localhost
  • Inspect and live-edit the DOM and the accessibility tree in browser DevTools
  • Run the Nu Html Checker or html-validate on a local file

Before you start

What you’ll learn · Before you start

What you will learn

  • Create, name and save an HTML file as UTF-8 and open it in a browser
  • Run Python's built-in web server to serve a folder over http://localhost
  • Inspect and live-edit the DOM and the accessibility tree in browser DevTools
  • Run the Nu Html Checker or html-validate on a local file

Before you start

On this page

To write HTML you need a text editor to type it, a browser to look at it, and two tools that make the work much easier: a local web server, which sends your files to the browser the way a real website would, and the browser’s developer tools (DevTools), which show the page as the browser actually built it. A validator then tells you about mistakes the browser repaired without saying a word. All of these cost nothing, and most are already on your computer.

This lesson sets each one up and runs it on real pages: Python’s built-in server answering requests, a module script that only works over http://, recorded in three browser engines, and two validators checking the same faulty page.

Write and save your first file

Any editor that saves plain text works. A code editor, such as Visual Studio Code or the one that comes with your system, adds colour, auto-completion and the closing of tags you have opened. Word processors do not work, because they save their own document format around your text.

Three habits save trouble later:

  1. Save as UTF-8. It is the only encoding the HTML Standard allows, and most editors use it by default; check the status bar or the “Save as” options if you are unsure. The next lesson shows what happens to text when a browser guesses the encoding wrong.
  2. Use lowercase names with hyphens: index.html, about-us.html, contact.html. The path part of a web address is case-sensitive under RFC 3986, and so are the file systems of many web servers, while Windows and macOS usually ignore case. A link to About-Us.html that works on your laptop can fail once the site is published. Spaces turn into %20 in addresses, so avoid them too.
  3. Call the main page of each folder index.html. When a browser asks for a folder, such as http://localhost:8000/, web servers look for that name and send it; Python’s server does exactly that.

Make a folder called my-site, save a page in it as index.html, and double-click the file. It opens in your browser at an address that starts with file://.

Open the file, or serve the folder

A file:// address is fine for plain HTML and CSS. Some features, though, only work on a page that comes from a web server, because the browser’s security rules are written in terms of a page’s origin, its scheme, host and port, and a page opened from a file has no ordinary origin. Module scripts are the first such feature most people meet. This page loads one:

A page that loads a module script

module-check/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Does the module run?</title>
    <script type="module" src="app.mjs"></script>
  </head>
  <body>
    <p id="status">The module did not run.</p>
  </body>
</html>

module-check/app.mjs

document.getElementById('status').textContent = 'The module ran.';

It was opened in three browser engines, once from a file:// address and once from a local server:

Does the module run? JavaScript · file_or_server.mjs
// Prints whether the module script of module-check/index.html ran in each engine, opened from a file:// address and
// over http://127.0.0.1 (recorded by record.mjs), and what the console said when it did not.
import { readFileSync } from 'node:fs';

const recorded = JSON.parse(readFileSync('recorded.json', 'utf8'));
const result = (id, engine) => recorded.checks.find((c) => c.id === id).results[engine];
const ran = (r) => (r.value === 'The module ran.' ? 'ran' : 'did not run');

console.log('module-check/index.html, opened two ways:\n');
console.log(`${'engine'.padEnd(26)}${'from file://'.padEnd(18)}over http://127.0.0.1`);
for (const { engine, version } of recorded.engines) {
  console.log(`${`${engine} ${version}`.padEnd(26)}${ran(result('module-from-file', engine)).padEnd(18)}${ran(result('module-over-http', engine))}`);
}
for (const { engine } of recorded.engines) {
  const messages = result('module-from-file', engine).console ?? [];
  if (messages.length) {
    console.log(`\n${engine}'s console, from file://:`);
    for (const m of messages) console.log(`  ${m}`);
  }
}

Output

module-check/index.html, opened two ways:

engine                    from file://      over http://127.0.0.1
Chromium 153.0.8010.12    did not run       ran
Firefox 155.0             ran               ran
WebKit 21624.5.1.11.3     did not run       ran

Chromium's console, from file://:
  Access to script at 'file:///…/module-check/app.mjs' from origin 'null' has been blocked by CORS policy: Cross origin requests are only supported for protocol schemes: chrome, chrome-untrusted, data, http, https.
  Failed to load resource: net::ERR_FAILED

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node file_or_server.mjs

From the file, Chromium and WebKit refused to load app.mjs and Firefox ran it. Through the server, all three ran it. A page that works in one browser from a file and fails in another is confusing, so make serving the folder your habit from the start.

A browser reads a page straight from your folder through a file address, or asks a local web server that sends it.Browserpython3 -m http.serveron http://localhost:8000/Your foldersite/index.htmlsite/app.mjsfile:///…/site/index.html:the browser reads the file itselfGET / over HTTPreads index.html, sends itwith Content-Type: text/html

Opening a page from a file, or through a local server

Text description of the diagram

The diagram shows two ways for a browser to get the same page from a folder on your computer.

  1. With a file:// address, the browser reads site/index.html from the folder itself. No server is involved, so there are no HTTP headers and the page has no web origin.
  2. With http://localhost:8000/, the browser sends a GET request over HTTP to python3 -m http.server, which runs on your computer. The server reads index.html from the folder and sends it back with a Content-Type: text/html header, the way a real web server would.

Python comes with a small web server. If python3 --version prints a version in your terminal, you have it; on Windows the command is usually py. Run these commands in the folder you want to serve:

Serve a folder at http://localhost:8000/ Shell · serve.sh
# Go to the folder that holds your index.html (here a folder called my-site):
cd my-site

# Serve it on port 8000, to this computer only. On Windows, type py instead of python3.
python3 -m http.server 8000 --bind 127.0.0.1

# Now open http://localhost:8000/ in your browser. Press Ctrl+C in this terminal to stop the server.

The --bind 127.0.0.1 part matters. Without it, Python’s server listens on every network your computer is connected to, so anyone on the same café or college Wi-Fi could request your files. Python’s documentation also warns that http.server is not meant for production, because it only implements basic security checks; use it to preview your work, and stop it with Ctrl+C when you are done.

What does the server actually send? This program hands three requests to the same request handler that python3 -m http.server uses, without opening a network port, and prints the start of each answer:

What Python's server answers Python · what_the_server_sends.py
"""Ask Python's built-in web server for three addresses, without opening a network port.

python3 -m http.server answers each request with SimpleHTTPRequestHandler. Here the same handler answers
requests that are handed to it in memory, so we can print the status line and the headers it sends back.
The folder it serves is site/, which holds index.html and app.mjs.
"""
import http.server
import io


class InMemoryConnection:
    """Stands in for a network socket: the request is read from bytes, the response is collected."""

    def __init__(self, request: bytes):
        self.incoming = io.BytesIO(request)
        self.outgoing = bytearray()

    def makefile(self, mode, *args, **kwargs):
        return self.incoming

    def sendall(self, data):
        self.outgoing += data


class QuietHandler(http.server.SimpleHTTPRequestHandler):
    def log_message(self, format, *args):
        pass  # the real server prints one line per request, with the time, to the terminal


def fetch(path: str) -> None:
    connection = InMemoryConnection(f"GET {path} HTTP/1.1\r\nHost: localhost:8000\r\n\r\n".encode())
    QuietHandler(connection, ("127.0.0.1", 50000), None, directory="site")
    head, _, body = bytes(connection.outgoing).partition(b"\r\n\r\n")
    lines = head.decode("latin-1").split("\r\n")
    print(f"GET {path}")
    print(" ", lines[0])
    for line in lines[1:]:
        name = line.split(":", 1)[0]
        if name.lower() in ("content-type", "content-length"):
            print(" ", line)
    print(f"  body: {len(body)} bytes, starts with {body[:15]!r}")


fetch("/")
fetch("/app.mjs")
fetch("/menu.html")

Output

GET /
  HTTP/1.0 200 OK
  Content-type: text/html
  Content-Length: 175
  body: 175 bytes, starts with b'<!DOCTYPE html>'
GET /app.mjs
  HTTP/1.0 200 OK
  Content-type: text/javascript
  Content-Length: 73
  body: 73 bytes, starts with b'document.queryS'
GET /menu.html
  HTTP/1.0 404 File not found
  Content-Type: text/html;charset=utf-8
  Content-Length: 460
  body: 460 bytes, starts with b'<!DOCTYPE HTML>'

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 what_the_server_sends.py

Three things in that output come up again in this track. The page is sent as text/html with no character set, so the browser has to find the encoding in the page itself, which the next lesson covers. The .mjs file is sent as text/javascript, and that matters: the HTML Standard runs a module script only when it arrives with a JavaScript MIME type, so a server that sends .mjs files as plain text breaks every module. And a page that does not exist gets the status 404 File not found, which is what your visitors see after a broken link.

Look inside the page with DevTools

Every desktop browser has developer tools. In Chrome, Edge and Firefox press F12, or Ctrl+Shift+I on Windows and Linux and Cmd+Option+I on macOS. You can also right-click any part of a page and choose Inspect. Safari hides its tools until you choose Safari, then Settings, then Advanced, and switch on “Show features for web developers”.

The tools you will use most for HTML:

  • The Elements panel (Inspector in Firefox) shows the DOM, the tree the browser built, not your file. Click an element on the page to find it in the tree. You can double-click text or an attribute to change it and watch the page update; the change lives only in the open tab and is gone when you reload.
  • View Source shows the text exactly as the server sent it. Right-click the page and choose View Page Source, or press Ctrl+U on Windows and Linux.
  • The Console lists errors and warnings, such as the blocked module script above.
  • The accessibility tree shows each element’s role and name, which is what a screen reader announces. In Chrome it is the Accessibility tab of the Elements panel (it may sit behind the “More tabs” button), with a switch that shows the whole tree; Firefox has an Accessibility panel, where an image without alt text shows up with no name.

The difference between View Source and the Elements panel is easiest to see on a page with a mistake in it. Here a list was put inside a paragraph, and the image has no alt text:

opening-hours.html, with two mistakes HTML · opening-hours.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Opening hours</title>
  </head>
  <body>
    <h1>Opening hours</h1>
    <p>We are open:
      <ul>
        <li>Monday to Friday, 8 am to 8 pm</li>
        <li>Saturday, 9 am to 2 pm</li>
      </ul>
    </p>
    <img src="shopfront.jpg">
  </body>
</html>
What the browsers built, and what the validators said JavaScript · opening_hours.mjs
// Prints the body that three engines built from opening-hours.html, and what two validators said about the file
// (both recorded by record.mjs).
import { readFileSync } from 'node:fs';

const recorded = JSON.parse(readFileSync('recorded.json', 'utf8'));
const bodies = Object.values(recorded.checks.find((c) => c.id === 'opening-hours').results).map((r) => r.value);
const engines = recorded.engines.map((e) => e.engine).join(', ');
console.log(`The body that ${engines} built (${new Set(bodies).size === 1 ? 'the same in all three' : 'NOT the same'}):\n`);
// Remove the indentation the lines share, and the empty lines the source had around the elements.
const lines = bodies[0].split('\n').filter((line) => line.trim());
const indent = Math.min(...lines.map((line) => line.length - line.trimStart().length));
console.log(lines.map((line) => line.slice(indent)).join('\n'));

const { versions } = recorded.validators;
console.log('\nWhat the validators report (line:column):');
for (const name of ['html-validate', 'Nu Html Checker']) {
  console.log(`\n${name} ${versions[name]}`);
  for (const m of recorded.validators[name]['opening-hours.html']) {
    console.log(`  ${`${m.line}:${m.column}`.padEnd(7)}${(m.rule ?? m.type).padEnd(19)}${m.message}`);
  }
}

Output

The body that Chromium, Firefox, WebKit built (the same in all three):

<h1>Opening hours</h1>
<p>We are open:
  </p><ul>
    <li>Monday to Friday, 8 am to 8 pm</li>
    <li>Saturday, 9 am to 2 pm</li>
  </ul>
<p></p>
<img src="shopfront.jpg">

What the validators report (line:column):

html-validate 11.16.1
  9:6    no-implicit-close  Element <p> is implicitly closed by adjacent <ul>
  14:6   close-order        Stray end tag '</p>'
  15:6   wcag/h37           <img> is missing required "alt" attribute

Nu Html Checker 26.10.2
  14:5   error              No “p” element in scope but a “p” end tag seen.
  15:5   error              An “img” element must have an “alt” attribute, except under certain conditions. For details, consult guidance on providing text alternatives for images.

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node opening_hours.mjs

A ul cannot sit inside a p, so the parser closed the paragraph as soon as the list began. The </p> that followed the list then had no paragraph to close, and the parser turned it into a new, empty paragraph. View Source still shows the list inside the paragraph; the Elements panel shows the repaired tree. Any CSS written for “the list inside the paragraph” would match nothing.

Check your page with a validator

Browsers repair mistakes silently, so you need a tool that reports them. Two validators are worth knowing:

  • The Nu Html Checker checks a page against the HTML Standard. Use it online at validator.w3.org/nu, or run vnu.jar on your own files with java -jar vnu.jar index.html; it needs Java 17 or later, and the vnu-jar package from npm contains the same file.
  • html-validate runs on Node.js: install it with npm install --save-dev html-validate and check a file with npm exec html-validate index.html. Its default preset, html-validate:recommended, checks the standard plus accessibility and best-practice rules, and html-validate:document adds the rules that need a complete page.

In the run above, both found the stray </p> and the missing alt text, and only html-validate pointed at line 9, where the list closed the paragraph early, which is where the mistake really is. The Nu Html Checker, the checker that runs at validator.w3.org/nu, follows the conformance rules of the standard closely, so running both catches the most. You can also check a page in your browser:

HTML Validator Paste a page or a snippet and get each problem with its line, column and a fix.

Test on a phone

StatCounter’s monthly figures for India put mobile phones at about three page views in every four, so check every page on a phone, not only on a laptop. DevTools can imitate a phone screen (the device toolbar in Chrome, Responsive Design Mode in Firefox), but a real phone shows real fonts, touch and network speed. To reach your local server from a phone on the same Wi-Fi, start it without --bind 127.0.0.1 and open your computer’s network address on the phone; do that only on a network you trust, and stop the server afterwards.

Try it

  1. Make the folder my-site with an index.html like the one in the previous lesson, saved as UTF-8.
  2. Serve it with python3 -m http.server 8000 --bind 127.0.0.1 and open http://localhost:8000/.
  3. Open DevTools, find your h1 in the Elements panel, change its text there, and reload to see the change vanish.
  4. Look at the same h1 in the accessibility tree and note its role and name.
  5. Copy opening-hours.html from above into your folder, check it with a validator, and fix it until the validator reports no errors: close the paragraph before the list (or drop it), remove the stray </p>, and give the image an alt text that describes the shop front.

Check yourself

5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 5 Which name is the safest choice for a page about your team?

    Choose one answer.

    Show the answer to question 1

    Answer: about-us.html

    Lowercase letters, digits and hyphens survive every server. The path part of a URL is case-sensitive, so a link to About.html can break once the site runs on a server whose file system tells About.html and about.html apart, and a space turns into %20 in the address.

  2. Question 2 of 5 A page loads a script with <script type="module" src="app.mjs">. It works on http://localhost:8000/, but when you double-click the file to open it in Chrome, the script does nothing. Why?

    Choose one answer.

    Show the answer to question 2

    Answer: A page opened from a file:// address has no web origin, so Chrome blocks the module request

    Module scripts are fetched with the browser's cross-origin rules, and a page from a file:// address has no origin they can allow. In the recorded run Chromium and WebKit blocked the module and Firefox ran it. A local server gives the page a real origin, http://localhost:8000, in every browser.

  3. Question 3 of 5 You want to preview your folder on a café's Wi-Fi without anyone else on the network seeing it. Which command do you use?

    Choose one answer.

    Show the answer to question 3

    Answer: python3 -m http.server 8000 --bind 127.0.0.1

    Without --bind, Python's server listens on all network interfaces, so other devices on the same network can request your files. 127.0.0.1 is this computer only. Python's documentation also warns that http.server is not meant for production.

  4. Question 4 of 5 You open opening-hours.html, where a list sits inside a paragraph. Which statements are true?

    Choose every answer that is right.

    Show the answer to question 4

    Answer:

    • View Source shows the list inside the paragraph, as it was written
    • The Elements panel shows the paragraph ending before the list, and an empty paragraph after it

    View Source shows the text the server sent; the Elements panel shows the DOM the parser built from it, which closed the p at the ul and turned the stray </p> into an empty paragraph. Changes made in the Elements panel live only in the open page and are gone when you reload.

  5. Question 5 of 5 In the recorded check of opening-hours.html, what did html-validate report that the Nu Html Checker did not?

    Choose one answer.

    Show the answer to question 5

    Answer: That the ul closed the paragraph early, at the line where it happened

    Both validators reported the stray end tag and the image without alt text. html-validate also pointed at line 9, where the ul implicitly closed the p, which is where the actual mistake is. That is why running both is useful.

Interview questions

Why serve a page from a local web server instead of opening the HTML file directly?

A file opened from a file:// address has no normal web origin, and browsers apply their security rules by origin, so some features behave differently or not at all. Module scripts are the common case: when index.html loaded app.mjs from a file, Chromium and WebKit blocked the request and Firefox ran it. A local server such as python3 -m http.server --bind 127.0.0.1 gives the page a real origin, http://localhost:8000, sends each file with a Content-Type header and serves index.html for folders, as a real host does. Pages then behave the same in every browser and the same as after publishing, and relative links and fetch requests work as they will online.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A student's site works when they double-click index.html on their laptop, but after uploading it to a host, the home page loads while the logo and the About page return 404. What are the likely causes, and how do you find and fix them?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    A developer starts python3 -m http.server in their home folder to show a colleague a page over the office Wi-Fi. What risks does that create, and how should they preview and share the page instead?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Save pages as UTF-8 with lowercase, hyphenated names, and call each folder’s main page index.html.
  • A page opened from file:// has no ordinary origin: in the recorded run a module script was blocked from a file in Chromium and WebKit. Preview through a local server instead.
  • python3 -m http.server 8000 --bind 127.0.0.1 serves a folder to your own computer only; it is not a production server.
  • DevTools’ Elements panel shows the DOM, View Source shows the file, and the accessibility tree shows roles and names.
  • Validators report what browsers repair silently; the Nu Html Checker and html-validate catch different things, so use both.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.