The API is now mostly functional—posts can be created, updated, and deleted through the interactive documentation at /docs or manual requests via curl. However, the front-end web pages remain read-only. Users can browse and view individual posts, but cannot create, edit, or delete anything from the browser itself. This chapter adds forms that call the API using JavaScript and the Fetch API.

The focus remains on API interaction: how the frontend sends data to endpoints and handles responses. JavaScript serves as the glue connecting everything. The architecture’s key advantage: any client can use the API identically—the JavaScript written here could just as easily be a mobile app or desktop application making the same requests.

Temporary Authentication Scaffolding

The API currently requires a user ID when creating posts. Without authentication (which comes in a future tutorial), a user ID will be hardcoded in JavaScript to simulate a logged-in user. This enables testing post creation, editing, and deletion as that user while showing what happens when someone lacks permission for posts owned by others. All of this will be replaced with proper authentication.

Prerequisites: You should have at least one user and some posts from previous tutorials. If not, create them via the /docs interface. Throughout this chapter, user ID 1 will be used as the hardcoded value.

Understanding Bootstrap Modals

Throughout this chapter, Bootstrap modals will be added. A modal is simply a popup overlay. For example, clicking a delete button displays a confirmation popup—this is a modal. Modals will be used for creating posts, displaying success messages, and showing errors.

Adding the Create Post Modal

Instead of embedding a form on a specific page, add a modal to the layout template. This makes the form accessible from anywhere in the application—homepage, individual post pages, everywhere. The modal keeps users where they are without navigation.

New Post Button in Navbar

In layout.html, add a button before the login and register links:

<button class="btn btn-outline-light mb-2 mb-md-0 me-md-2"  
        type="button"  
        data-bs-toggle="modal"  
        data-bs-target="#createPostModal">New Post</button>

data-bs-toggle="modal" tells Bootstrap this button opens a modal. data-bs-target="#createPostModal" specifies which modal. The button uses the same styling as the login button. Once authentication is implemented, this button will only be visible when users are logged in. For now, everyone sees it.

Create Post Modal Structure

Near the bottom of layout.html below the footer, add the modal:

<div class="modal fade"
	 id="createPostModal"
	 tabindex="-1"
	 aria-labelledby="createPostModalLabel"
	 aria-hidden="true">
	<div class="modal-dialog">
		<div class="modal-content">
			<div class="modal-header">
				<h5 class="modal-title" id="createPostModalLabel">New Post</h5>
				<button type="button"
						class="btn-close"
						data-bs-dismiss="modal"
						aria-label="Close"></button>
			</div>
			<form id="createPostForm">
				<div class="modal-body">
					<div class="mb-3">
						<label for="title" class="form-label">Title</label>
						<input type="text" class="form-control" id="title" name="title" required>
					</div>
					<div class="mb-3">
						<label for="content" class="form-label">Content</label>
						<textarea class="form-control" id="content" name="content" rows="5" required></textarea>
					</div>
				</div>
				<div class="modal-footer">
					<button type="button"
							class="btn btn-outline-secondary"
							data-bs-dismiss="modal">Cancel</button>
					<button type="submit" class="btn btn-primary">Post</button>
				</div>
			</form>
		</div>
	</div>
</div>
  • Form ID: id="createPostForm" is critical—JavaScript references this.
  • Required fields: Title and content both have the required attribute for basic browser validation.
  • No user ID field: This isn’t shown to end users. It will be hardcoded in JavaScript temporarily.
  • No action or method attributes: JavaScript intercepts submission, providing control over the process. This allows calling the API, showing feedback in a modal, and deciding when to reload.

Success and Error Modals

After the create post modal, add feedback modals:

<!-- Success Modal -->
<div class="modal fade"
	 id="successModal"
	 tabindex="-1"
	 aria-labelledby="successModalLabel"
	 aria-hidden="true">
	<div class="modal-dialog">
		<div class="modal-content">
			<div class="modal-header bg-success text-white">
				<h5 class="modal-title" id="successModalLabel">Success</h5>
				<button type="button"
						class="btn-close btn-close-white"
						data-bs-dismiss="modal"
						aria-label="Close"></button>
			</div>
			<div class="modal-body">
				<p id="successMessage" class="fs-5"></p>
			</div>
			<div class="modal-footer">
				<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
			</div>
		</div>
	</div>
