Most modern web applications are powered by a REST API under the hood. That way, developers can separate JavaScript front-end code from the back-end logic that a web framework like Flask provides.
Following this step-by-step project, youβll create an interactive single-page application with HTML, CSS, and JavaScript. The foundation is an existing Flask project with a REST API and a connected SQLite database, which youβll grab in just a moment.
In this tutorial, youβll learn how to:
- Navigate a full-stack web development workflow
- Structure an HTML file to act as the template for a single-page web application
- Leverage the Jinja templating engine to render dynamic HTML
- Use CSS to style the presentation of an application
- Write JavaScript to add interactivity to an application
- Leverage Ajax to make HTTP requests to the REST API
As a Python developer, youβre probably more comfortable working on the back end of an application. This tutorial will guide you through a full-stack experience from the back end to the front end. You may write less Python code than usual, but youβll learn a whole bunch about HTML, CSS, and JavaScript.
In the JavaScript world, itβs very common to reach for one of many frameworks and libraries. However, you wonβt be using any frameworks in this tutorial. That way, youβll get to know core JavaScript before using tools that build on top of that foundation in your future projects.
You can download the final code for this project by clicking the link below:
Free Source Code: Click here to download the free source code that youβll use to build a front end for your Flask REST API single-page web application.
Demo
In this tutorial, youβll fix some back-end shortcomings and move on to build the front end on top of an existing REST API that youβll download in a moment. The API already provides several API endpoints to keep track of notes for people who may visit you throughout the year. Youβll initiate the database with people like the Tooth Fairy, the Easter Bunny, and Knecht Ruprecht.
Ideally, you want to be on good terms with all three of them. Thatβs why youβll send them notes, to increase your chances of getting valuable gifts from them.
At the end of this tutorial, youβll be able to interact with your API from the convenience of your applicationβs front end:
At the end of this tutorial, you can put your earned knowledge into action and continue to build a fully functional single-page application that works seamlessly with the REST API that youβll get to know over the following few sections.
Throughout the tutorial, youβll adjust everything from the data model of the database all the way to the client-side experience. This will give you a good idea of what full-stack web development means for you as a Python developer.
Project Overview
In this tutorial, youβll build upon an existing Flask REST API with several endpoints. Youβll start by grabbing the materials for the Flask project and making sure that the API connects to the database.
Note: The tutorial youβre currently reading will guide you through all the steps that you need to create a JavaScript front end for a Flask REST API. However, if youβre curious, you can follow the Python REST APIs With Flask, Connexion, and SQLAlchemy tutorial series to build the Flask REST API that youβll use in this tutorial.
Part 1 of this series guides you through building a REST API, and Part 2 shows you how to connect that REST API to a database. In Part 3, you add relationships to the REST API and the supporting database.
If youβre interested in Python back-end development, then itβs a good idea to check out these three parts first. Either way, before you continue with this tutorial, follow the steps below to collect all the prerequisites.
After youβve verified that the Flask project works, youβll get to know the back end by investigating some shortcomings that the REST API currently has. This part will give you a good impression of the appβs back-end structure before you move on to the front end.
For most of this step-by-step project, youβll be iterating over HTML, CSS, and JavaScript code. Piece by piece, youβll make your single-page web application more maintainable and better looking.
In the end, youβll be able to communicate with your Flask back end from the convenience of your JavaScript-powered front end.
Prerequisites
In this project, youβll focus on writing the front-end code. Still, you need a back end to work with. In this case, itβs a Flask project that provides a REST API with several endpoints.
Read on and download the code that you need to create the Flask project. Additionally, youβll use a bootstrap script to build the database with some sample datasets.
Grab the Back-End Code
This tutorial builds upon the final code of the third part of the Flask REST API series. If youβre curious about building the back end yourself first, then itβs a good idea to start with the first part of the series and work your way through.
Before continuing with this tutorial, make sure to grab the source code by clicking the link below:
Free Source Code: Click here to download the free source code that youβll use to build a front end for your Flask REST API single-page web application.
Even if youβve followed the Flask REST API tutorial series up to this point, please compare your code with the code that you downloaded above. That way, you can work through this tutorial without making individual adjustments for any customized code in your previous project.
Note: The source code that you can download above contains a file named init_database.py that wasnβt part of the earlier tutorial series. Before continuing, ensure that you grabbed this file from the materials.
Before you continue with the tutorial, verify that your folder structure looks like this:
./
β
βββ templates/
β βββ home.html
β
βββ app.py
βββ init_database.py
βββ config.py
βββ models.py
βββ notes.py
βββ people.py
βββ swagger.yml
Once youβve got the Flask REST API folder structure in place, you can read on to create the database.
Install the Requirements
Before you continue working on your Flask project, itβs a good idea to create and activate a virtual environment. That way, youβre installing project dependencies not system-wide but only in your projectβs virtual environment.
Select your operating system below and use your platform-specific command to set up a virtual environment:
With the above commands, you create and activate a virtual environment named venv by using Pythonβs built-in venv module. The parenthesized (venv) in front of the prompt indicates that youβve successfully activated the virtual environment.
Next, you need to install the requirements that the project needs.
For this, you can either use the requirements.txt file from the source code that you downloaded above, or copy the contents from the collapsible section below:
Copy the contents into a file named requirements.txt:
attrs==22.1.0
certifi==2022.9.24
charset-normalizer==2.1.1
click==8.1.3
clickclick==20.10.2
connexion==2.14.1
Flask==2.2.2
flask-marshmallow==0.14.0
Flask-SQLAlchemy==3.0.2
idna==3.4
inflection==0.5.1
itsdangerous==2.1.2
Jinja2==3.1.2
jsonschema==4.16.0
MarkupSafe==2.1.1
marshmallow==3.18.0
marshmallow-sqlalchemy==0.28.1
packaging==21.3
pyparsing==3.0.9
pyrsistent==0.19.1
PyYAML==6.0
requests==2.28.1
six==1.16.0
SQLAlchemy==1.4.42
swagger-ui-bundle==0.0.9
urllib3==1.26.12
Werkzeug==2.2.2
If youβre curious about the packages that youβre installing, then you can check out Part 1, Part 2, and Part 3 of the tutorial series on how to build a Flask REST API.
Make sure that your virtual environment is still activated. Then, install the projectβs dependencies with the command below:
(venv) $ python -m pip install -r requirements.txt
Now your development environment is prepared to work with the Flask project. However, before you check the Flask app, you need to take care of the database.
Create the Database
In the source code that you downloaded above, you also find a file named init_database.py.
The init_database.py file contains code to create and populate the database with example data.
Note: If youβve followed Part 1, Part 2, and Part 3 of the tutorial series on how to build a Flask REST API, you may have a file named build_database.py in your project. But to continue, you must use the init_database.py file from materials you downloaded in the βGrab the Back-End Codeβ section.
Run the code below to get your database ready for use with the web application:
(venv) $ python init_database.py
Created new database
Running init_database.py will create a new database named people.db in your projectβs root directory. If you already have a database with this name, then youβll receive the output βUpdated existing databaseβ instead of βCreated new databaseβ. Both messages indicate that the script ran successfully.
Once your project contains a working database, you can continue to check if your Flask project works.
Check Your Flask Project
Now you can verify that your Flask application is running without errors. Execute the following command in the directory containing the app.py file:
(venv) $ python app.py
When you run this application, a web server will start on port 8000. If you open a browser and navigate to http://localhost:8000, then you should see a list of people with their notes displayed:

Perfect, your app is running flawlessly! Granted, it looks a bit old-school. But thatβs why youβre following this tutorial. Before you start to style your front end, have a look at the back end first.
Step 1: Address Some Shortcomings
The Flask project that youβre working with has a significant shortcoming. Currently, you canβt have two people with the same last name. In this section, youβll first investigate which parts of your code you need to change and then implement updates. With this procedure, youβll get a good understanding of how the back end works in general.
Explore Your API
Your Flask project comes with an automatically created Swagger UI documentation. The documentation helps you to explore your API and play around with the APIβs endpoints.
Visit your Swagger UI at http://localhost:8000/api/ui and inspect the current state of your API:
At first glance, everything seems fineβunless you try to add another fairy to the database:

Apparently, you canβt have two people with the same last name in your database. When you try adding βSugar Plum Fairyβ to the list of people, you get an error because thereβs already a person with βFairyβ as a last name. Thatβs a significant shortcoming worth fixing!
Investigate the Project Structure
Before you get to work, have a look at some files of your project. You have app.py, which starts your Flask app, and init_database.py, which you can run to re-create people.db. Besides those, there are six other files in your Flask project:
| File | Description |
|---|---|
config.py |
Creates and initializes the projectβs configuration |
models.py |
Defines the database structure |
notes.py |
Handles API requests for notes |
people.py |
Handles API requests for people |
swagger.yml |
Defines your REST API |
templates/home.html |
Contains your front-end markup |
If you have a look at models.py, then you may notice a significant difference in how Person and Note are defined:
1# models.py
2
3# ...
4
5class Note(db.Model):
6 __tablename__ = "note"
7 id = db.Column(db.Integer, primary_key=True)
8 person_id = db.Column(db.Integer, db.ForeignKey("person.id"))
9 content = db.Column(db.String, nullable=False)
10 timestamp = db.Column(
11 db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
12 )
13
14# ...
15
16class Person(db.Model):
17 __tablename__ = "person"
18 id = db.Column(db.Integer, primary_key=True)
19 lname = db.Column(db.String(32), unique=True)
20 fname = db.Column(db.String(32))
21 timestamp = db.Column(
22 db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
23 )
24
25 notes = db.relationship(
26 Note,
27 backref="person",
28 cascade="all, delete, delete-orphan",
29 single_parent=True,
30 order_by="desc(Note.timestamp)",
31 )
32
33# ...
A Note object contains an ID, some content, a timestamp, and a connection to a person.
Each Person object has an ID, a first name, a last name, a timestamp, and a relationship to the notes that are connected to them.
What looks a bit unusual is that the last name of a person must be unique because you set lname in line 19 to unique=True. That means you canβt have two people with the same last name in your database. Not ideal!
Next, have a look at people.py:
# people.py
# ...
def create(person):
lname = person.get("lname")
existing_person = Person.query.filter(Person.lname == lname).one_or_none()
if existing_person is None:
new_person = person_schema.load(person, session=db.session)
db.session.add(new_person)
db.session.commit()
return person_schema.dump(new_person), 201
else:
abort(406, f"Person with last name {lname} already exists")
# ...
The create() function checks for the last name of the person object. Itβll only create a new person if thereβs no other person with the same last name in the database already.
The other functions in people.py contain lname as a parameter:
# people.py
# ...
def read_one(lname):
# ...
def update(lname, person):
# ...
def delete(lname):
# ...
Using lname to find people in the database works because lname is the unique identifier for a person. Thatβs in tune with the current state of the Person model. However, this code architecture prevents you from having a Sugar Plum Fairy along with the Tooth Fairy in your database.
Fix Your Model
The models.py file defines the design of your database. To allow multiple entries with the same last name, you need to remove the unique constraint from Person and set the nullable property to False:
1# models.py
2
3# ...
4
5class Person(db.Model):
6 __tablename__ = "person"
7 id = db.Column(db.Integer, primary_key=True)
8 lname = db.Column(db.String(32), nullable=False) # Remove: unique=True
9 fname = db.Column(db.String(32))
10 timestamp = db.Column(
11 db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
12 )
13
14 notes = db.relationship(
15 Note,
16 backref="person",
17 cascade="all, delete, delete-orphan",
18 single_parent=True,
19 order_by="desc(Note.timestamp)",
20 )
21
22# ...
Without the unique constraint of lname entries, you allow the database to store people with the same last name. By adding nullable=False as a property to lname, you state that each person must have a last name. If you didnβt set the nullable property to False, then itβd be possible to store people without any last names.
To reflect the change in the database, stop your Flask development server with Ctrl+C and run init_database.py afterward:
(venv) $ python init_database.py
Updated existing database
When init_database.py finds a database named people.db, then the script backs up its contents and re-creates the database with the rules that you define in models.py. Here, you create a new database where lname doesnβt contain a unique constraint anymore and then populate the database with the existing data again.
Now that your database can store people with the same last name, hop over to the next section to make the required changes in your Flask functions that interact with the database.
Adjust Your Flask Functions
Even though your database now accepts identical last names, some functions in people.py still abort the process if the last name isnβt unique. Also, because you still need a unique identifier for each person, you need to come up with an alternative to using the last name.
If you look at notes.py, then youβll see that all the CRUD functions except create are working with note_id. Youβll adjust people.py to work with a personβs ID to read, update, and delete database entries accordingly.
Here are the changes that youβll perform on those functions:
- Rename the
lnameparameter toperson_id - Adjust the queries for an existing person
- Adapt the abort messages
Open people.py and start implementing the changes.
You can keep people.py open and tackle the changes function by function.
Start by updating create():
# people.py
# ...
def create(person):
new_person = person_schema.load(person, session=db.session)
db.session.add(new_person)
db.session.commit()
return person_schema.dump(new_person), 201
# ...
Now that the ID of a person is the only attribute that must be unique, so thereβs no need to check for any existing people anymore.
You can take the content of the conditional if block and use it as the function body of create().
Formerly, you needed to make sure that there was no person with the same last name. But now, your database will make sure that the ID of a person is unique. Therefore, you donβt need to check for uniqueness in your code.
Next, take care of the read_one() function:
# people.py
# ...
def read_one(person_id):
person = Person.query.get(person_id)
if person is not None:
return person_schema.dump(person)
else:
abort(404, f"Person with ID {person_id} not found")
# ...
In read_one() you must update the functionβs parameter to person_id and adjust the query for person accordingly.
Also, you need to update the error message when no person is found with the ID.
The changes in update() look similar:
# people.py
# ...
def update(person_id, person):
existing_person = Person.query.get(person_id)
if existing_person:
update_person = person_schema.load(person, session=db.session)
existing_person.fname = update_person.fname
db.session.merge(existing_person)
db.session.commit()
return person_schema.dump(existing_person), 201
else:
abort(404, f"Person with ID {person_id} not found")
# ...
Again, you adjust the parameter, the query to look for an existing person, and the error message.
Do the same for delete():
# people.py
# ...
def delete(person_id):
existing_person = Person.query.get(person_id)
if existing_person:
db.session.delete(existing_person)
db.session.commit()
return make_response(f"{person_id} successfully deleted", 200)
else:
abort(404, f"Person with ID {person_id} not found")
Donβt forget to also adjust the string you pass into make_response() to use person_id instead of lname.
The changes in create(), read_one(), update(), and delete() were very similar.
For update(), you can take the changes one step even further.
If you have a look at update() in people.py, then you can see that you currently only update the first name. Formerly, fname was the only attribute of a person that you were allowed to change. The last name was the unique identifier for a person, and therefore you werenβt allowed to change it.
Now that you work with person_id, you can allow updating lname in update() of people.py:
# people.py
# ...
def update(person_id, person):
existing_person = Person.query.get(person_id)
if existing_person:
update_person = person_schema.load(person, session=db.session)
existing_person.fname = update_person.fname
existing_person.lname = update_person.lname
db.session.merge(existing_person)
db.session.commit()
return person_schema.dump(existing_person), 201
else:
abort(404, f"Person with ID {person_id} not found")
# ...
Now you can update the first and the last name of a person. So if the Easter Bunny wants to go by Easter Rabbit, thatβs allowed.
With the changes in models.py, your database, and people.py in place, go on and look at your API documentation. For this, start your Flask development server with the python app.py command and visit http://localhost:8000/api/ui.
You can create people with the same last name. Thatβs good news! However, the documentation shows you that thereβs still some work to do:

Although your Flask functions in people.py work with people_id, it seems that the API hasnβt heard the news yet. The API documentation still shows that it expects lname for the endpoints as part of the request parameters.
Update API Endpoints
Before you dive back into your back-end code, have a look at the current People-API endpoints first:
| Action | HTTP Verb | URL Path | Description |
|---|---|---|---|
| Read | GET |
/api/people |
Reads a collection of people |
| Create | POST |
/api/people |
Creates a new person |
| Read | GET |
/api/people/{lname} |
Reads a particular person |
| Update | PUT |
/api/people/{lname} |
Updates an existing person |
| Delete | DELETE |
/api/people/{lname} |
Deletes an existing person |
The URL paths for the read, update and delete actions require lname as a request parameter. To change lname to person_id, you need to adjust your API specification.
You can find the specification of your API in the swagger.yml file. First, have a look at where you use lname at the moment:
1# swagger.yml
2
3# ...
4components:
5 schemas:
6 Person:
7 type: "object"
8 required:
9 - lname
10 properties:
11 fname:
12 type: "string"
13 lname:
14 type: "string"
15 parameters:
16 lname:
17 name: "lname"
18 description: "Last name of the person to get"
19 in: path
20 required: True
21 schema:
22 type: "string"
23 # ...
24
25paths:
26 # ...
27 /people/{lname}:
28 get:
29 operationId: "people.read_one"
30 tags:
31 - People
32 summary: "Read one person"
33 parameters:
34 - $ref: "#/components/parameters/lname"
35 responses:
36 "200":
37 description: "Successfully read person"
38 put:
39 tags:
40 - People
41 operationId: "people.update"
42 summary: "Update a person"
43 parameters:
44 - $ref: "#/components/parameters/lname"
45 responses:
46 "200":
47 description: "Successfully updated person"
48 requestBody:
49 content:
50 application/json:
51 schema:
52 x-body-name: "person"
53 $ref: "#/components/schemas/Person"
54 delete:
55 tags:
56 - People
57 operationId: "people.delete"
58 summary: "Delete a person"
59 parameters:
60 - $ref: "#/components/parameters/lname"
61 responses:
62 "204":
63 description: "Successfully deleted person"
64 # ...
Remember that you set the nullable property to False in the model for a person? Line 9 reflects that and can therefore stay the same. Also, you still want to keep the lname property in the Person object in line 13.
Other than that, you can replace all remaining occurrences of lname with person_id.
Keep your swagger.yml file open and start by changing the lname parameter in components:
1# swagger.yml
2
3# ...
4components:
5 schemas:
6 Person:
7 type: "object"
8 required:
9 - lname # Don't change
10 properties:
11 fname:
12 type: "string"
13 lname: # Don't change
14 type: "string"
15 parameters:
16 person_id:
17 name: "person_id"
18 description: "ID of the person to get"
19 in: path
20 required: True
21 schema:
22 type: "string"
23 # ...
24# ...
Then, update the /people/ endpoint to include the references to the parameter components:
1# swagger.yml
2
3# ...
4paths:
5 # ...
6 /people/{person_id}:
7 get:
8 operationId: "people.read_one"
9 tags:
10 - People
11 summary: "Read one person"
12 parameters:
13 - $ref: "#/components/parameters/person_id"
14 responses:
15 "200":
16 description: "Successfully read person"
17 put:
18 tags:
19 - People
20 operationId: "people.update"
21 summary: "Update a person"
22 parameters:
23 - $ref: "#/components/parameters/person_id"
24 responses:
25 "200":
26 description: "Successfully updated person"
27 requestBody:
28 content:
29 application/json:
30 schema:
31 x-body-name: "person"
32 $ref: "#/components/schemas/Person"
33 delete:
34 tags:
35 - People
36 operationId: "people.delete"
37 summary: "Delete a person"
38 parameters:
39 - $ref: "#/components/parameters/person_id"
40 responses:
41 "204":
42 description: "Successfully deleted person"
43 # ...
Good work! Now your API specification works with person_id instead of lname.
Still, keep swagger.yml open for a moment.
Thereβs another detail that you need to address.
Prevent a Type Error
Thereβs another update that you need to perform on swagger.yml. It may not be obvious at this moment, but if you go back and check the type of the person_id parameter in your updated lname, then youβll get a hint. Although IDs are commonly number types, you kept it as "string". And thatβs actually a good idea for two reasons:
- You wonβt perform any math operations on an ID. Therefore, it doesnβt need to be an integer.
- Your form fields will arrive as strings in your JavaScript code. Keeping them as strings saves you a conversion step.
If you specified type as "integer", then youβd run into a type error when your API receives a string instead of the expected integer. It saves you some headache to prevent this error by adjusting your API specification up front.
Note: Changing the API specification to save you work on the front end is an advantage when you work alone as a full-stack developer. After all, you can decide whatβs best for your workflow.
If youβre working in a team, then situations like this are a great starting point for a discussion of where to make changes. There are usually arguments on both sides for either implementing a change in the back end or letting the front end take care of it.
Here, you make the change on the back end in the API specification.
There are two instances where an ID has an integer type in your swagger.yml file, which you need to adjust. The first one is the note_id parameter, which you fix below:
# swagger.yml
# ...
components:
# ...
parameters:
person_id:
name: "person_id"
description: "ID of the person to get"
in: path
required: True
schema:
type: "string"
note_id:
name: "note_id"
description: "ID of the note"
in: path
required: True
schema:
type: "string"
# ...
Once youβve changed the type of the note_id parameter to "string", scroll down to the post path for your notes:
# swagger.yml
# ...
paths:
# ...
/notes:
post:
operationId: "notes.create"
tags:
- Notes
summary: "Create a note associated with a person"
requestBody:
description: "Note to create"
required: True
content:
application/json:
schema:
x-body-name: "note"
type: "object"
properties:
person_id:
type: "string"
content:
type: "string"
# ...
After you changed the type of person_id in the schema of your POST request content from "integer" to "string", head over to your Swagger UI API documentation at http://localhost:8000/api/ui and check how your API works.
Instead of showing lname, the documentation should now show person_id for your API endpoints.
That means youβve completed all the necessary fixes in the back end. Now itβs time to continue the full-stack experience and get some work done on the front end.
Step 2: Build the Front-End Components
There are three major components to the front end of a modern single-page web application:
- HTML provides the content and structure of a web page.
- CSS provides a web pageβs presentation or style. It defines how the pageβs content should look when your browser renders it.
- JavaScript provides the interactivity of a web page. It usually handles communication with the back-end server.
For now, youβll add and connect the components to your web project in a rather basic form. In the upcoming sections, youβll expand the HTML, CSS, and JavaScript components of your Flask project separately.
Nest Your HTML Templates
In this section, youβll extend your home.html template with a component so that you can see debugging information. For this, youβll leverage the configuration object that Flask loads into your templates. With the config variable, you can access your Flask configuration, such as debug information.
Note: Youβre currently running your Flask server in debug mode by default. Thatβs okay while developing your app, as it helps you to find debug application errors. If you want to change the running mode, then you need to adjust the debug parameter in app.py:
# app.py
# ...
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000, debug=True)
Before you deploy your Flask app, itβs important to set debug to False.
Open templates/home.html and add a conditional statement at the end of <body> to check if your app is running in DEBUG mode:
<!-- templates/home.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>RP Flask REST API</title>
</head>
<body>
<!-- .... -->
{% if config['DEBUG'] %}
<pre>
<code>
{{ config }}
</code>
</pre>
<style>
pre {
white-space: pre-wrap;
}
</style>
{% endif %}
</body>
</html>
Besides showing config, youβre also adding your first CSS styling. With white-space: pre-wrap, youβre enabling your browser to wrap the code instead of just displaying one long line of code.
When you run your app in debug mode, then youβll see the contents of config at the bottom of your home page. Visit http://localhost:8000 and check it out:

Showing debug information on the front end can be handy for getting a glance at the data that your browser is working with.
Admittedly, the debug area fulfills yet another purpose. By working with this segment of your website, youβll get a feel for the front-end creation workflow that youβll perform later. One key characteristic for this workflow is to modularize logical pieces of your projectβs source code.
Instead of having one big file with HTML, CSS, and JavaScript code, youβll create singular modules that you can link. That way, a file has one role that you can spot through its filename, so you can reference it wherever you need it in other files.
Following this logic, the code to show debug content deserves its own file.
Go on and create a new template named _debug.html in your templates/ directory:
<!-- templates/_debug.html -->
<div class="debug-card">
<form class="debug-form">
<input type="text" name="endpoint" value="/api/people" />
<button data-action="read">Get Data</button>
<button data-action="clear">Clear</button>
</form>
<pre>
<code>{{ config }}</code>
</pre>
</div>
<style>
pre {
white-space: pre-wrap;
}
</style>
This code shows HTML elements and HTML attributes that you may have seen before. Youβll use them multiple times when building your single-page web application, so itβs worthwhile to have a closer look at them.
These are the significant HTML elements from the code above:
<div>wraps content units that belong together.<form>creates a form that contains interactive controls for submitting data.<input>stores the values of a form.<button>creates a button element, which youβll link up with events.
Youβre also using HTML attributes, which provide contextual information to the HTML elements:
classclassifies elements for programmatical CSS styling.typedefines the type of an input field.nameidentifies the input field for better back-end access.valueholds the content of an input field.data-actionis a custom attribute to indicate the intended action to your application.
HTML attributes with the data prefix are custom HTML attributes that you define yourself. Using data attributes allows you to store arbitrary information on the HTML. Also, data attributes are handy when selecting HTML elements in your JavaScript code. Youβll use data attributes quite a lot in the following sections to store key information on the front end.
Note that this HTML file doesnβt contain an <html> tag. Thatβs okay because _debug.html is a template partial, which means that you donβt intend to use _debug.html on its own.
Template partials exist to be included into other templates and only contain a fraction of the complete HTML code.
You may prefix a templateβs name with an underscore (_) to indicate that the content is meant to be included. This is a common convention to keep your templates organized.
To show the HTML code of _debug.html in your app, include it to connect it with home.html:
<!-- templates/home.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>RP Flask REST API</title>
</head>
<body>
<!-- .... -->
{% if config['DEBUG'] %}
<!-- Remove div and style blocks -->
{% include "_debug.html" %}
{% endif %}
</body>
</html>
Replace the body of your conditional statement with a Jinja include tag that references _debug.html.
Visit http://localhost:8000 again, and have a look at the connected templates:

The content of the debug area looks very similar. That makes sense, as your Flask merges home.html with _debug.html before the browser serves the website. The only difference is the buttons that you added to _debug.html.
When you click the buttons, the page reloads. This is due to some default HTML form behavior. Apart from this, thereβs no action connected to the buttons. For this, youβll use JavaScript.
Sprinkle in Some JavaScript
With JavaScript, you can make Flask websites more interactive. For example, you can send API requests with the push of a button.
Enhance your _debug.html template to clear the <code> element when you click the button with the data-action='clear' attribute:
<!-- templates/_debug.html -->
<!-- ... -->
<script>
class DebugForm {
constructor() {
this.debugCard = document.querySelector(".debug-card");
this.form = this.debugCard.querySelector(".debug-form");
this.clearButton = this.form.querySelector("button[data-action='clear']");
this.clearButton.addEventListener(
"click",
this.handleClearClick.bind(this)
);
}
handleClearClick(event) {
event.preventDefault();
let code = this.debugCard.querySelector("code");
code.innerText = "";
}
}
new DebugForm();
</script>
With a <script> element, you can add JavaScript directly into your Flask template files. Here, youβre adding functionality to your form inside of the .debug-card element.
The JavaScript code inside this <script> element demonstrates the pattern that youβll use for the JavaScript in this project. Namely, you have a JavaScript class that acts as a controller for the corresponding HTML element. In this example, the DebugForm class controls the debug <div> element.
Youβll typically be using class constructors to select the target HTML elements with the .querySelector() method of the Document global object. Youβll also be setting up any event listeners that you may need.
Youβll note the call to .bind() when referencing the event handler handleClearClick() in the .addEventListener() method. This allows the event handler to call the this keyword as if it were an instance of the DebugForm. Binding allows the handler to have access to all the properties defined in the constructor, like this.debugCard.
If you want to learn more about JavaScriptβs this keyword, then check out the scope of this section of Python vs JavaScript for Pythonistas.
The result of the code above is that every time you press the Clear button, you trigger the .onHandleClick() method of DebugForm.
Note: As you noticed before, clicking a button triggers a page reload by default. With event.preventDefault(), you prevent the default behavior of an element. For event listeners, this line of code suppresses the page reload.
Clicking Clear removes the config data from the <code> element. But the Get Data button isnβt connected to any event handlers yet.
Update _debug.html to trigger an API request when clicking the button with the data-action='get' attribute:
1<!-- templates/_debug.html -->
2
3<!-- ... -->
4
5<script>
6class DebugForm {
7 constructor() {
8 this.debugCard = document.querySelector(".debug-card");
9 this.form = this.debugCard.querySelector(".debug-form");
10 this.clearButton = this.form.querySelector("button[data-action='clear']");
11 this.clearButton.addEventListener(
12 "click",
13 this.handleClearClick.bind(this)
14 );
15 this.sendButton = this.form.querySelector("button[data-action='read']");
16 this.sendButton.addEventListener("click", this.handleSendClick.bind(this));
17 }
18
19 handleClearClick(event) {
20 event.preventDefault();
21 let code = this.debugCard.querySelector("code");
22 code.innerText = "";
23 }
24
25 handleSendClick(event) {
26 event.preventDefault();
27 const input = document.querySelector(".debug-card input");
28 const endpoint = input.value;
29 getData(endpoint, this.showResponse);
30 }
31
32 showResponse(data) {
33 const debugCard = document.querySelector(".debug-card");
34 let code = debugCard.querySelector("code");
35 code.innerText = data;
36 }
37}
38
39new DebugForm();
40</script>
The .handleSendClick() method that youβre connecting to the sendButton event calls a function in line 29 that doesnβt exist yet. Youβll create the getData() function in a moment. Before you do, look at .showResponse() on line 32.
Youβll use .showResponse() as a callback function thatβs executed once getData() runs successfully. Instead of clearing the content of your <code> element like you do in .handleClearClick(), youβre showing the data that .showResponse() receives.
Go on and add getData() right before DebugForm:
1<!-- templates/_debug.html -->
2
3<!-- ... -->
4
5<script>
6function getData(endpoint, callback) {
7 const request = new XMLHttpRequest();
8 request.onreadystatechange = () => {
9 if (request.readyState === 4) {
10 callback(request.response);
11 }
12 };
13 request.open("GET", endpoint);
14 request.send();
15}
16
17class DebugForm {
18 // ...
19}
20</script>
21
22<!-- ... -->
With getData(), youβre introducing the first Ajax function to your Flask project. Ajax stands for Asynchronous JavaScript and XML. It perfectly describes what youβre doing in getData():
- Line 6 defines the
getData()function with the parametersendpointandcallback. - Line 7 creates a new
XMLHttpRequestobject that you use to make requests. - Line 8 binds the
.onreadystatechange()event torequest. It triggers when you change.readyState()by sending the request in line 14. - Line 9 checks for the value
4of.readyState. The value4indicates theDONEstate. - Line 10 calls the provided callback function with
request.responsewhen therequestoperation is complete. - Line 13 initializes your request with a GET HTTP action and the provided endpoint URL.
- Line 14 sends the request and triggers
.onreadystatechange()when done.
In short, this function makes a GET HTTP request with your API when you call getData(), as you do in DebugForm.onSendClick().
Note: Youβre using XMLHTTPRequest objects to interact with the server. Another approach would be to use the Fetch API.
Now that youβve connected your Flask debug form with some JavaScript events, hop over to http://localhost:8000 and try it out:
Awesome! You can use the input field of your debug form to request data from your database.
Granted, you already have this functionality in your Swagger UI API documentation at http://localhost:8000/api/ui. But youβve equipped yourself with the building blocks to evolve your front end into a full-fledged single-page application.
Over the next few sections, youβll continue to spend your time in the front end. Therefore, it makes sense to make it an attractive place to be. Read on and introduce some style to your single-page web application.
Style Your Front End
You style your websites with Cascading Style Sheets, in short CSS. You already added a bit of CSS in _debug.html to wrap the text. That was fine for the minimal styling of your debug form. But for more extensive styling changes, it makes sense to have a central place to store your styling declarations.
In Flask projects, you commonly use a static/ folder for local external resources. Inside of static/, it helps to create a subfolder for each type of resource. Consequently, you save CSS files inside your static/ directory within a css/ subdirectory.
Create style.css and add the CSS declarations below:
/* static/css/style.css */
:root {
--bg-color: white;
--main-color: coral;
--secondary-color: lavenderblush;
}
* {
box-sizing: border-box;
}
body {
color: var(--main-color);
font-size: 1.3em;
font-family: sans-serif;
display: grid;
justify-content: center;
}
h2 {
margin: 0;
padding-bottom: 0.3em;
}
hr {
border: 1px solid var(--main-color);
border-bottom: none;
}
label {
display: block;
}
label span {
min-width: 9ch;
display: inline-block;
}
input {
border: 1px solid var(--main-color);
color: inherit;
padding: 0.3em;
}
.hidden {
display: none;
}
.editing {
background-color: var(--secondary-color) !important;
}
button,
.button {
background-color: var(--main-color);
border: 1px solid var(--main-color);
color: var(--bg-color);
cursor: pointer;
font-size: 0.6em;
font-weight: bold;
margin: 0.3em 0;
padding: 0.3em 1.3em;
text-transform: uppercase;
min-width: 23ch;
}
button:hover {
background-color: var(--bg-color);
color: var(--main-color);
}
.button {
background-color: transparent;
color: var(--main-color);
cursor: pointer;
}
.button:hover {
background-color: var(--main-color);
color: var(--bg-color);
}
With the CSS declarations above, youβre centering the content and changing the colors and fonts of your single-page web application. Youβre also adjusting the look of your input fields, buttons, and button-like elements to make them more recognizable.
Note: Youβre using CSS variables for your colors. That way, you can play around with different color styles by changing only --bg-color, --main-color, and --secondary-color. Otherwise, youβd have to change the color value of every relevant element whenever you made changes to the design of your website.
Youβll use the CSS class .hidden later to hide elements with JavaScript. With .button, youβll transform text elements to look more like clickable buttons.
Next, adjust home.html to load style.css:
<!-- home.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>RP Flask REST API</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<!-- ... -->
With Flaskβs {{ url_for() }} template tag, youβre creating the full URL to style.css under the hood.
Navigate to http://localhost:8000 to get a first impression:

What a difference! Your home page is starting to look like a proper single-page web application. With your debug form, youβve already got a user interface (UI) element that communicates with your REST API.
In the next section, youβll restructure your Flask projectβs JavaScript code a bit. These changes will make it more convenient to improve your website further in the upcoming sections.
Step 3: Modularize Your Flask Projectβs JavaScript Code
In the previous step, you added JavaScript code directly into _debug.html. Itβs good to know that you can add JavaScript to your Flask template files that way. However, it can get complicated to maintain JavaScript code thatβs hidden in HTML code. In this step, youβll split the JavaScript code of your Flask project into modules.
Create Your First External JavaScript File
Like the home.html file, which you import other templates into, you can create a JavaScript home file. This file is commonly named index.js. Itβll be the hub for any other JavaScript files that you create for your Flask project.
As you learned before, you use a static/ folder for local external resources. In parallel to the css/ folder, create a js/ folder inside of static/ to store your Flask projectβs JavaScript files. Then, create a file named index.js:
// static/js/index.js
import { DebugForm } from "./debug.js";
function main() {
if (document.querySelector(".debug-card")) {
const debug = new DebugForm();
debug.showResponse("");
}
}
main();
The structure of index.js is comparable to a Python script. You have an import statement at the beginning of the file, where you reference another module. In JavaScript, this import is known as an import declaration. Below the import declaration, you create a main() function that you call at the bottom of your script.
The only time youβll see a debug form on your page is when youβre running your Flask app in debug mode.
Consequentially, youβre conditionally instantiating DebugForm only if thereβs a .debug-card element on the page inside of main().
Otherwise, thereβs no need to have the debug functionality present.
Currently, DebugForm is still hard-coded into _debug.html. To make the code in index.js work, move the JavaScript code from _debug.html into a JavaScript file.
Start by removing the JavaScript code and the script tags in _debug.html:
<!-- templates/_debug.html -->
<div class="debug-card">
<form class="debug-form">
<input type="text" name="endpoint" value="/api/people" />
<button data-action="read">Get Data</button>
<button data-action="clear">Clear</button>
</form>
<pre>
<code></code>
</pre>
</div>
<style>
pre {
white-space: pre-wrap;
}
</style>
<!-- Remove the script block -->
The JavaScript code that you removed from _debug.html contained getData() and DebugForm. Currently, your debug form is the only place in your single-page application where you make a request to the REST API. You may already anticipate making requests in other places, too. So, it makes sense to put the logic of making requests into its own file.
Distribute Some JavaScript Logic
Now that _debug.html doesnβt contain any JavaScript code anymore, you need to re-create the JavaScript code in other files. To match their respective purposes, youβll name them request.js and debug.js.
Start by creating a new JavaScript file named request.js inside the js/ folder of your Flask project and paste the getData() function into it:
1// static/js/request.js
2
3export function getData(endpoint, callback) {
4 const request = new XMLHttpRequest();
5 request.onreadystatechange = () => {
6 if (request.readyState === 4) {
7 callback(request.response);
8 }
9 };
10 request.open("GET", endpoint);
11 request.send();
12}
Note that you customize the code in line 3 by prepending the getData() function definition with an export declaration. You use export declarations to make values available in other JavaScript modules. You can think of them as the counterpart to import declarations in other JavaScript files.
Thanks to the export declaration for getData(), you can import the function in other JavaScript files. For example, youβll do this in a file containing your debug formβs JavaScript logic.
Create a new file named debug.js in the js/ folder:
1// static/js/debug.js
2
3import { getData } from "./request.js";
4
5export class DebugForm {
6 constructor() {
7 this.debugCard = document.querySelector(".debug-card");
8 this.form = this.debugCard.querySelector(".debug-form");
9 this.clearButton = this.form.querySelector("button[data-action='clear']");
10 this.clearButton.addEventListener(
11 "click",
12 this.handleClearClick.bind(this)
13 );
14 this.sendButton = this.form.querySelector("button[data-action='read']");
15 this.sendButton.addEventListener("click", this.handleSendClick.bind(this));
16 }
17
18 handleClearClick(event) {
19 event.preventDefault();
20 let code = this.debugCard.querySelector("code");
21 code.innerText = "";
22 }
23
24 handleSendClick(event) {
25 event.preventDefault();
26 const input = document.querySelector(".debug-card input");
27 const endpoint = input.value;
28 getData(endpoint, this.showResponse);
29 }
30
31 showResponse(data) {
32 const debugCard = document.querySelector(".debug-card");
33 let code = debugCard.querySelector("code");
34 code.innerText = data;
35 }
36}
37
38// Don't include new DebugForm();
In line 3, youβre importing getData() from request.js. After the import it, getData() is available the same way as before. Therefore, you donβt need to make any adjustments in line 28.
Like before, you use an export declaration in line 5 to make DebugForm importable in other files. You import DebugForm in index.js, where you also instantiate DebugForm(). Thatβs why you donβt include new DebugForm(); in line 38.
When you removed the JavaScript code from _debug.html, you disconnected your JavaScript logic from your HTML files. In the next section, youβll reconnect them again.
Connect JavaScript With HTML
If you visited http://localhost:8000 now, youβd notice that the JavaScript functionality in your Flask app is gone. To load the external JavaScript files into the HTML, you import them in a way thatβs similar to how you imported your external CSS file.
Open home.html and add a reference to index.js right before the closing </head> tag:
<!-- home.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>RP Flask REST API</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<script
src="{{ url_for('static', filename='js/index.js') }}"
type="module"
></script>
</head>
<!-- ... -->
To load external JavaScript files, you use the <script> tag. Inside it, you reference index.js with a Flask {{ url_for() }} template tag, just like you did for the CSS file. That way, you dynamically create the full URL to index.js.
Hop over to http://localhost:8000 and check if your debug form works again:
Perfect, your debug form works like a charm! You can enter API endpoints into the form field and make API requests by pushing the Get Data button. That means youβve successfully connected your external JavaScript files with your Flask templates.
Adjusting JavaScript code for your debug form gave you a first impression of how you can dynamically load content into your front end. In the next sections, youβll use this knowledge to create people from within the comfort of your front end.
Step 4: Create People
In the previous sections, you learned how to use HTML forms and Ajax requests to interact with your API over your front end. In this section, youβll build on what you already know and create the components to communicate with your REST API.
Youβll focus on one building block at a time. Thatβs very typical when youβre building a single-page web application. First, youβll take care of the HTML structure and add new elements to your home page. Then, youβll extend your CSS code to style the elements. Finally, youβre adding JavaScript to interact with the Flask back end.
In the end, youβll be able to add people to or remove them from your database while staying on the same page.
The HTML
When youβre interacting with your back end, youβre sending forms in an HTTP request and getting some data back as a response. Instead of adding these forms to home.html directly, youβll create template partials for this purpose and include them.
Before you create your new templates, adjust home.html to understand the context of these included templates:
1<!-- templates/home.html -->
2
3<!DOCTYPE html>
4<html lang="en">
5<head>
6 <meta charset="UTF-8">
7 <title>RP Flask REST API</title>
8 <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
9 <script
10 src="{{ url_for('static', filename='js/index.js') }}"
11 type="module"
12 ></script>
13</head>
14<body>
15 <h1>
16 Hello, People!
17 </h1>
18 <!-- Replace person in people for loop with this content -->
19 <div class="person-create-card">
20 {% include "_person_create_form.html" %}
21 </div>
22 <div class="people-list">
23 {% for person in people %}
24 {% include "_person_content.html" %}
25 {% endfor %}
26 </div>
27
28 {% if config['DEBUG'] %}
29 {% include "_debug.html" %}
30 {% endif %}
31</body>
32</html>
You include _person_create_form.html in line 20. Youβll use this form to create new people, as the name suggests. You show this form once after your Hello, People! headline within a <div> containing a person-create-card class attribute. This class attribute will come in handy to address the <form> element in the JavaScript code later.
The other included template is _person_content.html. You reference the template partial in line 24 within your people loop. The HTML code within _person_content.html will show up for every person in the list. The template will be capable of inheriting the particular person object for every loop step.
Now that you know where the template partials are placed, go on and create them. Start with _person_create_form.html:
<!-- templates/_person_create_form.html -->
<form>
<label>
<span>First Name</span>
<input name="fname" type="text" value="" />
</label>
<label>
<span>Last Name</span>
<input name="lname" type="text" value="" />
</label>
<button data-action="create">β¨ Create Person</button>
</form>
The form in the code above may look similar to the one you created in _debug.html because youβre following the same pattern. In the HTML, you create a form that contains the data fields you need. Later, youβll use Ajax to send the data over.
Thereβs one thing thatβs different from the form in _debug.html, though. Your input fields contain labels this time, so you know what data the field expects.
Note: The name attributes of the <input> elements contain the values lname and fname. The name attribute within form elements will help you to serialize your formβs data for the API request.
The button contains a create value for the data-action attribute. This allows you to address the button directly within your JavaScript code later.
Next, create _person_content.html:
<!-- templates/_person_content.html -->
<div class="person-card" data-person-id="{{ person.id }}">
<div class="person-content">
<h2>
<span data-person-fname="{{ person.fname }}">{{ person.fname }}</span>
<span data-person-lname="{{ person.lname }}">{{ person.lname }}</span>
</h2>
</div>
</div>
For now, only show the first and the last name of a person and no notes. Removing the notes will make it more comfortable for you to verify that the code works. No worries, youβll add the notes later again!
Inside of <h2>, you show a personβs name just like you did before, when you displayed the people data in home.html. In _person_content.html, youβre further improving the markup around <h2>.
You use data attributes to connect your HTML elements to the data that your API expects. You could name the attributes almost any way you like. But naming them data-person-id, data-person-fname, and data-person-lname makes the connection obvious and helps you with your JavaScript code later. Additionally, you can target and style the elements in CSS with the HTML class attributes.
The CSS
Now that you have updated your HTML markup, itβs time to take care of the CSS. Youβll use the HTML class attributes to give your HTML elements specific styling.
Before you move on to add some CSS, investigate how your website looks right now:

As expected, you can spot the form at the top of the page, and you donβt show the notes. Your new form element doesnβt look unstyled. Thatβs a good indicator that your existing CSS code is solid. However, thereβs always room for improvement.
Create a new CSS file in static/css/ named people.css. This file will contain all your people-specific styles:
/* static/css/people.css */
.person-create-card {
margin-right: 1em;
}
.person-create-card {
background-color: var(--secondary-color);
padding: 1em;
}
.person-create-card input {
width: 100%;
}
.people-list {
margin-bottom: 1.3em;
}
.person-card {
border-left: 0.3em solid var(--main-color);
padding: 0.3em 1em;
margin: 1em 0;
}
To load people.css, you have to add a reference to people.css to the <head> element in home.html right below your link to style.css:
<!-- templates/home.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>RP Flask REST API</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/people.css') }}">
<script
src="{{ url_for('static', filename='js/index.js') }}"
type="module"
></script>
</head>
<!-- ... --->
When you load people.css after the main CSS file, then you can work with the CSS variables that you defined in style.css. To see if the styles work, open http://localhost:8000 and have a look:

Sweet! The form area is now distinguishable from the list of your people, and each personβs list element got a bit more space.
Next, youβll add some functionality to the creation form.
The JavaScript
In your debug form, you make GET requests with the getData() function in request.js. In other words, youβre only triggering a response from a specific API endpoint. To create a new person, you must send data with your Ajax request. Commonly you do this with POST requests.
So that you can also send a POST request to the API, open request.js and add another Ajax function to it:
1// static/js/request.js
2
3// ...
4
5export function sendForm(form, action, endpoint, callback) {
6 const formData = new FormData(form);
7 const dataJSON = JSON.stringify(Object.fromEntries(formData));
8
9 const request = new XMLHttpRequest();
10 request.onreadystatechange = () => {
11 if (request.readyState === 4) {
12 callback(request.response, form);
13 }
14 };
15 request.open(action, endpoint);
16 request.setRequestHeader("Content-Type", "application/json");
17 request.send(dataJSON);
18}
When you compare sendForm() with getData(), which you defined in the Sprinkle in Some JavaScript section, then you can spot four differences:
- Besides
endpointandcallback,sendForm()accepts the parametersformandactionin line 5. - In lines 6 and 7, you create a JSON object by serializing your form data.
- You set the requestβs content type to JSON in line 16.
- In line 17, you hand over
dataJSONtorequest.send().
With sendForm(), your project contains a flexible Ajax function that can work with any endpoints that accept JSON data.
Create a new file named people.js to build the controller classes for the people-related elements. These classes will manage the people lists and the creation form, as well as dispatching requests to your REST API:
1// static/js/people.js
2
3import { sendForm } from "./request.js";
4
5export class People {
6 constructor() {
7 this.allPeopleCards = document.querySelectorAll(".person-card");
8 this.activateCreateForm();
9 }
10
11 activateCreateForm() {
12 const peopleForm = document.querySelector(".person-create-card form");
13 new CreatePersonForm(peopleForm);
14 }
15}
16
17class CreatePersonForm {
18 constructor(el) {
19 this.form = el;
20 this.createButton = el.querySelector("button[data-action='create']");
21 this.createButton.addEventListener(
22 "click",
23 this.handleCreateClick.bind(this)
24 );
25 }
26
27 handleCreateClick(event) {
28 event.preventDefault();
29 sendForm(this.form, "POST", "/api/people", this.addPersonToList);
30 this.form.reset();
31 }
32
33 addPersonToList(rawData) {
34 const data = JSON.parse(rawData);
35
36 const personCard = document.querySelector(".person-card").cloneNode(true);
37 const personContent = personCard.querySelector(".person-content");
38
39 const personFirstName = personContent.querySelector("[data-person-fname]");
40 personFirstName.textContent = data.fname;
41 personFirstName.setAttribute("data-person-fname", data.fname);
42
43 const personLastName = personContent.querySelector("[data-person-lname]");
44 personLastName.textContent = data.lname;
45 personLastName.setAttribute("data-person-lname", data.lname);
46
47 personCard.setAttribute("data-person-id", data.id);
48 document.querySelector(".people-list").appendChild(personCard);
49 }
50}
In people.js, youβre working with the classes People and CreatePersonForm. Youβll extend People later. For now, its only purpose is to activate the creation form. You do so by calling .activateCreateForm() in line 8.
In .activateCreateForm(), youβre looking for the form within .person-create-card and setting it to peopleForm in line 12. In line 13, youβre instantiating CreatePersonForm with peopleForm as an argument.
In CreatePersonForm, youβre connecting createButton to handleCreateClick(). When you click the button, then you make a POST request to the API in line 29.
After the API request is done, you clear the form input fields with .reset() in line 30.
The callback function of sendForm() in line 29 is .addPersonToList(), which you define in line 33. When .addPersonToList() executes, you clone the first .person-card as a personCard template and add the new personβs data. In line 48, you add personCard to your list of people.
To use people.js, you must add it to index.js and instantiate People:
// static/js/index.js
import { People } from "./people.js";
import { DebugForm } from "./debug.js";
function main() {
new People();
if (document.querySelector(".debug-card")) {
const debug = new DebugForm();
debug.showResponse("");
}
}
main();
The index.js file is the hub for all your JavaScript files in the Flask project. By importing People from people.js and instantiating it inside of main(), youβve done all the work to connect your website with the functionality to create a new person.
Open http://localhost:8000 and try out your new functionality:
Awesome, you can now add a new person to your database from within the comfort of your front end. When you click the Create Person button, you call the REST API. With the data you get back, youβre adding a new person card to the list of people.
Still, the notes for your people are missing. In the next section, youβll follow a similar path to show the notes in the front end.
Step 5: Create Notes
In the previous section, you removed the notes to focus on creating new people. In this section, youβll show the notes for each person. Youβll also add the functionality of creating new notes in the front end.
The HTML
In this section, youβll create two new template partials. The first template contains a form to create new notes. The second template will display the content of a note.
Before you create the new templates, start by extending _person_content.html:
1<!-- templates/_person_content.html -->
2
3<div class="person-card" data-person-id="{{ person.id }}">
4 <div class="person-content">
5 <h2>
6 <span data-person-fname="{{ person.fname }}">{{ person.fname }}</span>
7 <span data-person-lname="{{ person.lname }}">{{ person.lname }}</span>
8 </h2>
9 </div>
10 <ul class="note-list">
11 <li class="note-create-card">
12 {% include "_note_create_form.html" %}
13 </li>
14 {% for note in person.notes %}
15 {% include "_note_content.html" %}
16 {% endfor %}
17 </ul>
18</div>
In line 10, you add the <ul> element thatβll contain your notes. The first item of the notes list is the form to create new notes. Youβll define the form in _note_create_form.html, which you include in line 12.
In line 14, you loop through all the notes that a person contains. For each note, you include the _note_content.html template partial in line 15.
Next, create _note_create_form.html in your templates/ directory:
1<!-- templates/_note_create_form.html -->
2
3<form>
4 <input name="person_id" type="hidden" value="{{ person.id }}" />
5 <label>
6 <span>Note</span>
7 <input name="content" type="text" value="" />
8 </label>
9 <button data-action="create">β¨ Create Note</button>
10</form>
Since youβre including _note_create_form.html in _person_content.html, you can work with the person object in line 4.
The ID of a person is important for connecting the note to the right person in the database. Thatβs why you need it in the form. But you donβt need to see this information in the front end. Thatβs why you set the input type to "hidden".
Other than that, the form looks similar to the form that you use to create people. You have an input field that stores the data that youβll send over to the API with the push of a button.
Next, create the _note_content.html template:
<!-- templates/_note_content.html -->
<li class="note-card" data-note-id="{{ note.id }}">
<div class="note-content">{{ note.content }}</div>
</li>
With both templates finished, check out http://localhost:8000 to see the notes:

Both templates inherit the data from the parent template and render under each person. Still, you can improve the style of the notes a bit.
The CSS
At the moment, the form to create notes looks lost between a personβs name and the list of notes. Just like you did with people.css, create a separate CSS file named notes.css in static/css/:
/* static/css/notes.css */
.note-create-card {
background-color: var(--secondary-color);
padding: 1em;
}
.note-create-card input {
width: 100%;
}
.note-list {
list-style: none;
padding-left: 0;
}
.note-card {
background-color: blanchedalmond;
padding: 1em;
margin: 0.6em 0;
}
.note-content {
padding: 0.3em 0;
}
After you create the CSS file, you need to reference it in home.html:
<!-- templates/home.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>RP Flask REST API</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/people.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/notes.css') }}">
<script
src="{{ url_for('static', filename='js/index.js') }}"
type="module"
></script>
</head>
<!-- ... --->
With the updated styles in place and the reference in home.html, hop over to the browser and open http://localhost:8000:

Your notes look good! The form to create new notes is visually distinguishable from the existing notes. Read on to enhance your good-looking notes display with some JavaScript functionality.
The JavaScript
Just like you did for people, it makes sense to create a new JavaScript file in your Flask project to contain the functionality for handling your notes.
Name it notes.js and store it with the content below in static/js/:
1// static/js/notes.js
2
3import { sendForm } from "./request.js";
4
5export class Notes {
6 constructor() {
7 this.allNoteLists = document.querySelectorAll(".note-list");
8 this.allNotes = document.querySelectorAll(".note-card");
9 this.activateAllCreateForms();
10 }
11
12 activateAllCreateForms() {
13 this.allNoteLists.forEach((noteList) => {
14 const personCard = noteList.closest(".person-card");
15 const personID = personCard.getAttribute("data-person-id");
16 new NoteCreateForm(noteList, personID);
17 });
18 }
19}
20
21export class NoteCreateForm {
22 constructor(noteList, personID) {
23 this.noteList = noteList;
24 this.personID = personID;
25 this.form = this.noteList.querySelector(".note-create-card form");
26 this.createButton = this.form.querySelector(
27 "button[data-action='create']"
28 );
29 this.createButton.addEventListener(
30 "click",
31 this.handleCreateClick.bind(this)
32 );
33 this.connectPerson();
34 }
35
36 connectPerson() {
37 let fieldPersonID = this.form.querySelector("input[name='person_id']");
38 fieldPersonID.setAttribute("value", this.personID);
39 }
40
41 handleCreateClick(event) {
42 event.preventDefault();
43 sendForm(this.form, "POST", "/api/notes", this.addNoteToList);
44 this.form.reset();
45 }
46
47 addNoteToList(rawData) {
48 const data = JSON.parse(rawData);
49 const noteList = document
50 .querySelector("[data-person-id= '" + data.person_id + "']")
51 .querySelector(".note-list");
52 const newNoteCard = document.querySelector(".note-card").cloneNode(true);
53 newNoteCard.querySelector(".note-content").textContent = data.content;
54 newNoteCard.setAttribute("data-note-id", data.id);
55 noteList.insertBefore(newNoteCard, noteList.children[1]);
56 }
57}
The structure of notes.js is similar to people.js. Again, youβre working with two classes:
NotesNoteCreateForm
The purpose of Notes is to activate the creation form. You do so by calling .activateAllCreateForms() in line 9.
In .activateAllCreateForms(), youβre looping through all the note lists. For each note list that you find, youβre selecting the .person-card element that the note list is in and getting the corresponding personID.
In line 16, youβre instantiating NoteCreateForm with noteList and personID as arguments.
In NoteCreateForm, youβre connecting createButton to .handleCreateClick() in line 31. In line 33, you call .connectPerson() to make sure that the ID of the creation form matches the targeted person.
When you click the Create button, then you make a POST request to the API in line 43. If the API request is done, then you clear the form input fields with .reset() in line 44.
The callback function of sendForm() in line 43 is .addNoteToList(), which you define in line 47. When .addNoteToList() executes, then you select the first .note-card of your document in line 52. This is the note card of another person. Thatβs why you need to adjust its contents and attributes after cloning it in lines 53 and 54.
Finally, you add noteCard to noteList in line 55. However, you donβt append it to the end of the list, as you did with personCard in personList. Instead, youβre using .children[1] to add noteCard at index 1. Thatβs right after the note creation form, which is at the first position, index 0, of your noteList.
Before you can check if creating notes now works, you must add notes.js to index.js:
// static/js/index.js
import { People } from "./people.js";
import { Notes } from "./notes.js";
import { DebugForm } from "./debug.js";
function main() {
new People();
new Notes();
if (document.querySelector(".debug-card")) {
const debug = new DebugForm();
debug.showResponse("");
}
}
main();
You add the link to notes.js right after the link to the people.js file. Once the changes are in place, check out how your single-page web application works:
Fantastic, you can now create people and notes! Before you read on, play around with your front end a bit more. Showing the notes of people unveiled a bug that your code contains. Can you spot it?
Step 6: Edit a Person
In this step of the tutorial, youβll enhance your front end to make people editable. So if you mistype a personβs name, then you can edit it afterward. Youβll also add the functionality to remove a person if you donβt want one on your list anymore.
However, before you add more functionality to your single-page web application, you need to address a bug first.
The Bug
You may have added a new person successfully when you werenβt displaying the notes yet. At that point, everything looked fine. But now that youβre also showing the notes, youβve unveiled an interesting bug. Visit http://localhost:8000, create a new person, and see what happens:
When creating a new person, youβre currently copying the notes from an existing person with it. Instead, you want an empty note list.
To fix this bug, head over to people.js and adjust .addPersonToList():
1// static/js/people.js
2
3import { sendForm } from "./request.js";
4import { NoteCreateForm } from "./notes.js";
5
6// ...
7
8class CreatePersonForm {
9
10 //...
11
12 addPersonToList(rawData) {
13 const data = JSON.parse(rawData);
14
15 const personCard = document.querySelector(".person-card").cloneNode(true);
16 const personContent = personCard.querySelector(".person-content");
17
18 const personFirstName = personContent.querySelector("[data-person-fname]");
19 personFirstName.textContent = data.fname;
20 personFirstName.setAttribute("data-person-fname", data.fname);
21
22 const personLastName = personContent.querySelector("[data-person-lname]");
23 personLastName.textContent = data.lname;
24 personLastName.setAttribute("data-person-lname", data.lname);
25
26 personCard.setAttribute("data-person-id", data.id);
27 personCard
28 .querySelectorAll(".note-card")
29 .forEach((noteCard) => noteCard.remove());
30 new NoteCreateForm(personCard.querySelector(".note-list"), data.id);
31 document.querySelector(".people-list").appendChild(personCard);
32 }
33}
When you create a new person, you copy the data of an existing person in line 12. To avoid copying the existing notes along with the person, you delete them in lines 27 to 29.
Finally, youβre instantiating a new NoteCreateForm in line 30, which you import in line 4. That way, you ensure that the note-creation form connects to the right person.
Visit http://localhost:8000 to see if everything works as expected:
What a relief! When you create a new person, there are no notes that belong to another person. Instead, you can now create new notes for the new person without problems.
Now that the bug is out of the way, you can focus on adding features to your single-page web application. You start with the HTML to add edit controls to a person.
The HTML
To edit a person, you need to have a control element for each person. Before you create the HTML for the control element, open the existing _person_content.html file and create a reference to your new file:
<!-- templates/_person_content.html -->
<div class="person-card" data-person-id="{{ person.id }}">
{% include "_person_control.html" %}
<div class="person-content">
<!-- ... -->
</div>
<ul class="note-list">
<!-- ... -->
</ul>
</div>
You want to show the controls to edit a person inside of each personβs card. Like before, youβre referencing another partial. This nesting pattern enables you to separate concerns. This dedicated file is named _person_control.html. Go on and create it with the content below:
<!-- templates/_person_control.html -->
<div class="person-control-card">
<a class="button toggle-control">βοΈ Edit</a>
<div class="person-control hidden">
<form class="person-form">
<input name="id" type="hidden" value="" />
<label>
<span>First Name</span>
<input name="fname" type="text" value="" />
</label>
<label>
<span>Last Name</span>
<input name="lname" type="text" value="" />
</label>
<button data-action="update">π« Update Person</button>
<hr />
<button data-action="cancel">π Cancel</button>
<button data-action="delete">β Delete Person</button>
</form>
</div>
</div>
The form looks similar to the form that you use to create a person. However, the data-action attributes now refer to update and delete actions that youβll create within your JavaScript code in a bit.
Youβll also use JavaScript to populate input fields to see a personβs current first and last name. For now, itβs okay to instantiate them empty.
Note: If you visit your REST API front end right now, then you wonβt see the control form. The reason for this is the hidden class that youβve added to the person-control element. Later, youβll use the hidden class to toggle the visibility of your form with JavaScript.
The two other buttons will help you to toggle the control form. Later, only the Edit button will show. Pressing it will toggle the control form. When you click Cancel, youβll hide the form again without performing any actions.
The CSS
Once the HTML code is in place, itβs time to adjust some CSS code. Your HTML markup introduced a person-control-card element that inherits some styling. To make it look even better, enhance your CSS in people.css:
1/* static/css/people.css */
2
3.person-control-card {
4 text-align: right;
5}
6
7.person-control {
8 text-align: left;
9}
10
11.person-create-card {
12 margin-right: 1em;
13}
14
15.person-create-card,
16.person-control-card.editing {
17 background-color: var(--secondary-color);
18 padding: 1em;
19}
20
21.person-create-card input,
22.person-control-card input {
23 width: 100%;
24}
25
26.people-list {
27 margin-bottom: 1.3em;
28}
29
30.person-card {
31 border-left: 0.3em solid var(--main-color);
32 padding: 0.3em 1em;
33 margin: 1em 0;
34}
Some of the stylings that you introduce wonβt be visible right away. For example, the editing class in line 16 will be a dynamic class that you toggle with JavaScript when you click the Edit button. Only then does the control card receive the secondary-color background.
The JavaScript
The JavaScript that youβre about to write will add and remove some CSS classes. Other than that, youβll also connect the control form with your API.
Open people.js and create a new PersonControl class:
// static/js/people.js
// ...
class PersonControl {
constructor(personCard) {
this.personCard = personCard;
this.personElement = this.personCard.querySelector(".person-content");
this.personControl = this.personCard.querySelector(".person-control");
this.personID = this.personCard.getAttribute("data-person-id");
this.form = this.personCard.querySelector("form");
this.editBtn = this.personCard.querySelector(".toggle-control");
this.editBtn.addEventListener("click", this.handleEditClick.bind(this));
this.cancelBtn = this.personCard.querySelector("[data-action='cancel']");
this.cancelBtn.addEventListener(
"click",
this.handleCancelClick.bind(this)
);
this.deleteBtn = this.personCard.querySelector("[data-action='delete']");
this.deleteBtn.addEventListener(
"click",
this.handleDeleteClick.bind(this)
);
this.updateBtn = this.personCard.querySelector("[data-action='update']");
this.updateBtn.addEventListener(
"click",
this.handleUpdateClick.bind(this)
);
this.fillControlForm();
}
handleEditClick(event) {
event.preventDefault();
this.personCard
.querySelector(".person-control-card")
.classList.add("editing");
this.personElement.classList.add("hidden");
this.editBtn.classList.add("hidden");
this.personControl.classList.remove("hidden");
}
handleCancelClick(event) {
event.preventDefault();
this.personCard
.querySelector(".person-control-card")
.classList.remove("editing");
this.personElement.classList.remove("hidden");
this.editBtn.classList.remove("hidden");
this.personControl.classList.add("hidden");
}
handleDeleteClick(event) {
event.preventDefault();
const endpoint = "/api/people/" + this.personID;
sendForm(this.form, "DELETE", endpoint, (data, inputForm) => {
let personCard = inputForm.closest(".person-card");
if (window.confirm("Do you really want to remove this person?")) {
personCard.remove();
}
});
}
handleUpdateClick(event) {
event.preventDefault();
const endpoint = "/api/people/" + this.personID;
sendForm(this.form, "PUT", endpoint, this.updatePersonInList);
this.cancelBtn.click();
}
updatePersonInList(rawData, inputForm) {
const data = JSON.parse(rawData);
const personCard = inputForm.closest(".person-card");
const personFirstName = personCard.querySelector("[data-person-fname]");
personFirstName.textContent = data.fname;
personFirstName.setAttribute("data-person-fname", data.fname);
const personLastName = personCard.querySelector("[data-person-lname]");
personLastName.textContent = data.lname;
personLastName.setAttribute("data-person-lname", data.lname);
}
fillControlForm() {
const personFirstName = this.personElement.querySelector(
"[data-person-fname]"
).textContent;
const personLastName = this.personElement.querySelector(
"[data-person-lname]"
).textContent;
this.form
.querySelector("[name='id']")
.setAttribute("value", this.personID);
this.form
.querySelector("[name='fname']")
.setAttribute("value", personFirstName);
this.form
.querySelector("[name='lname']")
.setAttribute("value", personLastName);
}
}
To activate PersonControl, you need to instantiate it in your People class when you create a new person:
1// static/js/people.js
2
3export class People {
4 constructor() {
5 this.allPeopleCards = document.querySelectorAll(".person-card");
6 this.activateCreateForm();
7 this.activateAllControls();
8 }
9
10 activateCreateForm() {
11 const peopleForm = document.querySelector(".person-create-card form");
12 new CreatePersonForm(peopleForm);
13 }
14
15 activateAllControls() {
16 this.allPeopleCards.forEach((personCard) => {
17 new PersonControl(personCard);
18 });
19 }
20}
21
22class CreatePersonForm {
23 // ...
24
25 addPersonToList(rawData) {
26 const data = JSON.parse(rawData);
27
28 const personCard = document.querySelector(".person-card").cloneNode(true);
29 const personContent = personCard.querySelector(".person-content");
30
31 const personFirstName = personContent.querySelector("[data-person-fname]");
32 personFirstName.textContent = data.fname;
33 personFirstName.setAttribute("data-person-fname", data.fname);
34
35 const personLastName = personContent.querySelector("[data-person-lname]");
36 personLastName.textContent = data.lname;
37 personLastName.setAttribute("data-person-lname", data.lname);
38
39 personCard.setAttribute("data-person-id", data.id);
40 personCard
41 .querySelectorAll(".note-card")
42 .forEach((noteCard) => noteCard.remove());
43 new PersonControl(personCard);
44 new NoteCreateForm(personCard.querySelector(".note-list"), data.id);
45 document.querySelector(".people-list").appendChild(personCard);
46 }
47}
48
49// ...
With the JavaScript code for the control form in place, itβs time to check out the final state of your Flask REST API front end. Open the browser and visit http://localhost:8000/:
Fantastic, you have a functional and good-looking single-page web application! You can create, edit, and delete people and add notes to each person. All your UI updates appear conveniently in place without a page reload.
Conclusion
Youβve covered a great deal of new ground and should be proud of what youβve learned! It can be tricky to jump back and forth between Python and JavaScript to create a complete Flask single-page application.
In this tutorial, youβve learned how to:
- Navigate a full-stack web development workflow
- Structure an HTML file to act as the template of a single-page web application
- Leverage the Jinja templating engine to render dynamic content
- Use CSS to style the presentation of an application
- Write JavaScript to add interactivity to an application
- Leverage Ajax to make HTTP requests to the REST API
As a Python developer, you usually take care of the back-end parts of an application. But it may still be valuable to know your way around HTML, CSS, and JavaScript when you create Flask apps.
Free Source Code: Click here to download the free source code that youβll use to build a front end for your Flask REST API single-page web application.
Next Steps
Itβs impressive what your JavaScript-powered Flask REST API front end can already do. Of course, there are always opportunities for improvement. If you want to continue to work on your single-page web application, then here are some enhancement ideas:
- Add a control form to update or delete notes
- Restyle the CSS to your own taste
- Show creation and modification dates for people and notes
- Implement pagination for your front end
If youβve enhanced your single-page web application, then make sure to let the Real Python community know in the comments below!




