Build a Blog Reader with Fetch API
Fetch and display live blog data using the JavaScript Fetch API.
Introduction
30 Second Summary
Every time you scroll through a news feed, browse a social media timeline, or check your email, your browser is silently making requests to servers behind the scenes to fetch that content.
In this project, you will build a blog reader that fetches and displays real posts from a live API. You'll make GET requests to load content and POST requests to create new posts, all using the JavaScript Fetch API.
What You'll Build
A dynamic blog reader web app that communicates with a public REST API using the JavaScript Fetch API to load, display, and create blog posts.
By the end of this project, you'll have:
- A styled blog page that fetches posts from a live API when the page loads.
- A detail view that loads comments for any post you click.
- A "New Post" form that sends a POST request to create content on the server.
- Secret Mission: Add a user profile sidebar that fetches and displays author details when you click their name.
Are there any prerequisites?
This project assumes a basic understanding of HTML and CSS.
Some familiarity with JavaScript helps too (variables, functions, and event listeners).
Set Up Your Project
Before you can fetch data from an API, you need a page to display it on. Every web app starts with a structure that defines where content lives and how it looks.
In this step, you'll build the HTML skeleton for your blog reader and style it with CSS. This gives you the containers that your JavaScript will later fill with real data from the internet.
In this step, get ready to:
- Open your project folder in Cursor.
- Build the HTML page structure with a header, post form, post list, and comments area.
- Style the page with CSS and verify it loads in Chrome.
Open the project folder in Cursor
Cursor needs to know which folder your project lives in so it can show your files in the sidebar.
- In Cursor, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the blog-reader folder you created earlier, then click Open.
You should see index.html, style.css, and app.js listed in the Explorer sidebar on the left.
Don't see your files in the sidebar?
Make sure you opened the blog-reader folder itself, not a parent folder like Desktop. The three files should appear at the top level of the Explorer panel.
If the Explorer sidebar is hidden, press Cmd+Shift+E (macOS) or Ctrl+Shift+E (Windows) to toggle it open.
Build the HTML page structure
HTML defines the skeleton of your page. You need containers for each feature: a header, a form for creating posts, a list area for displaying posts, and a section for comments.
- Click on index.html in the Explorer sidebar to open it in the editor.
- Paste the following HTML into the empty file:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blog Reader</title>
<!-- Link to our stylesheet -->
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Page header with the app title -->
<header>
<h1>Blog Reader</h1>
<p>Powered by HTTP requests</p>
</header>
<main>
<!-- Form section for creating new posts -->
<section id="new-post-section">
<h2>Create a New Post</h2>
<form id="new-post-form">
<input type="text" id="post-title" placeholder="Post title" required>
<textarea id="post-body" placeholder="Write your post..." required></textarea>
<button type="submit">Publish Post</button>
</form>
<p id="form-message" class="message hidden"></p>
</section>
<!-- Section where fetched posts will appear -->
<section id="posts-section">
<h2>Latest Posts</h2>
<div id="posts-container">
<p id="loading-posts">Loading posts...</p>
</div>
</section>
<!-- Section for displaying comments on a selected post -->
<section id="comments-section" class="hidden">
<h2 id="comments-title">Comments</h2>
<div id="comments-container">
<p id="loading-comments">Loading comments...</p>
</div>
</section>
</main>
<!-- Load our JavaScript at the end so the DOM is ready -->
<script src="app.js"></script>
</body>
</html>
What does this HTML do?
- The <header> gives the page a title and subtitle.
- The new-post-section holds a form where users will type and submit new posts.
- The posts-container div is where JavaScript will inject post cards fetched from the API.
- The comments-section starts hidden and will appear when a user clicks a post.
- The <script> tag at the bottom loads your JavaScript after the page structure is ready.
- Save index.html by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Seeing raw HTML text in the browser?
Make sure you saved the file with the .html extension. If the file is named incorrectly, the browser will not render it as a web page.
Also check that the <!DOCTYPE html> declaration is the very first line with no blank lines above it.
Why isn't my HTML rendering correctly in Chrome?
Style the page with CSS
Without CSS, your page is just unstyled text. The stylesheet controls layout, colours, spacing, and the card-based design that makes the blog reader look like a real app.
- Click on style.css in the Explorer sidebar to open it.
- Add the layout foundation styles by pasting this into the empty file:
/* Reset default browser spacing */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Center the page content with a max width */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f5f5;
padding: 20px;
max-width: 900px;
margin: 0 auto;
}
/* Dark header bar */
header {
text-align: center;
margin-bottom: 30px;
padding: 20px;
background-color: #2c3e50;
color: white;
border-radius: 8px;
}
header h1 {
font-size: 2rem;
margin-bottom: 5px;
}
header p {
opacity: 0.8;
font-size: 0.9rem;
}
/* White card style for each section */
section {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
h2 {
margin-bottom: 15px;
color: #2c3e50;
}
What do these styles do?
- The * selector resets margins and padding so every browser starts from the same baseline.
- The body rule centres content at a max width of 900px with a light grey background.
- Each section gets a white card look with rounded corners and a subtle shadow.
- Add the component styles below the code you just pasted:
/* Form layout */
#new-post-form {
display: flex;
flex-direction: column;
gap: 10px;
}
#new-post-form input,
#new-post-form textarea {
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
font-family: inherit;
}
#new-post-form textarea {
min-height: 80px;
resize: vertical;
}
#new-post-form button {
padding: 10px 20px;
background-color: #3498db;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
}
#new-post-form button:hover {
background-color: #2980b9;
}
/* Status messages for form submissions */
.message {
margin-top: 10px;
padding: 10px;
border-radius: 4px;
font-size: 0.9rem;
}
.message.success {
background-color: #d4edda;
color: #155724;
}
.message.error {
background-color: #f8d7da;
color: #721c24;
}
/* Utility class to hide elements */
.hidden {
display: none;
}
/* Post cards that appear in the list */
.post-card {
padding: 15px;
border: 1px solid #eee;
border-radius: 6px;
margin-bottom: 10px;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
}
.post-card:hover {
border-color: #3498db;
box-shadow: 0 2px 8px rgba(52, 152, 219, 0.1);
}
.post-card.active {
border-color: #3498db;
background-color: #ebf5fb;
}
.post-card h3 {
color: #2c3e50;
margin-bottom: 5px;
font-size: 1.1rem;
}
.post-card p {
color: #666;
font-size: 0.9rem;
}
/* Comment styling with a left accent border */
.comment {
padding: 12px;
border-left: 3px solid #3498db;
margin-bottom: 10px;
background-color: #f8f9fa;
border-radius: 0 4px 4px 0;
}
.comment .comment-name {
font-weight: bold;
color: #2c3e50;
margin-bottom: 4px;
}
.comment .comment-body {
color: #555;
font-size: 0.9rem;
}
/* Loading state text */
#loading-posts,
#loading-comments {
color: #888;
font-style: italic;
}
What do these component styles do?
- The form styles stack inputs vertically with consistent spacing using flexbox.
- The .post-card class creates clickable cards that highlight on hover and show an active state.
- The .hidden class lets JavaScript show and hide sections by toggling a single class.
- The .comment class uses a blue left border to visually separate each comment.
- Save style.css.
- Open index.html in Chrome by right-clicking the file in Cursor's Explorer sidebar and selecting Reveal in Finder (macOS) or Reveal in File Explorer (Windows), then double-click index.html to open it in Chrome.
- Verify the page shows a dark blue header with "Blog Reader" in white text, a "Create a New Post" form with input fields, a "Latest Posts" section showing "Loading posts...", and no visible comments section.
- Press F12 to open Developer Tools and check the Console tab. You should see no errors.
Page looks unstyled or plain?
Check that the <link rel="stylesheet" href="style.css"> line is inside the <head> section of your HTML. The filename must match exactly, including lowercase.
Make sure style.css is saved in the same folder as index.html. If they are in different folders, the browser cannot find the stylesheet.
Help me figure out why my CSS isn't loading.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the complete index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blog Reader</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>Blog Reader</h1>
<p>Powered by HTTP requests</p>
</header>
<main>
<section id="new-post-section">
<h2>Create a New Post</h2>
<form id="new-post-form">
<input type="text" id="post-title" placeholder="Post title" required>
<textarea id="post-body" placeholder="Write your post..." required></textarea>
<button type="submit">Publish Post</button>
</form>
<p id="form-message" class="message hidden"></p>
</section>
<section id="posts-section">
<h2>Latest Posts</h2>
<div id="posts-container">
<p id="loading-posts">Loading posts...</p>
</div>
</section>
<section id="comments-section" class="hidden">
<h2 id="comments-title">Comments</h2>
<div id="comments-container">
<p id="loading-comments">Loading comments...</p>
</div>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
Here is the complete style.css:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f5f5;
padding: 20px;
max-width: 900px;
margin: 0 auto;
}
header {
text-align: center;
margin-bottom: 30px;
padding: 20px;
background-color: #2c3e50;
color: white;
border-radius: 8px;
}
header h1 {
font-size: 2rem;
margin-bottom: 5px;
}
header p {
opacity: 0.8;
font-size: 0.9rem;
}
section {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
h2 {
margin-bottom: 15px;
color: #2c3e50;
}
#new-post-form {
display: flex;
flex-direction: column;
gap: 10px;
}
#new-post-form input,
#new-post-form textarea {
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
font-family: inherit;
}
#new-post-form textarea {
min-height: 80px;
resize: vertical;
}
#new-post-form button {
padding: 10px 20px;
background-color: #3498db;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
}
#new-post-form button:hover {
background-color: #2980b9;
}
.message {
margin-top: 10px;
padding: 10px;
border-radius: 4px;
font-size: 0.9rem;
}
.message.success {
background-color: #d4edda;
color: #155724;
}
.message.error {
background-color: #f8d7da;
color: #721c24;
}
.hidden {
display: none;
}
.post-card {
padding: 15px;
border: 1px solid #eee;
border-radius: 6px;
margin-bottom: 10px;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
}
.post-card:hover {
border-color: #3498db;
box-shadow: 0 2px 8px rgba(52, 152, 219, 0.1);
}
.post-card.active {
border-color: #3498db;
background-color: #ebf5fb;
}
.post-card h3 {
color: #2c3e50;
margin-bottom: 5px;
font-size: 1.1rem;
}
.post-card p {
color: #666;
font-size: 0.9rem;
}
.comment {
padding: 12px;
border-left: 3px solid #3498db;
margin-bottom: 10px;
background-color: #f8f9fa;
border-radius: 0 4px 4px 0;
}
.comment .comment-name {
font-weight: bold;
color: #2c3e50;
margin-bottom: 4px;
}
.comment .comment-body {
color: #555;
font-size: 0.9rem;
}
#loading-posts,
#loading-comments {
color: #888;
font-style: italic;
}
Your page structure is ready and styled. Next up, you'll bring it to life by writing JavaScript that fetches real blog posts from the internet and fills those empty containers with data.
Fetch and Display Blog Posts
Your project files are ready and your HTML loads in the browser. The page looks nice, but it's completely static.
Every modern web app needs to pull data from a server. In this step, you'll write JavaScript that reaches out to a live REST API, grabs blog post data, and renders it to the page. This is the core skill behind every dynamic website.
In this step, get ready to:
- Write an async function that fetches blog posts from a public API.
- Render fetched data as clickable post cards in the browser.
- Add loading and error states so the page handles slow or failed requests gracefully.
Connect your JavaScript to the HTML elements
Before your JavaScript can update anything on the page, it needs references to the HTML elements it will change. These references let you show loading messages, insert post cards, and display errors.
- Switch back to Cursor and open app.js.
- Add the following DOM references at the top of the file:
const postsContainer = document.getElementById("posts-container");
const loadingPosts = document.getElementById("loading-posts");
const commentsSection = document.getElementById("comments-section");
const commentsContainer = document.getElementById("comments-container");
const commentsTitle = document.getElementById("comments-title");
const loadingComments = document.getElementById("loading-comments");
const newPostForm = document.getElementById("new-post-form");
const postTitleInput = document.getElementById("post-title");
const postBodyInput = document.getElementById("post-body");
const formMessage = document.getElementById("form-message");
What does this code do?
Each line grabs a reference to an HTML element by its id attribute. Storing these in variables means you can update their content, show or hide them, and style them later without searching the page every time.
You're grabbing references for elements you'll use in later steps too (like comments and the form). Setting them all up now keeps the top of your file organized.
- Save app.js.
You won't see a change yet. These references only become useful once you write the functions that use them.
Write the fetchPosts() function
The Fetch API is JavaScript's built-in way to make HTTP requests. It returns a Promise, which means you can use async/await to write code that reads top-to-bottom instead of nesting callbacks.
- Below the DOM references in app.js, add the fetchPosts() function and the call that triggers it on page load:
async function fetchPosts() {
// Show loading message while we wait for data
loadingPosts.textContent = "Loading posts...";
loadingPosts.classList.remove("hidden");
try {
// Make a GET request to the JSONPlaceholder API
const response = await fetch("https://jsonplaceholder.typicode.com/posts");
// Check if the server responded with an error status
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
// Parse the JSON body from the response
const posts = await response.json();
// Display only the first 20 posts for a cleaner UI
displayPosts(posts.slice(0, 20));
} catch (error) {
// Show the error message if something goes wrong
loadingPosts.textContent = `Error loading posts: ${error.message}`;
loadingPosts.style.color = "#721c24";
}
}
fetchPosts();
What does this code do?
- async function fetchPosts() declares an asynchronous function, which lets you use await inside it to pause until a network request finishes.
- fetch("https://jsonplaceholder.typicode.com/posts") sends a GET request to the JSONPlaceholder API. This public API returns 100 fake blog posts with no signup required.
- response.ok is true when the status code is 200-299. The fetch() function only rejects on network errors, not on HTTP errors like 404. You must check response.ok yourself.
- response.json() parses the response body from a JSON string into a JavaScript array of post objects.
- The catch block handles both network failures and the error you throw when response.ok is false.
- fetchPosts() at the bottom calls the function immediately when the script loads, so posts appear as soon as the page opens.
Why check response.ok instead of relying on catch?
The Fetch API only throws an error when the network itself fails (no internet, DNS failure, server unreachable). If the server responds with a 404 or 500, fetch() still resolves successfully. Checking response.ok catches those HTTP-level errors that would otherwise slip through silently.
- Save app.js.
If you refresh the browser now, you'll see a console error because displayPosts doesn't exist yet. That's expected.
Seeing a different error?
If you see a syntax error, check that all backticks in the template literals (the `...` strings) are backticks and not single quotes. They sit on the same key as the tilde (~) on most keyboards.
help me fix a syntax error in my fetchPosts function
Write the displayPosts() function
The API gives you raw data as a JavaScript array. You need a function that turns each post object into visible HTML on the page.
- In app.js, add the displayPosts() function between the fetchPosts() function and the fetchPosts(); call at the bottom:
function displayPosts(posts) {
// Hide the loading message
loadingPosts.classList.add("hidden");
// Clear any existing content
postsContainer.innerHTML = "";
posts.forEach(function (post) {
// Create a card container for each post
const card = document.createElement("div");
card.className = "post-card";
card.dataset.postId = post.id;
// Create the post title
const title = document.createElement("h3");
title.textContent = post.title;
// Create the post body, truncated to 100 characters
const body = document.createElement("p");
body.textContent = post.body.substring(0, 100) + "...";
card.appendChild(title);
card.appendChild(body);
postsContainer.appendChild(card);
});
}
What does this code do?
- loadingPosts.classList.add("hidden") hides the "Loading posts..." message once data arrives.
- posts.forEach() loops through each post in the array. For each one, it creates a div with the class post-card (which your CSS already styles).
- post.body.substring(0, 100) + "..." truncates long post bodies so cards stay compact.
- card.dataset.postId = post.id stores the post's ID on the element. You'll use this in the next step to fetch comments for a specific post.
- Save app.js.
Posts not appearing?
Make sure the displayPosts() function is placed BEFORE the fetchPosts(); call at the bottom of the file. JavaScript reads top to bottom, so fetchPosts() needs to find displayPosts when it runs.
Also confirm you have an internet connection. The fetch() call reaches out to a live server.
help me debug why posts aren't showing up
Test the blog reader in Chrome
Your app now has a loading state, data fetching, and rendering all wired together. Time to see it in action.
- Switch to Google Chrome and refresh the page showing index.html.
- Watch for the "Loading posts..." message that briefly appears before the posts load.
- You should see 20 blog post cards, each showing a title and a truncated body.
What is happening behind the scenes?
- The page loads and runs fetchPosts() immediately.
- The loading message appears while the request is in flight.
- Once data arrives, displayPosts() hides the loading message and creates 20 card elements.
- If the network fails or the server returns an error, the catch block displays a red error message instead.
- To test error handling, open DevTools by pressing F12 (Windows) or Cmd+Option+I (macOS).
- Click the Network tab, then check the Offline checkbox.
- Refresh the page. You should see the error message appear in red instead of the posts.
- Uncheck Offline and refresh again to restore normal behavior.
Don't see the Offline checkbox?
In Chrome DevTools, the Offline checkbox is at the top of the Network tab. If you don't see it, look for a No throttling dropdown. Click it and select Offline from the list.
help me find the offline toggle in Chrome DevTools
✔️ Awesome, I've got everything!
Great. Double check you've saved app.js and your posts are loading in the browser.
ⓧ I'd like to double check the full code
const postsContainer = document.getElementById("posts-container");
const loadingPosts = document.getElementById("loading-posts");
const commentsSection = document.getElementById("comments-section");
const commentsContainer = document.getElementById("comments-container");
const commentsTitle = document.getElementById("comments-title");
const loadingComments = document.getElementById("loading-comments");
const newPostForm = document.getElementById("new-post-form");
const postTitleInput = document.getElementById("post-title");
const postBodyInput = document.getElementById("post-body");
const formMessage = document.getElementById("form-message");
async function fetchPosts() {
loadingPosts.textContent = "Loading posts...";
loadingPosts.classList.remove("hidden");
try {
const response = await fetch("https://jsonplaceholder.typicode.com/posts");
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const posts = await response.json();
displayPosts(posts.slice(0, 20));
} catch (error) {
loadingPosts.textContent = `Error loading posts: ${error.message}`;
loadingPosts.style.color = "#721c24";
}
}
function displayPosts(posts) {
loadingPosts.classList.add("hidden");
postsContainer.innerHTML = "";
posts.forEach(function (post) {
const card = document.createElement("div");
card.className = "post-card";
card.dataset.postId = post.id;
const title = document.createElement("h3");
title.textContent = post.title;
const body = document.createElement("p");
body.textContent = post.body.substring(0, 100) + "...";
card.appendChild(title);
card.appendChild(body);
postsContainer.appendChild(card);
});
}
fetchPosts();
Your blog reader is now pulling live data from the internet and rendering it to the page. Next up, you'll make those cards interactive by loading comments when you click on a post.
Load Comments for a Post
Your blog reader displays posts, but a blog without comments is like a conversation where nobody can reply. Right now, clicking a post card does nothing.
In this step, you will make each post interactive. When the learner clicks a post, your app will fire a second GET request to fetch that post's comments and render them in a dedicated panel below.
In this step, get ready to:
- Add click event listeners to each post card.
- Fetch and display comments for the selected post.
- Highlight the active post and show loading states.
Add click listeners to fetch comments
Each post card needs to respond to clicks. When a user clicks a card, your app should highlight it and fetch that post's comments from the API.
- In app.js, find this line inside the displayPosts function:
card.appendChild(body);
postsContainer.appendChild(card);
- Add the following click event listener between those two lines, so it sits after card.appendChild(body); and before postsContainer.appendChild(card);:
card.addEventListener("click", function () {
// Remove active highlight from all other cards
document.querySelectorAll(".post-card").forEach(function (c) {
c.classList.remove("active");
});
// Highlight this card and fetch its comments
card.classList.add("active");
fetchComments(post.id, post.title);
});
What does this code do?
Each card gets a click listener. When clicked, it loops through every .post-card and removes the active class so only one card is highlighted at a time.
Then it adds active to the clicked card and calls fetchComments with the post's ID and title. The CSS you already have will highlight the active card with a blue border and light background.
You won't see a change yet. The listener calls fetchComments, which does not exist yet. Let's write it now.
- Add the fetchComments function below the closing brace of displayPosts by pasting the following code:
async function fetchComments(postId, postTitle) {
// Show the comments section and set a loading state
commentsSection.classList.remove("hidden");
commentsTitle.textContent = `Comments on: ${postTitle}`;
commentsContainer.innerHTML = "";
loadingComments.textContent = "Loading comments...";
loadingComments.classList.remove("hidden");
try {
// Fetch comments for this specific post
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts/${postId}/comments`
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const comments = await response.json();
displayComments(comments);
} catch (error) {
loadingComments.textContent = `Error loading comments: ${error.message}`;
loadingComments.style.color = "#721c24";
}
}
What does this code do?
- It reveals the hidden comments section and sets the heading to show which post's comments are loading.
- It fetches comments from https://jsonplaceholder.typicode.com/posts/{postId}/comments. This is a nested resource endpoint. The API knows to return only comments belonging to that specific post.
- The error handling pattern is identical to fetchPosts. Check response.ok, parse JSON, and catch any network failures.
You won't see a change yet. The function calls displayComments which does not exist. The next chunk adds it.
Write the displayComments function
Once the API returns comments, you need a function to turn that raw data into visible HTML. This follows the same pattern as displayPosts.
- Add the displayComments function below fetchComments by pasting this code:
function displayComments(comments) {
// Hide the loading message
loadingComments.classList.add("hidden");
commentsContainer.innerHTML = "";
comments.forEach(function (comment) {
// Create a styled container for each comment
const commentDiv = document.createElement("div");
commentDiv.className = "comment";
// Add the commenter's name
const name = document.createElement("p");
name.className = "comment-name";
name.textContent = comment.name;
// Add the comment body text
const body = document.createElement("p");
body.className = "comment-body";
body.textContent = comment.body;
commentDiv.appendChild(name);
commentDiv.appendChild(body);
commentsContainer.appendChild(commentDiv);
});
}
What does this code do?
- It hides the "Loading comments..." message and clears any previous comments from the container.
- For each comment object, it creates a div with the class comment, plus two paragraphs for the commenter's name and their comment body.
- Your CSS already styles .comment with a blue left border and light background, so each comment will appear as a distinct card.
- Save app.js.
- Refresh your browser and click any post card. You should see the card highlight with a blue border, and a "Comments" section appear below with a list of comments.
Don't see comments appearing?
- Open the browser console (press F12 then click the **Console** tab) and look for errors. A red message mentioning displayComments is not defined means the function is missing or misspelled.
- Make sure the click listener code is placed after card.appendChild(body); and before postsContainer.appendChild(card);. If it is outside the forEach loop, clicks will not work.
- Check that fetchComments and displayComments are placed outside the displayPosts function, not nested inside it.
Why aren't my post comments showing up when I click a card?
Test the comments feature
Your app now makes two types of HTTP requests. One loads all posts on page load. The other loads a specific post's comments on demand.
- Click on a post card. Verify the card gets a blue border and light blue background.
- Scroll down and confirm you see a "Comments on:" heading with a list of comments below it.
- Click a different post card. The previously active card should lose its highlight, the new card should become active, and the comments should update.
Why does the API use nested URLs?
The URL /posts/1/comments follows a REST convention. Resources are organized hierarchically. Comments belong to a specific post, so they live "under" that post's URL.
This pattern makes APIs predictable. If you know a post's ID, you can guess where to find its comments, its likes, or any other related data.
✔️ Awesome, I've got everything!
Great. Double check you have saved app.js.
ⓧ I'd like to double check the full code
Here is the complete app.js file at the end of this step.
const postsContainer = document.getElementById("posts-container");
const loadingPosts = document.getElementById("loading-posts");
const commentsSection = document.getElementById("comments-section");
const commentsContainer = document.getElementById("comments-container");
const commentsTitle = document.getElementById("comments-title");
const loadingComments = document.getElementById("loading-comments");
const newPostForm = document.getElementById("new-post-form");
const postTitleInput = document.getElementById("post-title");
const postBodyInput = document.getElementById("post-body");
const formMessage = document.getElementById("form-message");
async function fetchPosts() {
loadingPosts.textContent = "Loading posts...";
loadingPosts.classList.remove("hidden");
try {
const response = await fetch("https://jsonplaceholder.typicode.com/posts");
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const posts = await response.json();
displayPosts(posts.slice(0, 20));
} catch (error) {
loadingPosts.textContent = `Error loading posts: ${error.message}`;
loadingPosts.style.color = "#721c24";
}
}
function displayPosts(posts) {
loadingPosts.classList.add("hidden");
postsContainer.innerHTML = "";
posts.forEach(function (post) {
const card = document.createElement("div");
card.className = "post-card";
card.dataset.postId = post.id;
const title = document.createElement("h3");
title.textContent = post.title;
const body = document.createElement("p");
body.textContent = post.body.substring(0, 100) + "...";
card.appendChild(title);
card.appendChild(body);
card.addEventListener("click", function () {
document.querySelectorAll(".post-card").forEach(function (c) {
c.classList.remove("active");
});
card.classList.add("active");
fetchComments(post.id, post.title);
});
postsContainer.appendChild(card);
});
}
async function fetchComments(postId, postTitle) {
commentsSection.classList.remove("hidden");
commentsTitle.textContent = `Comments on: ${postTitle}`;
commentsContainer.innerHTML = "";
loadingComments.textContent = "Loading comments...";
loadingComments.classList.remove("hidden");
try {
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts/${postId}/comments`
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const comments = await response.json();
displayComments(comments);
} catch (error) {
loadingComments.textContent = `Error loading comments: ${error.message}`;
loadingComments.style.color = "#721c24";
}
}
function displayComments(comments) {
loadingComments.classList.add("hidden");
commentsContainer.innerHTML = "";
comments.forEach(function (comment) {
const commentDiv = document.createElement("div");
commentDiv.className = "comment";
const name = document.createElement("p");
name.className = "comment-name";
name.textContent = comment.name;
const body = document.createElement("p");
body.className = "comment-body";
body.textContent = comment.body;
commentDiv.appendChild(name);
commentDiv.appendChild(body);
commentsContainer.appendChild(commentDiv);
});
}
fetchPosts();
Your blog reader now responds to user interaction. Each click triggers a fresh GET request and renders the result. Next up, you will write your first POST request to send new data to the server.
Create a New Post with a POST Request
Your blog reader can now fetch posts and load comments. Both of those actions use GET requests, which pull data from a server.
In this step, you will send data to the server using a POST request. You will wire up the "Create a New Post" form so that submitting it sends your content to the API and displays the new post in the list.
In this step, get ready to:
- Write a function that sends a POST request with JSON data.
- Handle the form submission and display the new post.
- Add success and error messages for user feedback.
Write the createPost function
A GET request asks for data. A POST request sends new data to the server in a request body.
Your createPost function will package the form data as JSON, tell the server what format to expect, and return the response.
- In app.js, find the fetchPosts(); call at the very bottom of the file.
- Add the following function directly above that fetchPosts(); call:
async function createPost(title, body) {
try {
const response = await fetch("https://jsonplaceholder.typicode.com/posts", {
method: "POST",
body: JSON.stringify({
title: title,
body: body,
userId: 1,
}),
headers: {
"Content-type": "application/json; charset=UTF-8",
},
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const newPost = await response.json();
return newPost;
} catch (error) {
throw error;
}
}
What does this code do?
- fetch() now receives a second argument: an options object. The method: "POST" property tells the server you are sending data, not requesting it.
- JSON.stringify() converts the JavaScript object into a JSON string for the request body.
- The Content-type header tells the server that the body is JSON so it knows how to parse it.
- The function checks response.ok and re-throws any errors so the caller can handle them.
Why does JSONPlaceholder return id 101?
JSONPlaceholder already has 100 posts in its dataset. When you create a new one, it "fakes" the response by returning the next available ID (101). The data is not actually saved on the server, but the response looks exactly like a real creation would.
You cannot test this function alone yet. The next chunk wires it to the form so you can trigger it from the browser.
Add the form submission handler
The HTML form and DOM references are already in place from earlier steps. Now you need an event listener that intercepts the form submission, calls createPost, and updates the page with the result.
- In app.js, add the following code directly below the createPost function you just wrote (and still above the fetchPosts(); call at the bottom):
newPostForm.addEventListener("submit", async function (event) {
event.preventDefault();
const title = postTitleInput.value.trim();
const body = postBodyInput.value.trim();
if (!title || !body) {
return;
}
formMessage.classList.remove("hidden", "success", "error");
try {
const newPost = await createPost(title, body);
formMessage.textContent = `Post created successfully! (ID: ${newPost.id})`;
formMessage.classList.add("success");
const card = document.createElement("div");
card.className = "post-card";
card.dataset.postId = newPost.id;
const cardTitle = document.createElement("h3");
cardTitle.textContent = newPost.title;
const cardBody = document.createElement("p");
cardBody.textContent = newPost.body.substring(0, 100) + "...";
card.appendChild(cardTitle);
card.appendChild(cardBody);
card.addEventListener("click", function () {
document.querySelectorAll(".post-card").forEach(function (c) {
c.classList.remove("active");
});
card.classList.add("active");
fetchComments(newPost.id, newPost.title);
});
postsContainer.insertBefore(card, postsContainer.firstChild);
postTitleInput.value = "";
postBodyInput.value = "";
} catch (error) {
formMessage.textContent = `Error creating post: ${error.message}`;
formMessage.classList.add("error");
}
});
What does this code do?
- event.preventDefault() stops the browser from refreshing the page when the form submits. Without this, your app would reload and lose all its state.
- The function trims the input values and returns early if either field is empty.
- On success, it shows a green confirmation message, builds a new post card, and prepends it to the top of the list using insertBefore.
- The new card gets a click listener so it works just like the other posts (clicking it loads comments).
- On failure, a red error message appears below the form.
- Save app.js.
- Switch back to Chrome and refresh the page.
- In the "Create a New Post" form, type a title (e.g. My First Post) and a body (e.g. Hello from my blog reader!).
- Click Publish Post. You should see a green success message ("Post created successfully! (ID: 101)") and your new post card at the top of the list.
Post not appearing or seeing an error?
- Open Developer Tools with F12 and check the Console tab for errors. A typo in the URL or headers will cause a failed request.
- Make sure your createPost function is placed above the event listener. JavaScript reads top to bottom, so the function must exist before the listener calls it.
- Confirm the Content-type header value is exactly application/json; charset=UTF-8 with no extra spaces or typos.
Why isn't my POST request working?
Test the full form workflow
- Click on your newly created post card at the top of the list.
- The comments section should appear showing "Loading comments..." followed by the results. Because JSONPlaceholder fakes the creation, the comments response may be empty for post ID 101, which is expected.
- Try submitting the form with one or both fields empty. Nothing should happen (no error, no submission).
What is the difference between GET and POST?
GET retrieves data from a server without changing anything. POST sends new data for the server to store or process. These are two of the most common HTTP methods you will use as a developer.
A POST request includes a body (the data you are sending) and headers that describe the format. A GET request has neither.
✔️ Awesome, I've got everything!
Great. Double check you have saved app.js and that submitting the form creates a new card at the top of the post list.
ⓧ I'd like to double check the full code
Here is the complete app.js file at this point. Compare it with yours to find any differences.
const postsContainer = document.getElementById("posts-container");
const loadingPosts = document.getElementById("loading-posts");
const commentsSection = document.getElementById("comments-section");
const commentsContainer = document.getElementById("comments-container");
const commentsTitle = document.getElementById("comments-title");
const loadingComments = document.getElementById("loading-comments");
const newPostForm = document.getElementById("new-post-form");
const postTitleInput = document.getElementById("post-title");
const postBodyInput = document.getElementById("post-body");
const formMessage = document.getElementById("form-message");
async function fetchPosts() {
loadingPosts.textContent = "Loading posts...";
loadingPosts.classList.remove("hidden");
try {
const response = await fetch("https://jsonplaceholder.typicode.com/posts");
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const posts = await response.json();
displayPosts(posts.slice(0, 20));
} catch (error) {
loadingPosts.textContent = `Error loading posts: ${error.message}`;
loadingPosts.style.color = "#721c24";
}
}
function displayPosts(posts) {
loadingPosts.classList.add("hidden");
postsContainer.innerHTML = "";
posts.forEach(function (post) {
const card = document.createElement("div");
card.className = "post-card";
card.dataset.postId = post.id;
const title = document.createElement("h3");
title.textContent = post.title;
const body = document.createElement("p");
body.textContent = post.body.substring(0, 100) + "...";
card.appendChild(title);
card.appendChild(body);
card.addEventListener("click", function () {
document.querySelectorAll(".post-card").forEach(function (c) {
c.classList.remove("active");
});
card.classList.add("active");
fetchComments(post.id, post.title);
});
postsContainer.appendChild(card);
});
}
async function fetchComments(postId, postTitle) {
commentsSection.classList.remove("hidden");
commentsTitle.textContent = `Comments on: ${postTitle}`;
commentsContainer.innerHTML = "";
loadingComments.textContent = "Loading comments...";
loadingComments.classList.remove("hidden");
try {
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts/${postId}/comments`
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const comments = await response.json();
displayComments(comments);
} catch (error) {
loadingComments.textContent = `Error loading comments: ${error.message}`;
loadingComments.style.color = "#721c24";
}
}
function displayComments(comments) {
loadingComments.classList.add("hidden");
commentsContainer.innerHTML = "";
comments.forEach(function (comment) {
const commentDiv = document.createElement("div");
commentDiv.className = "comment";
const name = document.createElement("p");
name.className = "comment-name";
name.textContent = comment.name;
const body = document.createElement("p");
body.className = "comment-body";
body.textContent = comment.body;
commentDiv.appendChild(name);
commentDiv.appendChild(body);
commentsContainer.appendChild(commentDiv);
});
}
async function createPost(title, body) {
try {
const response = await fetch("https://jsonplaceholder.typicode.com/posts", {
method: "POST",
body: JSON.stringify({
title: title,
body: body,
userId: 1,
}),
headers: {
"Content-type": "application/json; charset=UTF-8",
},
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const newPost = await response.json();
return newPost;
} catch (error) {
throw error;
}
}
newPostForm.addEventListener("submit", async function (event) {
event.preventDefault();
const title = postTitleInput.value.trim();
const body = postBodyInput.value.trim();
if (!title || !body) {
return;
}
formMessage.classList.remove("hidden", "success", "error");
try {
const newPost = await createPost(title, body);
formMessage.textContent = `Post created successfully! (ID: ${newPost.id})`;
formMessage.classList.add("success");
const card = document.createElement("div");
card.className = "post-card";
card.dataset.postId = newPost.id;
const cardTitle = document.createElement("h3");
cardTitle.textContent = newPost.title;
const cardBody = document.createElement("p");
cardBody.textContent = newPost.body.substring(0, 100) + "...";
card.appendChild(cardTitle);
card.appendChild(cardBody);
card.addEventListener("click", function () {
document.querySelectorAll(".post-card").forEach(function (c) {
c.classList.remove("active");
});
card.classList.add("active");
fetchComments(newPost.id, newPost.title);
});
postsContainer.insertBefore(card, postsContainer.firstChild);
postTitleInput.value = "";
postBodyInput.value = "";
} catch (error) {
formMessage.textContent = `Error creating post: ${error.message}`;
formMessage.classList.add("error");
}
});
fetchPosts();
Your blog reader is now fully functional. You can fetch posts, load comments, and create new content. Next up, take on the Secret Mission to add a user profile sidebar that fetches author details from yet another endpoint.
Secret mission
User Profile Sidebar
Your blog reader fetches posts and comments, but who wrote them? In this secret mission, add a user profile sidebar that fetches and displays author details when you click their name. This introduces a third API endpoint, data caching, and linking data across related resources.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
This project runs entirely locally, so there are no ongoing costs. The JSONPlaceholder API is free and requires no account.
Resources you used:
- The blog-reader project folder on your Desktop (contains index.html, style.css, and app.js).
Keep everything running
No action needed. Choose this if you are still actively building or want to revisit the project later.
- Your blog-reader folder stays on your Desktop, ready to open anytime.
- Cursor and Google Chrome remain installed for future projects.
Pause - I'll come back to this later
Since this project has no running processes or cloud resources, pausing looks the same as keeping everything. Your files sit quietly on disk until you return.
- Close any open browser tabs showing your blog reader.
- Close the blog-reader project in Cursor.
- Your files remain on your Desktop. Open the folder in Cursor whenever you want to continue.
Delete - I don't want to use this again
Remove all project files from your machine. This cannot be undone.
- Close the blog-reader project in Cursor if it is still open.
- Delete the project folder by running this command in your terminal:
macOS
rm -rf ~/Desktop/blog-reader
Windows
Remove-Item -Recurse -Force ~\Desktop\blog-reader
- Confirm the folder is gone by running ls ~/Desktop (macOS) or ls ~\Desktop (Windows). You should no longer see blog-reader in the output.
Nice Work!
Nice Work!
Nice work! You just built a fully functional blog reader powered entirely by HTTP requests.
You've learned how to:
- Made GET and POST requests to a live REST API using the JavaScript Fetch API with async/await.
- Parsed JSON responses and dynamically rendered blog posts, comments, and new content to the DOM.
- Added loading states and error handling that check response.ok and display meaningful feedback when requests fail.
- Built a user profile sidebar that fetches author data from a separate endpoint and caches results.
Ready to quiz yourself?