</div
<!-- Error Modal -->
<div class="modal fade"
	 id="errorModal"
	 tabindex="-1"
	 aria-labelledby="errorModalLabel"
	 aria-hidden="true">
	<div class="modal-dialog">
		<div class="modal-content">
			<div class="modal-header bg-danger text-white">
				<h5 class="modal-title" id="errorModalLabel">Error</h5>
				<button type="button"
						class="btn-close btn-close-white"
						data-bs-dismiss="modal"
						aria-label="Close"></button>
			</div>
			<div class="modal-body">
				<p id="errorMessage" class="fs-5"></p>
			</div>
			<div class="modal-footer">
				<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
			</div>
		</div>
	</div>
</div>

The success modal has a green header with a paragraph containing id="successMessage". The error modal has a red header with id="errorMessage". JavaScript will grab these IDs and populate them dynamically. Both are placed in layout.html so they’re available on every page.

JavaScript Utilities Module

Before wiring up forms, create a utilities module. Shared logic across pages belongs in one place. Create static/js/utils.js:

// Error message extraction from API responses  
export function getErrorMessage(error) {  
  if (typeof error.detail === "string") {  
    return error.detail;  
  } else if (Array.isArray(error.detail)) {  
    return error.detail.map((err) => err.msg).join(". ");  
  }  
  return "An error occurred. Please try again.";  
}  
  
// Show a Bootstrap modal by ID  
export function showModal(modalId) {  
  const modal = bootstrap.Modal.getOrCreateInstance(  
    document.getElementById(modalId),  
  );  
  modal.show();  
  return modal;  
}  
  
// Hide a Bootstrap modal by ID  
export function hideModal(modalId) {  
  const modal = bootstrap.Modal.getInstance(document.getElementById(modalId));  
  if (modal) modal.hide();  
}
  • getErrorMessage() handles FastAPI validation errors, which aren’t simple strings. Sometimes detail is a string (like “Post not found”), but validation errors are lists of error objects. Dumping this directly into the UI produces the weird code-like error messages seen on some websites. This function extracts user-friendly messages. If detail is a string, return it directly. If it’s an array of validation errors, extract messages and join them.
  • showModal() uses Bootstrap’s getOrCreateInstance() to get or create a modal instance and show it. Just pass the modal ID as a string.
  • hideModal() gets an existing modal and hides it, safely handling cases where the modal doesn’t exist.

All functions are exported so templates can import only what they need.

Wiring Up the Create Post Form

This is where JavaScript begins interacting with the API. The handler must exist on every page because the modal is on every page. Place this script at the bottom of layout.html before the closing </body> tag:

<script type="module">
    import {
    getErrorMessage,
    hideModal,
    showModal,
    } from "/static/js/utils.js";
 
    const createForm = document.getElementById("createPostForm");
 
    createForm.addEventListener("submit", async (event) => {
    // Stop default form submission - we'll handle it with JavaScript
    event.preventDefault();
 
    // Gather form values into a plain object {title: "...", content: "..."}
    const formData = new FormData(createForm);
    const postData = Object.fromEntries(formData.entries());
 
    // TEMPORARY - hardcoded until authorization (Tutorial 11)
    postData.user_id = 1;
 
    try {
        // POST to our API as JSON
        const response = await fetch("/api/posts", {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
        },
        body: JSON.stringify(postData),
        });
 
        if (response.ok) {
        const data = await response.json();
        document.getElementById("successMessage").textContent =
            `Post "${data.title}" created successfully!`;
 
        hideModal("createPostModal");
        showModal("successModal");
 
        // Clear form
        createForm.reset();
 
        // Reload page after modal is hidden to show new post
        document
            .getElementById("successModal")
            .addEventListener(
            "hidden.bs.modal",
            () => {
                window.location.reload();
            },
            { once: true },
            );
        } else {
        const error = await response.json();
        document.getElementById("errorMessage").textContent =
            getErrorMessage(error);
 
        hideModal("createPostModal");
        showModal("errorModal");
        }
    } catch (error) {
        document.getElementById("errorMessage").textContent =
        "Network error. Please check your connection and try again.";
        showModal("errorModal");
    }
    });
        </script>
  • Module imports: type="module" on the script tag enables import statements. Utility functions are imported from utils.js.
  • Form reference: document.getElementById('createPostForm') gets the form.
  • Submit listener: An event listener is added for the submit event.
  • e.preventDefault() stops the browser’s default form behavior, allowing JavaScript to handle submission.
  • Form data gathering: new FormData(form) grabs all form values. Object.fromEntries() converts it to a plain object like {title: "...", content: "..."}.
  • Hardcoded user ID: postData.user_id = 1 is the temporary scaffolding mentioned earlier. Once authentication is added, this comes from the authenticated user’s token.
  • Fetch API call: fetch('/api/posts', {...}) posts to the API with method POST, Content-Type header application/json (because the API expects JSON), and stringified post data as the body.
  • Success handling: If response.ok, parse the JSON, set the success message, hide the create post modal, show the success modal, and clear the form. An event listener reloads the page when the success modal closes, ensuring the new post appears in the list.
  • Error handling: If an error response is received, parse it, use getErrorMessage() to extract a user-friendly message, hide the create post modal, and show the error modal.
  • Network error handling: A catch block handles other errors like network failures or API downtime, showing a generic “Network error. Please check your connection and try again.”
  • Block scripts section: At the end, add {% block scripts %}{% endblock %}. This lets child templates add their own scripts that load after Bootstrap is ready. This will be used for the post page.

