Fetch and APIs
What you'll build
By the end of this lesson you will have a page that fetches a list of GitHub repositories for any username and renders them as cards, with a loading spinner while the request is in flight, an error message if something goes wrong, and clean results when it succeeds.
Concepts
What an API is
An API (Application Programming Interface) is a URL you can hit to get or send structured data, usually JSON. Public APIs respond to HTTP requests just like web pages do, but instead of sending HTML the server sends JSON.
Request: GET https://api.github.com/users/torvalds/repos
Response: [
{ "name": "linux", "stargazers_count": 182000, ... },
{ "name": "subsurface", "stargazers_count": 2000, ... },
...
]
The browser's fetch() function is how you make these requests from JavaScript. It returns a Promise, an object that represents a value that is not available yet.
Promises and async/await
A Promise has three states: pending, fulfilled, or rejected. You handle the result with .then() / .catch(), or with the cleaner async/await syntax.
// Promise chain style
fetch("https://api.github.com/users/torvalds")
.then((response) => response.json())
.then((data) => console.log(data.name))
.catch((error) => console.error("Failed:", error));
// async/await style (same thing, easier to read)
async function loadUser() {
try {
const response = await fetch("https://api.github.com/users/torvalds");
const data = await response.json();
console.log(data.name);
} catch (error) {
console.error("Failed:", error);
}
}
loadUser();
async/await is syntactic sugar over Promises. Under the hood it is the same thing, await just pauses execution inside the async function until the Promise resolves, then continues.
JSON
JSON (JavaScript Object Notation) is a text format for structured data. It looks like JavaScript object and array literals with a few restrictions: keys must be quoted strings, values can be strings, numbers, booleans, null, arrays, or objects.
// Parse JSON text into a JavaScript value
const jsonText = '{"name": "Priya", "age": 22, "skills": ["JS", "Python"]}';
const obj = JSON.parse(jsonText);
console.log(obj.name); // "Priya"
console.log(obj.skills[0]); // "JS"
// Serialize a JavaScript value into JSON text
const person = { name: "Rahul", city: "Mumbai" };
const json = JSON.stringify(person);
console.log(json); // '{"name":"Rahul","city":"Mumbai"}'
// Pretty-print with indentation (useful for debugging)
console.log(JSON.stringify(person, null, 2));
When you call response.json() after a fetch, it is calling JSON.parse() on the response body for you.
Error handling
fetch() only rejects (throws an error) for network failures, a server returning 404 or 500 is still a "successful" fetch from the browser's perspective. You have to check response.ok yourself.
async function fetchData(url) {
let response;
try {
response = await fetch(url);
} catch (networkError) {
// DNS failure, no internet, request blocked, etc.
throw new Error("Network error: could not reach the server.");
}
if (!response.ok) {
// Server replied but with an error status (4xx, 5xx)
throw new Error(`Server error: ${response.status} ${response.statusText}`);
}
return response.json();
}
// Usage
async function run() {
try {
const data = await fetchData("https://api.github.com/users/octocat");
console.log(data);
} catch (error) {
console.error(error.message);
}
}
Rendering remote data into the DOM
Once you have the data, you build DOM elements the same way you did in the previous lesson, createElement, textContent, appendChild, but now the content comes from the API response.
function renderCard(repo) {
const article = document.createElement("article");
article.innerHTML = `
<h3><a href="${repo.html_url}" target="_blank" rel="noopener">${repo.name}</a></h3>
<p>${repo.description || "No description"}</p>
<p>Stars: ${repo.stargazers_count}</p>
`;
return article;
}
async function loadRepos(username) {
const container = document.querySelector("#repos");
container.innerHTML = "<p>Loading...</p>";
try {
const repos = await fetchData(`https://api.github.com/users/${username}/repos?sort=stars`);
container.innerHTML = "";
repos.slice(0, 6).forEach((repo) => {
container.appendChild(renderCard(repo));
});
} catch (error) {
container.innerHTML = `<p style="color:red;">Error: ${error.message}</p>`;
}
}
Hands-on
Build the GitHub repo finder. Create github.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GitHub Repo Finder</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 40px auto; padding: 0 16px; }
.search-bar { display: flex; gap: 8px; margin-bottom: 24px; }
.search-bar input { flex: 1; padding: 10px; font-size: 1rem; border: 1px solid #ccc; border-radius: 4px; }
.search-bar button { padding: 10px 20px; background: #0969da; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; }
.search-bar button:disabled { background: #8ab4d8; cursor: not-allowed; }
#repos { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }
.repo-card { border: 1px solid #d0d7de; border-radius: 8px; padding: 16px; }
.repo-card h3 { margin: 0 0 8px; font-size: 1rem; }
.repo-card h3 a { color: #0969da; text-decoration: none; }
.repo-card p { margin: 0 0 8px; font-size: 0.875rem; color: #555; }
.repo-card .stars { font-size: 0.8rem; color: #888; }
#status { margin-bottom: 16px; }
</style>
</head>
<body>
<h1>GitHub Repo Finder</h1>
<div class="search-bar">
<input type="text" id="username" placeholder="Enter GitHub username" value="torvalds">
<button id="search-btn">Search</button>
</div>
<p id="status"></p>
<div id="repos"></div>
<script>
const usernameInput = document.querySelector("#username");
const searchBtn = document.querySelector("#search-btn");
const reposContainer = document.querySelector("#repos");
const statusEl = document.querySelector("#status");
async function fetchData(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${response.status}: ${response.statusText}`);
}
return response.json();
}
function renderCard(repo) {
const card = document.createElement("article");
card.className = "repo-card";
card.innerHTML = `
<h3><a href="${repo.html_url}" target="_blank" rel="noopener noreferrer">${repo.name}</a></h3>
<p>${repo.description || "No description provided."}</p>
<p class="stars">★ ${repo.stargazers_count.toLocaleString()} stars</p>
`;
return card;
}
async function loadRepos() {
const username = usernameInput.value.trim();
if (!username) return;
// Show loading state
searchBtn.disabled = true;
searchBtn.textContent = "Loading...";
reposContainer.innerHTML = "";
statusEl.textContent = "";
try {
const repos = await fetchData(
`https://api.github.com/users/${username}/repos?sort=stars&per_page=12`
);
if (repos.length === 0) {
statusEl.textContent = "This user has no public repositories.";
return;
}
statusEl.textContent = `Showing ${Math.min(repos.length, 12)} of ${repos.length} repositories for ${username}`;
repos.forEach((repo) => {
reposContainer.appendChild(renderCard(repo));
});
} catch (error) {
statusEl.style.color = "red";
statusEl.textContent = `Error: ${error.message}`;
} finally {
// Always restore the button, even if an error occurred
searchBtn.disabled = false;
searchBtn.textContent = "Search";
}
}
searchBtn.addEventListener("click", loadRepos);
// Allow pressing Enter in the input
usernameInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") loadRepos();
});
// Load defaults on page load
loadRepos();
</script>
</body>
</html>
Open this page. It immediately fetches Linus Torvalds' repos. Type a different username and press Search or Enter. Try a username that does not exist (xyznotexist123), the 404 error is caught and shown in red without any crash.
Watch the Network tab in DevTools while you search. You will see the request to api.github.com, its status, and its response body. If you look at the response headers, you will also see X-RateLimit-Remaining, GitHub's unauthenticated API allows 60 requests per hour per IP.
The finally block runs whether the request succeeded or failed. It is the right place to clean up loading states, resetting the button, hiding a spinner, enabling a form again.
Common pitfalls
- Not checking
response.ok. A 404 or 500 from the server does not reject the Promise.fetch()only rejects on network failure. Always checkresponse.okorresponse.statusbefore calling.json(). - Calling
.json()twice. Response bodies can only be read once. If you callresponse.json()and thenresponse.text(), the second call will fail. Parse once and store the result. - Forgetting
await. Writingconst data = response.json()gives you a Promise object, not the parsed data. Everyasyncfunction call that returns a Promise needsawait. - Storing API keys in client-side JavaScript. Anything in your HTML or JS files is visible to anyone who views source. Never put secret API keys in front-end code. Use a backend server to make authenticated API calls and proxy the results.
- Not handling loading state. If you do not disable the button while a request is in flight, a user can click it multiple times and fire several concurrent requests. Disable the button (or show a spinner) until the request completes.
What to try next
- Add a toggle to sort repos by name alphabetically instead of by stars. Store the current sort order in a variable and re-render the existing repos without making a new fetch call.
- Fetch user profile information (
https://api.github.com/users/{username}) alongside the repos and display the user's avatar, bio, and follower count at the top of the page. UsePromise.all([fetchRepos, fetchProfile])to fire both requests simultaneously. - Replace the
statusEl.textContenterror message with a styled error card. Make it dismissable with a close button that setsdisplay: noneon the card.
Prefer watching over reading?
Subscribe for free.