Introduction#

In this guide, we will develop an intuition for the basic principles and concepts of web development. We will do so using the FastHTML library, which enables us to create websites and web applications using Python. Crucially, it still requires considerable understanding of how web pages and web apps are built and how they work. It won’t make it easy to build web pages without understanding HTML/CSS or HTTP requests. But it might mean that there is now a “Pythonic” way to learn it and to get started quickly.

FastHTML is a bit different from other web development frameworks insofar as you can write Python functions to handle server logic without having to write a separate HTML template and Javascript for client-side interactivity. This is because FastHTML is designed to be a Pythonic way to generate HTML content, and it uses htmx for client-side interactivity. This approach is somewhat controversial, as exemplified by this Hacker News exchange with the creator of FastHTML:

Hacker News exchange with FastHTML creator

The key thing to remember here is that we return HTML content directly from our Python functions rather than populating a template and then rendering it.

The goal of this guide is to start as simply as possible and to add more and more complexity to that simple start, explaining what is happening at each step and mapping the FastHTML code to a broader understanding of web development concepts.

A Simple Start#

As always…let’s start with a Hello, World. In this case, we will develop a web page that says “Hello, World” that you can serve and see in your browser.

from fasthtml.common import *

app, rt = fast_app()

@rt("/")
def get():
    return Titled("FastHTML", P("Hello, World!"))

serve()

To see the results, save this as a .py file and run it with python <filename>.py. You can save a cell as a file with the %%writefile <filename>.py magic command. There are a few things happening here that are immediately worth pointing out:

  1. The @rt("/") decorator tells us the route with which the decorated function will be associated. / is just the index page or main page.

  2. get() isn’t just an arbitrary function name—it is an HTTP verb or HTTP request method. HTTP methods specify the type of action to be performed; get means that we want to retrieve some data. Note that FastHTML gives a few different ways of specifying routes and methods. We could have decorated a differently-named function with @app.get("/"). The following cell does the same thing; replace app.py with its contents and run it again to see.

#%%writefile app_1.py
from fasthtml.common import *

app, rt = fast_app()

@app.get("/")
def hello():
    return Titled("Hello, World!")

serve()

To recap what happens: when a user goes to the home page (/), a get() http request is triggered, which, in this case, calls the hello() function, which generates the resulting “Hello, World” page.

But what does “generates the resulting “Hello, World” page actually mean? What FastHTML does is generate the HTML structure for the page. This HTML is then sent back to the user’s browser, which renders it as a web page displaying “Hello, World”. We can actually see this raw HTML using the Starlette TestClient. (Starlette is an “Asynchronous Server Gateway Interface” or ASGI framework and one of the core components of FastHTML. Don’t worry about it for now.)

Here’s how you can see the generated HTML (e.g. in a Jupyter notebook).

from starlette.testclient import TestClient
client = TestClient(app)
r = client.get("/")
print(r.text)
<!doctype html>

<html>
  <head>
    <title>Hello, World!</title>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
    <script src="https://unpkg.com/htmx.org@next/dist/htmx.min.js"></script>
    <script src="https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@main/fasthtml.js"></script>
    <script src="https://cdn.jsdelivr.net/gh/answerdotai/surreal@main/surreal.js"></script>
    <script src="https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js"></script>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@latest/css/pico.min.css">
    <style>:root { --pico-font-size: 100%; }</style>
  </head>
  <body><main class="container"><h1>Hello, World!</h1>
</main>
</body>
</html>

You’ll notice a few things:

  • We ended up with the title Hello, World (<title>Hello, World</title>)

  • In the page body, Hello, World is wrapped in h1 tags.

Modifying our First Webpage#

Titled is an FT (FastTags) component that combines a Title and H1 HTML tag. What happens if we use something simpler, like <p>?

#%%writefile app_1.py
from fasthtml.common import *

app, rt = fast_app()

@app.get("/")
def hello():
    return P("Hello, World!")

serve()
client = TestClient(app)
r = client.get("/")
print(r.text)
<!doctype html>

<html>
  <head>
    <title>FastHTML page</title>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
    <script src="https://unpkg.com/htmx.org@next/dist/htmx.min.js"></script>
    <script src="https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@main/fasthtml.js"></script>
    <script src="https://cdn.jsdelivr.net/gh/answerdotai/surreal@main/surreal.js"></script>
    <script src="https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js"></script>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@latest/css/pico.min.css">
    <style>:root { --pico-font-size: 100%; }</style>
  </head>
  <body><p>Hello, World!</p>
</body>
</html>

When we make this change, the title reverts to the default FastHTML page and the Hello, World text is no longer formatted as a header. But let’s add the title and formatting back in, without using Titled:

# %%writefile app_1.py
from fasthtml.common import *

app, rt = fast_app()


@app.get("/")
def hello():
    return (Title("Hello, World!"), Main(H1("Hello, World!"), cls="container"))