Testing Create Post

Save layout.html and reload the browser. A “New Post” button appears in the navbar. Clicking it opens the modal. Fill out a test post with title “Test Post from Frontend” and content “Frontend post.” Submitting displays the success message. Closing the success modal reloads the page. The new post appears—but at the bottom.

Fixing Post Order with Backend Sorting

By default, the database returns posts in creation order (oldest to newest). For blogs or social feeds, newest posts should appear first. Where should this be fixed?

While JavaScript could sort posts on the frontend, best practice is handling this on the backend. Business logic belongs in the backend. Database sorting with ORDER BY is more efficient than JavaScript sorting, consistent across all clients, and serves as a single source of truth.

Let’s update four functions where lists of posts are returned.

Posts Router

In routers/posts.py:

@router.get("", response_model=list[PostResponse])
async def get_posts(db: Annotated[AsyncSession, Depends(get_db)]):
    result = await db.execute(
        select(models.Post)
        .options(selectinload(models.Post.author))
        .order_by(models.Post.date_posted.desc())
    )
    posts = result.scalars().all()
    return posts

.order_by(models.Post.date_posted.desc()) tells SQLAlchemy to order by the date_posted field in descending order—newest first. The .desc() method provides descending order. Without it, ascending order is the default (oldest first).

Home Route

In main.py:

@app.get("/", include_in_schema=False, name="home")
async def home(
    request: Request,
    db: Annotated[AsyncSession, Depends(get_db)]
):
    result = await db.execute(
        select(models.Post)
        .options(selectinload(models.Post.author))
        .order_by(models.Post.date_posted.desc())
    )
    posts = result.scalars().all()
    # ... rest of function

User Posts Page

In main.py:

@app.get("/users/{user_id}/posts", include_in_schema=False, name="user_posts")
async def user_posts_page(
    request: Request,
    user_id: int,
    db: Annotated[AsyncSession, Depends(get_db)]
):
    # ... user verification ...
    
    result = await db.execute(
        select(models.Post)
        .options(selectinload(models.Post.author))
        .where(models.Post.user_id == user_id)
        .order_by(models.Post.date_posted.desc())
    )
    posts = result.scalars().all()
    # ... rest of function

Get User Posts API

In routers/users.py:

@router.get("/{user_id}/posts", response_model=list[PostResponse])
async def get_user_posts(
    user_id: int,
    db: Annotated[AsyncSession, Depends(get_db)]
):
    # ... user verification ...
    
    result = await db.execute(
        select(models.Post)
        .options(selectinload(models.Post.author))
        .where(models.Post.user_id == user_id)
        .order_by(models.Post.date_posted.desc())
    )
    posts = result.scalars().all()
    return posts

Finding all locations to update: If you need to change query ordering across a project, search for db.execute to find all queries requiring order_by statements.

Reload the browser. The test post from the frontend now appears first. Clicking the user shows newest posts at the top. Checking the API confirms the ordering—the latest post is now first.

Adding Edit and Delete Functionality

Individual post pages have edit and delete buttons, but they’re currently unwired. The post.html template has a placeholder post actions div with an edit button (an anchor tag with href="#" that does nothing) and a delete button opening a placeholder modal. Replace this with a JavaScript-driven version.

Updated Post Actions with Conditional Display

Delete the existing post actions div and replace it with:

{% if post.user_id == 1 %}  
    <div class="post-actions mt-3 pt-3 border-top">  
        <button type="button"  
                class="btn btn-outline-secondary me-1"  
                data-bs-toggle="modal"  
                data-bs-target="#editModal">Edit Post</button>  
        <button type="button"  
                class="btn btn-outline-danger"  
                data-bs-toggle="modal"  
                data-bs-target="#deleteModal">Delete Post</button>  
    </div>
{% endif %}

Currently, buttons only show if post.user_id == 1. With authentication, this becomes “if current user’s ID equals post author’s ID.” The buttons use the same Bootstrap toggle and target data attributes as the new post button to open their respective modals.

Edit Post Modal

After the article element closes, add the edit modal:

<div class="modal fade" id="editPostModal">
    <div class="modal-dialog">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title">Edit Post</h5>
                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
            </div>
            <div class="modal-body">
                <form id="editPostForm">
                    <input type="hidden" name="post_id" value="{{ post.id }}">
                    <div class="mb-3">
                        <label for="edit-title" class="form-label">Title</label>
                        <input type="text" class="form-control" id="edit-title" name="title" 
                               value="{{ post.title }}" required>
                    </div>
                    <div class="mb-3">
                        <label for="edit-content" class="form-label">Content</label>
                        <textarea class="form-control" id="edit-content" name="content" 
                                  rows="5" required>{{ post.content }}</textarea>
                    </div>
                </form>
            </div>
            <div class="modal-footer">
                <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
                <button type="submit" form="editPostForm" class="btn btn-primary">Save Changes</button>
            </div>
        </div>
    </div>
</div>
  • Form ID: id="editPostForm" for separate JavaScript reference.
  • Hidden input: Contains the post ID.
  • Prefilled fields: Title and content are populated with current values (value="{{ post.title }}" and {{ post.content }}). When users open the form, they immediately see current data.

Delete Confirmation Modal

Replace the placeholder delete modal with a working version:

<!-- Delete Confirmation Modal -->
<div class="modal fade"
	 id="deleteModal"
	 tabindex="-1"
	 aria-labelledby="deleteModalLabel"
	 aria-hidden="true">
	<div class="modal-dialog">
		<div class="modal-content">
			<div class="modal-header bg-danger text-white">
				<h5 class="modal-title" id="deleteModalLabel">Delete Post?</h5>
				<button type="button"
						class="btn-close btn-close-white"
						data-bs-dismiss="modal"
						aria-label="Close"></button>
			</div>
			<div class="modal-body">
				<p class="fs-5">Are you sure you want to delete this post? This action cannot be undone.</p>
			</div>
			<div class="modal-footer">
				<button type="button"
						class="btn btn-outline-secondary"
						data-bs-dismiss="modal">Cancel</button>
				<button type="button" class="btn btn-danger" id="confirmDelete">Delete</button>
			</div>
		</div>
	</div>
</div>

The delete button has id="confirmDelete" for JavaScript reference. It’s a regular button, not a form. The red header (bg-danger) signals a dangerous activity.

JavaScript for Edit and Delete

At the end of post.html after {% endblock content %}, add a scripts block:

{% block scripts %}
    <script type="module">
    import {
      getErrorMessage,
      hideModal,
      showModal,
    } from "/static/js/utils.js";
 
    // Get post ID from Jinja2 template
    const postId = {{ post.id }};
 
    // Edit Post Form Handler
    const editForm = document.getElementById("editPostForm");
    editForm.addEventListener("submit", async (event) => {
      // Stop default form submission - we'll handle it with JavaScript
      event.preventDefault();
 
      // Gather form values into a plain object
      const formData = new FormData(editForm);
      const postData = Object.fromEntries(formData.entries());
 
      // Remove post_id from data (it's in the URL, not the body)
      delete postData.post_id;
 
      try {
        // PATCH for partial update (just title and content, not user_id)
        const response = await fetch(`/api/posts/${postId}`, {
          method: "PATCH",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify(postData),
        });
 
        if (response.ok) {
          document.getElementById("successMessage").textContent =
            "Post updated successfully!";
 
          hideModal("editModal");
          showModal("successModal");
 
          document
            .getElementById("successModal")
            .addEventListener(
              "hidden.bs.modal",
              () => {
                window.location.reload();
              },
              { once: true },
            );
        } else {
          const error = await response.json();
          document.getElementById("errorMessage").textContent =
            getErrorMessage(error);
 
          hideModal("editModal");
          showModal("errorModal");
        }
      } catch (error) {
        document.getElementById("errorMessage").textContent =
          "Network error. Please check your connection and try again.";
        showModal("errorModal");
      }
    });
 
    // Delete Post Handler - listen for click on delete button
    const deleteButton = document.getElementById("confirmDelete");
    deleteButton.addEventListener("click", async () => {
      try {
        // DELETE request - no body needed, post_id is in the URL
        const response = await fetch(`/api/posts/${postId}`, {
          method: "DELETE",
        });
 
        // 204 = No Content (success)
        if (response.status === 204) {
          // Post is gone, redirect to home page
          window.location.href = "/";
        } else {
          const error = await response.json();
          document.getElementById("errorMessage").textContent =
            getErrorMessage(error);
 
          hideModal("deleteModal");
          showModal("errorModal");
        }
      } catch (error) {
        document.getElementById("errorMessage").textContent =
          "Network error. Please check your connection and try again.";
        showModal("errorModal");
      }
    });
    </script>
{% endblock scripts %}