serve()
client = TestClient(app)
r = client.get("/")
print(r.text)
<!doctype html>

<html>
  <head>
    <title>Hello, World!</title>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
    <script src="https://unpkg.com/htmx.org@next/dist/htmx.min.js"></script>
    <script src="https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@main/fasthtml.js"></script>
    <script src="https://cdn.jsdelivr.net/gh/answerdotai/surreal@main/surreal.js"></script>
    <script src="https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js"></script>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@latest/css/pico.min.css">
    <style>:root { --pico-font-size: 100%; }</style>
  </head>
  <body><main class="container"><h1>Hello, World!</h1>
</main>
</body>
</html>

Our hello function can return multiple components, some of which might be nested. In this case, we returned a Title and the “Hello, World” text nested in main and h1 tags. Furthermore, we specified the container class for our main element. By default, FastHTML uses PicoCSS for styling; the default PicoCSS container format is centered and fixed width. If you omit the container specification, you’ll see the “Hello, World” text pushed to the left margin.

Let’s add one more element: some simple text under the Hello, World header.

#%%writefile app_1.py
from fasthtml.common import *

app, rt = fast_app()


@app.get("/")
def hello():
    return (Title("Hello, World!"), Main(H1("Hello, World!"), cls="container"),
            Div(P("Goodbye, World!"), cls="container"))


serve()
client = TestClient(app)
r = client.get("/")
print(r.text)
<!doctype html>

<html>
  <head>
    <title>Hello, World!</title>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
    <script src="https://unpkg.com/htmx.org@next/dist/htmx.min.js"></script>
    <script src="https://cdn.jsdelivr.net/gh/answerdotai/fasthtml-js@main/fasthtml.js"></script>
    <script src="https://cdn.jsdelivr.net/gh/answerdotai/surreal@main/surreal.js"></script>
    <script src="https://cdn.jsdelivr.net/gh/gnat/css-scope-inline@main/script.js"></script>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@latest/css/pico.min.css">
    <style>:root { --pico-font-size: 100%; }</style>
  </head>
  <body>
    <main class="container"><h1>Hello, World!</h1>
</main>
    <div class="container"><p>Goodbye, World!</p>
</div>
  </body>
</html>

Adding Another Page#

Right now, our web page is just one page. Let’s add another page, and links for navigating back and forth between them.

Note: At this point, I’m going to stop showing the html after each change. But do check it yourself, either with the Starlette TestClient or by viewing the source in your browser!

%%writefile app_1.py
from fasthtml.common import *

app, rt = fast_app()


@app.get("/")
def hello():
    return (Title("Hello, World!"), Main(H1("Hello, World!"), cls="container"),
            Div(P("Goodbye, World!"), cls="container"))

@rt("/about")
def get():
    return(Titled("About this Site",
                  P("This is an example site built with FastHTML!"),
                  A("Return Home", href="/")))


serve()
Overwriting app_0.py

This is missing something—but let’s take a look before we finish it anyway. Save and run the script again. It will take you to the home page, but there’s no way to get to the new “about” page. But you can still get there manually: just add “about” to the end of the url (e.g. http://0.0.0.0:5001/about). By navigating to that URL, you are manually invoking the get request we defined on that route.

Of course, we’d rather have a link on the home page. Let’s add it. Just like in the “about” route, we use the A FT component.

%%writefile app_1.py
from fasthtml.common import *

app, rt = fast_app()


@app.get("/")
def hello():
    return (Title("Hello, World!"), Main(H1("Hello, World!"), cls="container"),
            Div(P("Goodbye, World!"), A("About", href="/about"), cls="container"))

@rt("/about")
def get():
    return(Titled("About this Site",
                  P("This is an example site built with FastHTML!"),
                  A("Return Home", href="/")))


serve()
Overwriting app_0.py

One last thing in this part of the tutorial—you might want to inspect the html of specific components as you’re developing the site. You don’t need the TestClient or your browser’s developer tools to do this. If you’re working in a Jupyter notebook, simply running the cell with a component will print out the HTML. And calling show() on that component will actually display the rendered HTML.

A("Return Home", href="/")
<a href="/">Return Home</a>
show(A("Return Home", href="/"))

Review#

This concludes the first part of our FastHTML tutorial. At this point, you should have a very basic understanding of:

  1. How to locally serve a simple FastHTML website

  2. How to use the Starlette TestCLient to view the HTML generated by http get requests on specific routes

  3. How FT components map Python to HTML

  4. How to add multiple pages and links between them to a site

  5. How to preview components in a Jupyter notebook

There’s already a lot you can do at this point. I recommend stopping here and taking some time to experiment. Add more pages, try out different layouts. Pick a few HTML elements from this list and figure out how the FT version works (hint: FT components start with capital letters). You might, for example, try adding a Blockquote or an Hr component.

In the next section, we will focus on customizing our website. After that, we will start exploring how to add more interesting and sophisticated capabilities using Python code and http methods other than get.