This script loads after Bootstrap is ready because it’s in the {% block scripts %} section added to the layout template.

Edit form handling:

  • Utilities are imported from utils.js. The post ID is obtained directly from Jinja2 templating. A submit event listener is added to the edit form. The default submission is prevented. Form data is gathered and the post ID is removed from the data since it’s in the URL, not the body.
  • Fetch sends a PATCH request to /api/posts/{postId}. PATCH is used instead of PUT because this is a partial update—only title and content are sent, not user ID. PATCH is semantically correct for partial updates.
  • If the response is okay, show the success modal and reload when it closes. On error, parse the error message and show the error modal. Network errors display a generic message.

Delete button handling:

  • A click event listener is added to the confirm delete button. Fetch sends a DELETE request to /api/posts/{postId}. No body is needed for DELETE—the post ID in the URL identifies what to delete.
  • If the response status is 204 (success with no content), redirect to the homepage. The post no longer exists, so staying on that page makes no sense. On error, parse and show the error modal. Network errors display a generic message.

Testing Complete CRUD Operations

Create a new post: “New Post with All Functionality” / “New post done.” Success—it appears at the top.

Edit the post: “New Post with Edit Functionality.” Save. The page reloads and the edit worked.

Delete the post: Clicking delete shows “Are you sure? This is permanent. This can’t be undone.” Confirming deletion redirects to the homepage. The post is gone.

All CRUD operations are now fully functional from the frontend.

Testing Error Handling

Create a new post with an excessively long title by pasting text multiple times. Titles have a maximum of 100 characters (defined in the schema). Attempting to submit displays an error: “String should have at most 100 characters.” The validation and error handling work correctly from the frontend.

Architecture and Design Principles

The key principle is separation of concerns: The backend handles data, validation, and business logic through the JSON API. The frontend handles presentation and user interaction.

Because the API returns JSON, the same API can serve multiple clients. Currently it’s used in a web browser, but a mobile app could use the identical API. A desktop app could use it. It could even be accessed via command line.

This is not a full single-page application like React. After successful actions, the page still reloads. However, this hybrid approach provides benefits:

  • Forms appear in modals, keeping users on their current page
  • Error messages appear in modals instead of redirecting to error pages
  • Clean API separation makes the backend reusable

It’s a practical middle ground used by many real-world applications and provides a foundation for more dynamic behavior if desired later.

Security Concerns and Next Steps

The main issue: All hardcoded user ID values aren’t secure. Anyone could call the API directly and create, edit, or delete virtually anything.

The next tutorials will fix this with authentication. Users will register and log in with JSON Web Tokens (JWT). The user ID will come from the token automatically, and the API will verify ownership before allowing edits and deletes. Replacing all hardcoded values with proper authentication will be satisfying.

Summary

Frontend forms now interact with the FastAPI backend using JavaScript and the Fetch API. Bootstrap modals were added to the layout template for creating posts and displaying success/error feedback. A JavaScript utilities module (utils.js) provides shared functions for error message extraction and modal management. The core pattern for all forms involves preventing default submission, gathering form data, calling the API with fetch, and handling success/error responses through modals. Backend queries were updated with .order_by(models.Post.date_posted.desc()) to show newest posts first—business logic belongs in the backend, not the frontend. Edit and delete functionality was implemented using PATCH and DELETE requests respectively, with conditional display based on the hardcoded user ID.

The architecture demonstrates clean separation of concerns: the backend provides a JSON API handling all business logic, while the frontend manages presentation and user interaction. The same API serves any client—web browsers, mobile apps, desktop applications, or command-line tools.

The next tutorial implements authentication to secure the application. Users will register and log in using JSON Web Tokens, user IDs will come from tokens automatically, and the API will verify ownership before allowing operations. All temporary scaffolding with hardcoded values will be removed.