Build a GitHub Profile Explorer
Build a React app that fetches and displays GitHub profiles via REST API.
Introduction
30 Second Summary
Every developer has a GitHub profile, but exploring someone's coding activity means clicking through dozens of pages and tabs. What if you could type any username and instantly see their highlights in one place?
In this project, you will build a GitHub Profile Explorer using React. It fetches live data from the GitHub API and displays user profiles, top repositories, and language statistics in one polished dark-themed interface.
What You'll Build
A single-page React application that consumes the GitHub REST API to display any user's profile card, their top starred repositories in a responsive grid, and an aggregated language breakdown visualized as a colored bar chart.
By the end of this project, you'll have:
- A React app scaffolded with Vite that fetches and displays any GitHub user's profile, top repositories, and language stats.
- Real-world API integration patterns including loading states, error handling, and parallel data fetching with Promise.all.
- A polished dark-themed UI deployed live on Vercel with a shareable URL.
- Secret Mission: a search history feature that remembers previously searched usernames using localStorage.
Are there any prerequisites?
This project assumes you have basic HTML, CSS, and JavaScript knowledge. Familiarity with React fundamentals (components, props, and JSX) will help you move through the steps faster.
Set Up the Project
To build our GitHub Profile Explorer, we need a React development environment. React apps need a build tool to transform JSX into regular JavaScript that browsers understand.
We will use Vite to scaffold our React project. Vite is a modern build tool that gives you instant hot-reloading and fast builds out of the box.
In this step, get ready to:
- Scaffold a React project with Vite.
- Clean up the default scaffold files.
- Create an organized folder structure for components and styles.
Scaffold a new React project with Vite
Vite's scaffold command creates a ready-to-go React project with all the configuration handled for you. You just need to run one command and Vite sets up the file structure, installs React, and configures the build tool.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your React project by running this command:
npm create vite@latest github-explorer -- --template react
What does this command do?
- npm create vite@latest runs the latest version of Vite's project creator.
- github-explorer is the name of your project folder.
- -- --template react tells Vite to use the React template so you skip any interactive prompts.
- Move into your new project folder and install dependencies by running these commands:
cd github-explorer
npm install
- Wait for the install to finish. You should see a message confirming packages were added, with no errors.
Seeing an error during install?
- If you see a permission error, try running the scaffold command with sudo on macOS/Linux, or open your terminal as Administrator on Windows.
- If you see a network error, check your internet connection and try again.
- Vite 8 requires Node.js version 20.19+ or 22.12+. Run node --version to confirm your version meets this requirement.
If you're stuck, help me troubleshoot my npm install error.
Clean the default scaffold files
The Vite scaffold comes with demo content and logos we do not need. Let's strip it down to a blank slate so we can build our own app from scratch.
- Delete the default CSS and logo files by running this command:
rm src/App.css src/index.css src/assets/react.svg
- Confirm the files are gone by running:
ls src/
- You should see App.jsx, main.jsx, and the assets folder (now empty). The CSS files and React logo should be gone.
- Open src/App.jsx in VS Code's Explorer sidebar (click the top icon on the left).
- Select all the existing content and replace it with this code:
function App() {
return <div><h1>GitHub Profile Explorer</h1></div>;
}
export default App;
What does this code do?
This is the simplest possible React component. It renders a heading so we can confirm React is working before we build out the real UI.
- Save src/App.jsx.
- Now open src/main.jsx in the Explorer sidebar.
- Select all the existing content and replace it with this code:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>
);
What changed here?
The scaffold's main.jsx imported index.css which we just deleted. Removing that import prevents a crash when we start the dev server.
- Save src/main.jsx.
- Now open index.html in the Explorer sidebar (it is in the project root, not inside src/).
- Find this line:
<title>github-explorer</title>
- Replace github-explorer with GitHub Profile Explorer, so the line looks like this:
<title>GitHub Profile Explorer</title>
- Save index.html.
- Start the development server by running this command in your terminal:
npm run dev
- Open your browser and go to http://localhost:5173.
- You should see a plain white page with the heading "GitHub Profile Explorer" in black text.
Don't see the heading?
- Make sure you saved all three files (App.jsx, main.jsx, index.html).
- Check the terminal for error messages. A red error about a missing file means the index.css import is still in main.jsx.
- If the page is completely blank with no heading, open your browser's DevTools console (F12) and look for error messages.
If you're stuck, help me fix my Vite dev server.
Create the component and styles folders
Organizing your code into folders from the start keeps things manageable as your app grows. We will put React components in one folder and CSS files in another.
- Create the folders and an empty CSS file by running these commands in your terminal:
mkdir src/components src/styles
touch src/styles/App.css
What are these folders for?
- src/components/ will hold each React component (SearchBar, ProfileCard, RepoList, and more).
- src/styles/ will hold your CSS files. The empty App.css is where you will add all your styles later.
- Confirm the folders exist by running:
ls src/
- You should see App.jsx, assets, components, main.jsx, and styles listed.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files.
ⓧ I'd like to double check the full code
Here is what each edited file should look like at this point.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GitHub Profile Explorer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>
);
function App() {
return <div><h1>GitHub Profile Explorer</h1></div>;
}
export default App;
The file src/styles/App.css should exist but be empty. The folders src/components/ and src/styles/ should both exist.
Your project skeleton is ready. Next up, you will build the search bar and profile card components that fetch real data from the GitHub API.
Build the Search Bar and Profile Card
Your project scaffold is ready and the dev server is running. Now it is time to build the two core UI pieces that make your app interactive: a search bar for typing GitHub usernames and a profile card for displaying results.
You will create each piece as its own React component, then wire them together in App.jsx with real GitHub REST API calls. By the end of this step, you will type a username, hit search, and see a real GitHub profile appear on screen.
In this step, get ready to:
- Build a SearchBar component with a controlled input and submit handler.
- Build a ProfileCard component that displays GitHub user data as props.
- Wire them together in App.jsx with live API fetching, loading state, and error handling.
Create the SearchBar component
A controlled input in React is one where the component's state is the single source of truth for what the user has typed. This lets you validate, trim, or transform input before sending it anywhere.
- In VS Code, create a new file called SearchBar.jsx inside the src/components/ folder.
- Add the following code to SearchBar.jsx:
import { useState } from 'react';
function SearchBar({ onSearch }) {
// Track what the user types in the input field
const [input, setInput] = useState('');
// Handle form submission
const handleSubmit = (e) => {
e.preventDefault();
if (input.trim()) {
onSearch(input);
}
};
return (
<form className="search-bar" onSubmit={handleSubmit}>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Enter a GitHub username..."
className="search-input"
/>
<button type="submit" className="search-button">
Search
</button>
</form>
);
}
export default SearchBar;
What does this code do?
useState creates a state variable called input that always reflects what the user has typed. Every keystroke updates state through the onChange handler.
When the form submits, handleSubmit prevents the page from reloading (e.preventDefault()), checks that the input is not empty, and passes the typed username up to the parent via the onSearch callback prop.
- Save src/components/SearchBar.jsx.
- You will not see a change in the browser yet because App.jsx does not import this component yet. The next substep creates ProfileCard, then you will wire everything together.
Seeing a red squiggly in VS Code?
If VS Code shows an error on the import line, make sure the file is saved inside src/components/ and not in the project root. The file extension must be .jsx, not .js.
help me fix an import error in my SearchBar component
Create the ProfileCard component
The ProfileCard receives a user object as a prop and renders the fields that matter most: avatar, name, bio, location, and follower stats. Using props means this component stays simple and reusable.
- Create a new file called ProfileCard.jsx inside src/components/.
- Add the following code to ProfileCard.jsx:
function ProfileCard({ user }) {
return (
<div className="profile-card">
{/* Display the user's GitHub avatar */}
<img src={user.avatar_url} alt={user.login} className="profile-avatar" />
<div className="profile-info">
{/* Show the display name, or fall back to the login handle */}
<h2 className="profile-name">{user.name || user.login}</h2>
<p className="profile-login">@{user.login}</p>
{/* Conditionally render bio and location only if they exist */}
{user.bio && <p className="profile-bio">{user.bio}</p>}
{user.location && <p className="profile-location">{user.location}</p>}
<div className="profile-stats">
<span className="stat"><strong>{user.followers}</strong> followers</span>
<span className="stat"><strong>{user.following}</strong> following</span>
<span className="stat"><strong>{user.public_repos}</strong> repos</span>
</div>
</div>
</div>
);
}
export default ProfileCard;
What does this code do?
The component destructures a user prop, which will be the JSON object returned by the GitHub API. Fields like avatar_url, login, and public_repos come directly from that response.
The && pattern is conditional rendering. If user.bio is null or empty, React skips rendering that paragraph entirely. This prevents blank lines for users who have not filled in their bio or location.
- Save src/components/ProfileCard.jsx.
- You will not see a change in the browser yet. The next substep imports both components into App.jsx and connects them to a live API call.
Wire the components together with API fetching
Now for the exciting part. You will rewrite App.jsx to import both components, call the GitHub API when a username is submitted, and handle loading and error states.
- Open src/App.jsx in VS Code.
- Select all the existing code and replace it with the following:
import { useState, useEffect } from 'react';
import SearchBar from './components/SearchBar.jsx';
import ProfileCard from './components/ProfileCard.jsx';
function App() {
// State for the searched username, fetched user data, loading flag, and error messages
const [username, setUsername] = useState('');
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// Fetch user data whenever the username state changes
useEffect(() => {
if (!username) return;
const fetchData = async () => {
setLoading(true);
setError('');
setUser(null);
try {
const userRes = await fetch(`https://api.github.com/users/${username}`);
if (!userRes.ok) {
if (userRes.status === 404) {
setError('User not found. Check the username and try again.');
} else if (userRes.status === 403) {
setError('API rate limit exceeded. Please wait a minute and try again.');
} else {
setError('Something went wrong. Please try again.');
}
setLoading(false);
return;
}
const userData = await userRes.json();
setUser(userData);
} catch (err) {
setError('Network error. Please check your connection.');
}
setLoading(false);
};
fetchData();
}, [username]);
// Callback passed to SearchBar — trims whitespace and triggers the effect
const handleSearch = (searchedUsername) => {
setUsername(searchedUsername.trim());
};
return (
<div className="app">
<header className="app-header">
<h1>GitHub Profile Explorer</h1>
</header>
<main className="app-main">
<SearchBar onSearch={handleSearch} />
{loading && <p className="loading">Loading...</p>}
{error && <p className="error">{error}</p>}
{user && <ProfileCard user={user} />}
</main>
</div>
);
}
export default App;
What does this code do?
- useState creates four pieces of state: the username to fetch, the user data object, a loading boolean, and an error message string.
- useEffect watches the username variable. Every time it changes, the effect fires and calls the GitHub API at https://api.github.com/users/{username}.
- If the API returns a 404, the app shows a friendly "User not found" message. A 403 means you have hit the rate limit (60 requests per hour for unauthenticated calls).
- The JSX conditionally renders Loading... text, an error message, or the ProfileCard depending on which state is active.
- Save src/App.jsx.
- Switch to your browser at http://localhost:5173. You should see the heading "GitHub Profile Explorer" with a text input and a green **Search** button.
- Type octocat into the search field and click **Search**.
- You should see the Octocat avatar, the name, and follower stats appear below the search bar.
Not seeing any profile data?
Check the browser console (press F12 then click the **Console** tab) for error messages. A common issue is a typo in the fetch URL template literal. Make sure you are using backticks (`) not single quotes around the URL.
If you see a 403 error in the console, you have exceeded the GitHub API rate limit of 60 requests per hour. Wait a minute and try again.
help me debug why my GitHub profile fetch is not working
What is a rate limit?
The GitHub API allows 60 requests per hour when you are not using an authentication token. Each search uses one request, so you can search about 60 different usernames before hitting the limit. For a learning project this is more than enough.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each file at this point:
import { useState } from 'react';
function SearchBar({ onSearch }) {
const [input, setInput] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (input.trim()) {
onSearch(input);
}
};
return (
<form className="search-bar" onSubmit={handleSubmit}>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Enter a GitHub username..."
className="search-input"
/>
<button type="submit" className="search-button">
Search
</button>
</form>
);
}
export default SearchBar;
function ProfileCard({ user }) {
return (
<div className="profile-card">
<img src={user.avatar_url} alt={user.login} className="profile-avatar" />
<div className="profile-info">
<h2 className="profile-name">{user.name || user.login}</h2>
<p className="profile-login">@{user.login}</p>
{user.bio && <p className="profile-bio">{user.bio}</p>}
{user.location && <p className="profile-location">{user.location}</p>}
<div className="profile-stats">
<span className="stat"><strong>{user.followers}</strong> followers</span>
<span className="stat"><strong>{user.following}</strong> following</span>
<span className="stat"><strong>{user.public_repos}</strong> repos</span>
</div>
</div>
</div>
);
}
export default ProfileCard;
import { useState, useEffect } from 'react';
import SearchBar from './components/SearchBar.jsx';
import ProfileCard from './components/ProfileCard.jsx';
function App() {
const [username, setUsername] = useState('');
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (!username) return;
const fetchData = async () => {
setLoading(true);
setError('');
setUser(null);
try {
const userRes = await fetch(`https://api.github.com/users/${username}`);
if (!userRes.ok) {
if (userRes.status === 404) {
setError('User not found. Check the username and try again.');
} else if (userRes.status === 403) {
setError('API rate limit exceeded. Please wait a minute and try again.');
} else {
setError('Something went wrong. Please try again.');
}
setLoading(false);
return;
}
const userData = await userRes.json();
setUser(userData);
} catch (err) {
setError('Network error. Please check your connection.');
}
setLoading(false);
};
fetchData();
}, [username]);
const handleSearch = (searchedUsername) => {
setUsername(searchedUsername.trim());
};
return (
<div className="app">
<header className="app-header">
<h1>GitHub Profile Explorer</h1>
</header>
<main className="app-main">
<SearchBar onSearch={handleSearch} />
{loading && <p className="loading">Loading...</p>}
{error && <p className="error">{error}</p>}
{user && <ProfileCard user={user} />}
</main>
</div>
);
}
export default App;
Your search bar and profile card are live and pulling real data from GitHub. Next up, you will fetch the user's top repositories and display them in a responsive grid below the profile card.
Display Top Repositories
Your app can now fetch and display a user's profile. The next piece of the puzzle is showing their most popular repositories.
The GitHub REST API has a repos endpoint that returns a user's repositories sorted by different criteria. You'll fetch the top 6 repos by star count and display them as clickable cards below the profile.
In this step, get ready to:
- Create a RepoList component that renders repo cards with key metadata.
- Fetch the user's top repositories from the GitHub API.
- Make each repo name a clickable link that opens GitHub in a new tab.
Create the RepoList component
Each repo card needs to show the repository name, a truncated description, the primary language, and star/fork counts. The entire card will be a clickable link to the actual GitHub repository.
- Create a new file at src/components/RepoList.jsx and add the following code:
function RepoList({ repos }) {
if (!repos.length) return null;
return (
<div className="repo-list">
<h3 className="section-title">Top Repositories</h3>
<div className="repo-grid">
{repos.map((repo) => (
<a
key={repo.id}
href={repo.html_url}
target="_blank"
rel="noopener noreferrer"
className="repo-card"
>
<h4 className="repo-name">{repo.name}</h4>
<p className="repo-description">
{repo.description
? repo.description.length > 80
? repo.description.slice(0, 80) + '...'
: repo.description
: 'No description'}
</p>
<div className="repo-meta">
{repo.language && <span className="repo-language">{repo.language}</span>}
<span className="repo-stars">★ {repo.stargazers_count}</span>
<span className="repo-forks">☍ {repo.forks_count}</span>
</div>
</a>
))}
</div>
</div>
);
}
export default RepoList;
What does this code do?
The component receives a repos array as a prop and maps over it to render cards. If the array is empty, it returns null so nothing renders.
Descriptions are truncated at 80 characters with an ellipsis to keep cards uniform. If a repo has no description, it shows "No description" as a fallback.
Each card is an <a> tag linking to repo.html_url (the GitHub page). The target="_blank" attribute opens the link in a new tab, and rel="noopener noreferrer" prevents the opened page from accessing your app's window object.
- Save src/components/RepoList.jsx.
Seeing a syntax error in your editor?
Check that all curly braces and parentheses are balanced. The ternary expression for repo.description has nested ternaries which are easy to mistype.
help me fix a syntax error in my RepoList component
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before moving on.
ⓧ I'd like to double check the full code
function RepoList({ repos }) {
if (!repos.length) return null;
return (
<div className="repo-list">
<h3 className="section-title">Top Repositories</h3>
<div className="repo-grid">
{repos.map((repo) => (
<a
key={repo.id}
href={repo.html_url}
target="_blank"
rel="noopener noreferrer"
className="repo-card"
>
<h4 className="repo-name">{repo.name}</h4>
<p className="repo-description">
{repo.description
? repo.description.length > 80
? repo.description.slice(0, 80) + '...'
: repo.description
: 'No description'}
</p>
<div className="repo-meta">
{repo.language && <span className="repo-language">{repo.language}</span>}
<span className="repo-stars">★ {repo.stargazers_count}</span>
<span className="repo-forks">☍ {repo.forks_count}</span>
</div>
</a>
))}
</div>
</div>
);
}
export default RepoList;
Fetch and display repos in App.jsx
The RepoList component is ready, but it needs data. You'll add a second API call that fetches the user's repositories right after the profile loads.
- Switch back to src/App.jsx and replace its entire contents with the following code:
import { useState, useEffect } from 'react';
import SearchBar from './components/SearchBar.jsx';
import ProfileCard from './components/ProfileCard.jsx';
import RepoList from './components/RepoList.jsx';
function App() {
const [username, setUsername] = useState('');
const [user, setUser] = useState(null);
const [repos, setRepos] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (!username) return;
const fetchData = async () => {
setLoading(true);
setError('');
setUser(null);
setRepos([]);
try {
const userRes = await fetch(`https://api.github.com/users/${username}`);
if (!userRes.ok) {
if (userRes.status === 404) {
setError('User not found. Check the username and try again.');
} else if (userRes.status === 403) {
setError('API rate limit exceeded. Please wait a minute and try again.');
} else {
setError('Something went wrong. Please try again.');
}
setLoading(false);
return;
}
const userData = await userRes.json();
setUser(userData);
const reposRes = await fetch(`https://api.github.com/users/${username}/repos?sort=stars&per_page=6`);
const reposData = await reposRes.json();
setRepos(reposData);
} catch (err) {
setError('Network error. Please check your connection.');
}
setLoading(false);
};
fetchData();
}, [username]);
const handleSearch = (searchedUsername) => {
setUsername(searchedUsername.trim());
};
return (
<div className="app">
<header className="app-header">
<h1>GitHub Profile Explorer</h1>
</header>
<main className="app-main">
<SearchBar onSearch={handleSearch} />
{loading && <p className="loading">Loading...</p>}
{error && <p className="error">{error}</p>}
{user && (
<div className="results">
<ProfileCard user={user} />
<RepoList repos={repos} />
</div>
)}
</main>
</div>
);
}
export default App;
What changed in App.jsx?
- A new repos state variable (initialized as an empty array) stores the fetched repositories.
- setRepos([]) clears old repos each time a new search starts, so stale data from a previous user doesn't flash on screen.
- After the user profile loads successfully, a second fetch calls the repos endpoint with ?sort=stars&per_page=6 to get the top 6 repos by star count.
- The render now wraps ProfileCard and RepoList in a results div for layout grouping.
- Save src/App.jsx.
- Return to your browser at http://localhost:5173 and search for a GitHub username (try torvalds or octocat). You should see repo cards appear below the profile card showing names, descriptions, stars, and forks.
Repos not appearing below the profile?
Make sure the import RepoList from './components/RepoList.jsx' line is at the top of App.jsx.
Confirm that <RepoList repos={repos} /> is inside the {user && (...)} conditional block. If it's outside, it renders before any data exists.
help me debug why my RepoList component isn't showing any repos
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before continuing.
ⓧ I'd like to double check the full code
import { useState, useEffect } from 'react';
import SearchBar from './components/SearchBar.jsx';
import ProfileCard from './components/ProfileCard.jsx';
import RepoList from './components/RepoList.jsx';
function App() {
const [username, setUsername] = useState('');
const [user, setUser] = useState(null);
const [repos, setRepos] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (!username) return;
const fetchData = async () => {
setLoading(true);
setError('');
setUser(null);
setRepos([]);
try {
const userRes = await fetch(`https://api.github.com/users/${username}`);
if (!userRes.ok) {
if (userRes.status === 404) {
setError('User not found. Check the username and try again.');
} else if (userRes.status === 403) {
setError('API rate limit exceeded. Please wait a minute and try again.');
} else {
setError('Something went wrong. Please try again.');
}
setLoading(false);
return;
}
const userData = await userRes.json();
setUser(userData);
const reposRes = await fetch(`https://api.github.com/users/${username}/repos?sort=stars&per_page=6`);
const reposData = await reposRes.json();
setRepos(reposData);
} catch (err) {
setError('Network error. Please check your connection.');
}
setLoading(false);
};
fetchData();
}, [username]);
const handleSearch = (searchedUsername) => {
setUsername(searchedUsername.trim());
};
return (
<div className="app">
<header className="app-header">
<h1>GitHub Profile Explorer</h1>
</header>
<main className="app-main">
<SearchBar onSearch={handleSearch} />
{loading && <p className="loading">Loading...</p>}
{error && <p className="error">{error}</p>}
{user && (
<div className="results">
<ProfileCard user={user} />
<RepoList repos={repos} />
</div>
)}
</main>
</div>
);
}
export default App;
Verify repo links open in a new tab
Each repo card is wrapped in an anchor tag that links directly to the repository on GitHub. The two attributes on that link protect your app from a security vulnerability called reverse tabnapping.
- In your browser, click any repo card in the results.
- Confirm that the repository opens in a new browser tab, not in the same tab as your app.
Why use noopener noreferrer?
When you open a link with target="_blank", the destination page can access your page's window.opener property. A malicious page could use that to redirect your app to a phishing site.
Adding rel="noopener noreferrer" sets window.opener to null and prevents the Referer header from being sent. This is a best practice whenever you link to external sites in a new tab.
Your explorer now shows both profile data and top repositories. Next up, you'll aggregate language statistics across all those repos and visualize them as a colored bar chart.
Aggregate and Visualize Language Stats
Your explorer already shows a user's profile and top repos. But one question every developer has about a GitHub profile is: what languages does this person actually use?
In this step, you'll build a language breakdown that aggregates byte counts from every fetched repo into a visual stacked bar. This means calling a separate GitHub REST API endpoint for each repo, combining the results, and rendering them as colored segments with percentages.
In this step, get ready to:
- Create a LanguageStats component with a color-coded bar chart.
- Fetch language data for all repos in parallel using Promise.all.
- Render the language breakdown between the profile card and repos.
Create the LanguageStats component
GitHub's language colors are recognizable across the platform. You'll replicate them with a color mapping object so each language segment in your bar matches what developers expect to see.
- Create a new file src/components/LanguageStats.jsx.
- Add the language color mapping by pasting this code:
const LANGUAGE_COLORS = {
JavaScript: '#f1e05a',
TypeScript: '#3178c6',
Python: '#3572A5',
Java: '#b07219',
CSS: '#563d7c',
HTML: '#e34c26',
Ruby: '#701516',
Go: '#00ADD8',
Rust: '#dea584',
C: '#555555',
'C++': '#f34b7d',
'C#': '#178600',
PHP: '#4F5D95',
Shell: '#89e051',
Swift: '#F05138',
Kotlin: '#A97BFF',
Dart: '#00B4AB',
Vue: '#41b883',
SCSS: '#c6538c',
Other: '#8b8b8b',
};
What does this code do?
LANGUAGE_COLORS maps programming language names to their official GitHub dot colors. When a language appears in your bar chart, the component looks up its hex color here.
The Other entry is a fallback for any language not in the list or for grouped minor languages.
Now you need the component function that takes the aggregated language data, calculates percentages, shows the top 6 languages, groups the rest as "Other", and renders both a stacked bar and a legend.
- Below the LANGUAGE_COLORS object, add the component function:
function LanguageStats({ languages }) {
const entries = Object.entries(languages);
if (!entries.length) return null;
// Calculate the total bytes across all languages
const total = entries.reduce((sum, [, bytes]) => sum + bytes, 0);
const sorted = entries.sort((a, b) => b[1] - a[1]);
// Keep top 6 languages, group the rest as "Other"
const top6 = sorted.slice(0, 6);
const otherBytes = sorted.slice(6).reduce((sum, [, bytes]) => sum + bytes, 0);
if (otherBytes > 0) {
top6.push(['Other', otherBytes]);
}
return (
<div className="language-stats">
<h3 className="section-title">Languages</h3>
<div className="language-bar">
{top6.map(([lang, bytes]) => {
const percent = ((bytes / total) * 100).toFixed(1);
return (
<div
key={lang}
className="language-segment"
style={{
width: `${percent}%`,
backgroundColor: LANGUAGE_COLORS[lang] || LANGUAGE_COLORS['Other'],
}}
title={`${lang}: ${percent}%`}
/>
);
})}
</div>
<div className="language-legend">
{top6.map(([lang, bytes]) => {
const percent = ((bytes / total) * 100).toFixed(1);
return (
<span key={lang} className="legend-item">
<span
className="legend-color"
style={{
backgroundColor: LANGUAGE_COLORS[lang] || LANGUAGE_COLORS['Other'],
}}
/>
{lang} {percent}%
</span>
);
})}
</div>
</div>
);
}
export default LanguageStats;
What does this code do?
- entries.reduce() sums all byte counts to get the total. Each language's percentage is calculated relative to this total.
- sorted.slice(0, 6) keeps only the top 6 languages by byte count. Everything else gets grouped into an "Other" bucket.
- The language-bar div renders each language as a colored segment whose width is proportional to its percentage.
- The language-legend div renders a dot and label for each language so users can identify the colors.
- Save src/components/LanguageStats.jsx.
- You won't see a change in the browser yet. The component is ready but not wired into App.jsx.
Seeing a red squiggle in your editor?
Make sure the export default LanguageStats; line is at the very bottom of the file, outside the function body.
help me fix a syntax error in LanguageStats.jsx
Fetch language data with Promise.all
Each repo has its own languages endpoint that returns byte counts per language. Fetching them one at a time would be slow. Promise.all lets you fire all 6 requests in parallel and wait for them to finish together.
- In src/App.jsx, find this import line at the top:
import RepoList from './components/RepoList.jsx';
- Add the LanguageStats import directly below it, so your imports look like this:
import RepoList from './components/RepoList.jsx';
import LanguageStats from './components/LanguageStats.jsx';
- Next, find the state declarations inside the App function. Locate this line:
const [repos, setRepos] = useState([]);
- Add a new state variable for languages directly below it:
const [repos, setRepos] = useState([]);
const [languages, setLanguages] = useState({});
- Inside the fetchData function, find the reset block that has setRepos([]); and add setLanguages({}); below it so the block looks like:
setUser(null);
setRepos([]);
setLanguages({});
Now for the main fetching logic. After the repos are fetched, you'll call the languages endpoint for each repo in parallel, then aggregate all the byte counts into one object.
- Find the line setRepos(reposData); inside the try block. Add the following code directly below it:
// Fetch language byte counts for each repo in parallel
const langPromises = reposData.map((repo) =>
fetch(`https://api.github.com/repos/${username}/${repo.name}/languages`).then((res) => res.json())
);
const langResults = await Promise.all(langPromises);
// Aggregate all language bytes into a single object
const aggregated = {};
langResults.forEach((repoLangs) => {
Object.entries(repoLangs).forEach(([lang, bytes]) => {
aggregated[lang] = (aggregated[lang] || 0) + bytes;
});
});
setLanguages(aggregated);
What does this code do?
- reposData.map() creates an array of fetch promises, one for each repo's /languages endpoint. Each endpoint returns an object like { JavaScript: 45000, CSS: 8000 }.
- Promise.all(langPromises) fires all 6 requests at once and waits until they all resolve. This is much faster than fetching them one by one.
- The aggregation loop combines byte counts across repos. If three repos use JavaScript, their byte counts get summed into one total.
Why Promise.all instead of sequential fetches?
Sequential fetches would take 6x as long because each one waits for the previous to finish. Promise.all sends all requests simultaneously. The total wait time equals the slowest single request, not the sum of all of them.
- Save src/App.jsx.
Seeing a blank page or console errors?
Check that the new code sits inside the existing try block, after setRepos(reposData); and before the } catch line. Misplaced braces are the most common cause.
help me fix my App.jsx after adding the language fetching logic
Render LanguageStats between ProfileCard and RepoList
- In src/App.jsx, scroll down to the JSX return. Find this line inside the results div:
<ProfileCard user={user} />
<RepoList repos={repos} />
- Add <LanguageStats languages={languages} /> between them, so the block looks like this:
<ProfileCard user={user} />
<LanguageStats languages={languages} />
<RepoList repos={repos} />
- Save src/App.jsx.
- Return to your browser at http://localhost:5173 and search for a GitHub username.
- You should see a colored horizontal bar appear between the profile card and the repo grid. Below the bar, a legend shows each language name with its percentage.
Don't see the language bar?
If the profile and repos appear but no language bar shows, open your browser's DevTools console. A 403 error means you've hit the API rate limit (60 requests per hour for unauthenticated calls). Wait a minute and try again.
If you see no errors at all, check that your import statement at the top of App.jsx matches the filename exactly: ./components/LanguageStats.jsx.
help me debug why my LanguageStats component isn't showing up
What about the GitHub API rate limit?
Without authentication, the GitHub API allows 60 requests per hour. Each search uses about 8 calls (1 user + 1 repos + 6 language endpoints). That gives you roughly 7 searches per hour before hitting the limit.
Your app already handles this gracefully. The 403 error handler you built in the previous step displays a friendly message when the rate limit is exceeded.
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/components/LanguageStats.jsx:
const LANGUAGE_COLORS = {
JavaScript: '#f1e05a',
TypeScript: '#3178c6',
Python: '#3572A5',
Java: '#b07219',
CSS: '#563d7c',
HTML: '#e34c26',
Ruby: '#701516',
Go: '#00ADD8',
Rust: '#dea584',
C: '#555555',
'C++': '#f34b7d',
'C#': '#178600',
PHP: '#4F5D95',
Shell: '#89e051',
Swift: '#F05138',
Kotlin: '#A97BFF',
Dart: '#00B4AB',
Vue: '#41b883',
SCSS: '#c6538c',
Other: '#8b8b8b',
};
function LanguageStats({ languages }) {
const entries = Object.entries(languages);
if (!entries.length) return null;
const total = entries.reduce((sum, [, bytes]) => sum + bytes, 0);
const sorted = entries.sort((a, b) => b[1] - a[1]);
const top6 = sorted.slice(0, 6);
const otherBytes = sorted.slice(6).reduce((sum, [, bytes]) => sum + bytes, 0);
if (otherBytes > 0) {
top6.push(['Other', otherBytes]);
}
return (
<div className="language-stats">
<h3 className="section-title">Languages</h3>
<div className="language-bar">
{top6.map(([lang, bytes]) => {
const percent = ((bytes / total) * 100).toFixed(1);
return (
<div
key={lang}
className="language-segment"
style={{
width: `${percent}%`,
backgroundColor: LANGUAGE_COLORS[lang] || LANGUAGE_COLORS['Other'],
}}
title={`${lang}: ${percent}%`}
/>
);
})}
</div>
<div className="language-legend">
{top6.map(([lang, bytes]) => {
const percent = ((bytes / total) * 100).toFixed(1);
return (
<span key={lang} className="legend-item">
<span
className="legend-color"
style={{
backgroundColor: LANGUAGE_COLORS[lang] || LANGUAGE_COLORS['Other'],
}}
/>
{lang} {percent}%
</span>
);
})}
</div>
</div>
);
}
export default LanguageStats;
And here is the complete src/App.jsx:
import { useState, useEffect } from 'react';
import SearchBar from './components/SearchBar.jsx';
import ProfileCard from './components/ProfileCard.jsx';
import RepoList from './components/RepoList.jsx';
import LanguageStats from './components/LanguageStats.jsx';
function App() {
const [username, setUsername] = useState('');
const [user, setUser] = useState(null);
const [repos, setRepos] = useState([]);
const [languages, setLanguages] = useState({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (!username) return;
const fetchData = async () => {
setLoading(true);
setError('');
setUser(null);
setRepos([]);
setLanguages({});
try {
const userRes = await fetch(`https://api.github.com/users/${username}`);
if (!userRes.ok) {
if (userRes.status === 404) {
setError('User not found. Check the username and try again.');
} else if (userRes.status === 403) {
setError('API rate limit exceeded. Please wait a minute and try again.');
} else {
setError('Something went wrong. Please try again.');
}
setLoading(false);
return;
}
const userData = await userRes.json();
setUser(userData);
const reposRes = await fetch(`https://api.github.com/users/${username}/repos?sort=stars&per_page=6`);
const reposData = await reposRes.json();
setRepos(reposData);
const langPromises = reposData.map((repo) =>
fetch(`https://api.github.com/repos/${username}/${repo.name}/languages`).then((res) => res.json())
);
const langResults = await Promise.all(langPromises);
const aggregated = {};
langResults.forEach((repoLangs) => {
Object.entries(repoLangs).forEach(([lang, bytes]) => {
aggregated[lang] = (aggregated[lang] || 0) + bytes;
});
});
setLanguages(aggregated);
} catch (err) {
setError('Network error. Please check your connection.');
}
setLoading(false);
};
fetchData();
}, [username]);
const handleSearch = (searchedUsername) => {
setUsername(searchedUsername.trim());
};
return (
<div className="app">
<header className="app-header">
<h1>GitHub Profile Explorer</h1>
</header>
<main className="app-main">
<SearchBar onSearch={handleSearch} />
{loading && <p className="loading">Loading...</p>}
{error && <p className="error">{error}</p>}
{user && (
<div className="results">
<ProfileCard user={user} />
<LanguageStats languages={languages} />
<RepoList repos={repos} />
</div>
)}
</main>
</div>
);
}
export default App;
Your app now fetches, aggregates, and visualizes language data from multiple API endpoints in parallel. Next up, you'll polish the entire UI with a dark theme and deploy the finished app live.
Polish the UI and Deploy
Your app fetches and displays GitHub profiles, repos, and language stats. Now it's time to make it look professional and share it with the world.
CSS will transform the raw HTML into a polished dark-themed interface. Then you'll deploy the finished app to Vercel so anyone can access it from a URL.
In this step, get ready to:
- Style the entire app with a dark theme, responsive layout, and hover animations.
- Add a GitHub icon, fade-in transitions, and a footer to the app.
- Deploy to Vercel and get a live shareable URL.
Style the app with CSS
Right now the app works, but it renders unstyled HTML. You'll write a full stylesheet that gives it a dark theme inspired by GitHub's own interface.
- Open src/styles/App.css in VS Code (it should be empty).
- Paste the following base styles for layout, search bar, loading states, and animations:
/* Global reset and base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #0d1117;
color: #c9d1d9;
min-height: 100vh;
}
/* App container - centers content with max-width */
.app {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Header styling */
.app-header {
text-align: center;
margin-bottom: 2rem;
}
.app-header h1 {
font-size: 1.8rem;
color: #f0f6fc;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.github-icon {
fill: #f0f6fc;
}
/* Main content area fills remaining space */
.app-main {
flex: 1;
}
/* Footer with subtle top border */
.app-footer {
text-align: center;
margin-top: 3rem;
padding-top: 1rem;
border-top: 1px solid #21262d;
color: #8b949e;
font-size: 0.85rem;
}
/* Search bar layout */
.search-bar {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
}
.search-input {
flex: 1;
padding: 0.75rem 1rem;
border: 1px solid #30363d;
border-radius: 6px;
background-color: #161b22;
color: #c9d1d9;
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
.search-input:focus {
border-color: #58a6ff;
}
/* Green submit button with hover effect */
.search-button {
padding: 0.75rem 1.5rem;
background-color: #238636;
color: #ffffff;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.search-button:hover {
background-color: #2ea043;
}
/* Loading and error states */
.loading {
text-align: center;
color: #8b949e;
font-size: 1.1rem;
padding: 2rem;
}
.error {
text-align: center;
color: #f85149;
font-size: 1rem;
padding: 1rem;
background-color: #161b22;
border: 1px solid #f8514930;
border-radius: 6px;
}
/* Results container with vertical spacing */
.results {
display: flex;
flex-direction: column;
gap: 2rem;
}
/* Fade-in animation for loaded content */
.fade-in {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
What does this code do?
- The global reset removes default browser margins and applies box-sizing: border-box so padding never breaks layouts.
- The #0d1117 background color matches GitHub's dark theme.
- The .app container uses max-width: 800px and flexbox to center content and push the footer down.
- The @keyframes fadeIn animation slides content up slightly as it appears, creating a smooth loading transition.
- Now paste the component styles below the animation code in the same file:
/* Profile card - horizontal layout with avatar */
.profile-card {
display: flex;
gap: 1.5rem;
padding: 1.5rem;
background-color: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
}
.profile-avatar {
width: 120px;
height: 120px;
border-radius: 50%;
border: 3px solid #30363d;
}
.profile-info {
flex: 1;
}
.profile-name {
font-size: 1.4rem;
color: #f0f6fc;
margin-bottom: 0.2rem;
}
.profile-login {
color: #8b949e;
margin-bottom: 0.5rem;
}
.profile-bio {
color: #c9d1d9;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.profile-location {
color: #8b949e;
font-size: 0.9rem;
margin-bottom: 0.75rem;
}
.profile-stats {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.stat {
font-size: 0.9rem;
color: #8b949e;
}
.stat strong {
color: #f0f6fc;
}
/* Section headings for repos and languages */
.section-title {
font-size: 1.2rem;
color: #f0f6fc;
margin-bottom: 1rem;
}
/* Repository grid - 2 columns on desktop */
.repo-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
/* Repo cards with hover animation */
.repo-card {
padding: 1rem;
background-color: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
text-decoration: none;
color: inherit;
transition: border-color 0.2s, transform 0.2s;
}
.repo-card:hover {
border-color: #58a6ff;
transform: translateY(-2px);
}
.repo-name {
font-size: 1rem;
color: #58a6ff;
margin-bottom: 0.5rem;
}
.repo-description {
font-size: 0.85rem;
color: #8b949e;
margin-bottom: 0.75rem;
line-height: 1.4;
}
.repo-meta {
display: flex;
gap: 0.75rem;
font-size: 0.8rem;
color: #8b949e;
}
.repo-language {
font-weight: 600;
}
/* Language stats bar and legend */
.language-stats {
padding: 1.5rem;
background-color: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
}
.language-bar {
display: flex;
height: 10px;
border-radius: 5px;
overflow: hidden;
margin-bottom: 1rem;
}
.language-segment {
transition: width 0.3s ease;
}
.language-legend {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem;
color: #8b949e;
}
.legend-color {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
/* Responsive - stack on mobile */
@media (max-width: 600px) {
.profile-card {
flex-direction: column;
align-items: center;
text-align: center;
}
.profile-stats {
justify-content: center;
}
.repo-grid {
grid-template-columns: 1fr;
}
}
What does this code do?
- The profile card uses flexbox to place the avatar beside the user info.
- Repo cards use CSS Grid with repeat(2, 1fr) for a two-column layout. On hover, they lift slightly with transform: translateY(-2px).
- The language bar uses flexbox with overflow: hidden and border-radius to create a smooth rounded progress bar.
- The @media query at 600px switches to single-column layouts for mobile devices.
- Save src/styles/App.css.
The styles won't apply yet because App.jsx doesn't import the CSS file. You'll connect them in the next substep.
Seeing a syntax error in the CSS?
- Make sure you pasted both chunks into a single src/styles/App.css file, one after the other.
- Check for stray characters at the top of the file. The file should start with * {.
Still stuck? Help me fix a CSS syntax error in my App.css file.
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before continuing.
ⓧ I'd like to double check the full code
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #0d1117;
color: #c9d1d9;
min-height: 100vh;
}
.app {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app-header {
text-align: center;
margin-bottom: 2rem;
}
.app-header h1 {
font-size: 1.8rem;
color: #f0f6fc;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.github-icon {
fill: #f0f6fc;
}
.app-main {
flex: 1;
}
.app-footer {
text-align: center;
margin-top: 3rem;
padding-top: 1rem;
border-top: 1px solid #21262d;
color: #8b949e;
font-size: 0.85rem;
}
.search-bar {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
}
.search-input {
flex: 1;
padding: 0.75rem 1rem;
border: 1px solid #30363d;
border-radius: 6px;
background-color: #161b22;
color: #c9d1d9;
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
.search-input:focus {
border-color: #58a6ff;
}
.search-button {
padding: 0.75rem 1.5rem;
background-color: #238636;
color: #ffffff;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.search-button:hover {
background-color: #2ea043;
}
.loading {
text-align: center;
color: #8b949e;
font-size: 1.1rem;
padding: 2rem;
}
.error {
text-align: center;
color: #f85149;
font-size: 1rem;
padding: 1rem;
background-color: #161b22;
border: 1px solid #f8514930;
border-radius: 6px;
}
.results {
display: flex;
flex-direction: column;
gap: 2rem;
}
.fade-in {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.profile-card {
display: flex;
gap: 1.5rem;
padding: 1.5rem;
background-color: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
}
.profile-avatar {
width: 120px;
height: 120px;
border-radius: 50%;
border: 3px solid #30363d;
}
.profile-info {
flex: 1;
}
.profile-name {
font-size: 1.4rem;
color: #f0f6fc;
margin-bottom: 0.2rem;
}
.profile-login {
color: #8b949e;
margin-bottom: 0.5rem;
}
.profile-bio {
color: #c9d1d9;
margin-bottom: 0.5rem;
line-height: 1.4;
}
.profile-location {
color: #8b949e;
font-size: 0.9rem;
margin-bottom: 0.75rem;
}
.profile-stats {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.stat {
font-size: 0.9rem;
color: #8b949e;
}
.stat strong {
color: #f0f6fc;
}
.section-title {
font-size: 1.2rem;
color: #f0f6fc;
margin-bottom: 1rem;
}
.repo-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.repo-card {
padding: 1rem;
background-color: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
text-decoration: none;
color: inherit;
transition: border-color 0.2s, transform 0.2s;
}
.repo-card:hover {
border-color: #58a6ff;
transform: translateY(-2px);
}
.repo-name {
font-size: 1rem;
color: #58a6ff;
margin-bottom: 0.5rem;
}
.repo-description {
font-size: 0.85rem;
color: #8b949e;
margin-bottom: 0.75rem;
line-height: 1.4;
}
.repo-meta {
display: flex;
gap: 0.75rem;
font-size: 0.8rem;
color: #8b949e;
}
.repo-language {
font-weight: 600;
}
.language-stats {
padding: 1.5rem;
background-color: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
}
.language-bar {
display: flex;
height: 10px;
border-radius: 5px;
overflow: hidden;
margin-bottom: 1rem;
}
.language-segment {
transition: width 0.3s ease;
}
.language-legend {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem;
color: #8b949e;
}
.legend-color {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
}
@media (max-width: 600px) {
.profile-card {
flex-direction: column;
align-items: center;
text-align: center;
}
.profile-stats {
justify-content: center;
}
.repo-grid {
grid-template-columns: 1fr;
}
}
Add finishing touches to App.jsx
The CSS file is ready. Now you need to import it and update the JSX to include the GitHub icon, a fade-in animation class, and a footer.
- In src/App.jsx, find this line at the top of your imports:
import LanguageStats from './components/LanguageStats.jsx';
- Add the CSS import directly below it so the line reads:
import LanguageStats from './components/LanguageStats.jsx';
import './styles/App.css';
- Now replace the entire return (...) block in your App function with the updated version below. This adds the GitHub SVG icon in the header, the fade-in class on results, and a footer:
return (
<div className="app">
<header className="app-header">
<h1>
<svg
className="github-icon"
viewBox="0 0 16 16"
width="32"
height="32"
fill="currentColor"
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
GitHub Profile Explorer
</h1>
</header>
<main className="app-main">
<SearchBar onSearch={handleSearch} />
{loading && <p className="loading">Loading...</p>}
{error && <p className="error">{error}</p>}
{user && (
<div className="results fade-in">
<ProfileCard user={user} />
<LanguageStats languages={languages} />
<RepoList repos={repos} />
</div>
)}
</main>
<footer className="app-footer">
<p>Powered by GitHub API</p>
</footer>
</div>
);
What changed here?
- The <h1> now contains an inline SVG of the GitHub Octocat logo, rendered at 32x32 pixels.
- The results wrapper gained a fade-in class that triggers the CSS animation you wrote earlier.
- A <footer> at the bottom credits the GitHub API and provides visual closure.
- Save src/App.jsx.
- Switch back to your browser at http://localhost:5173. You should see a dark-themed page with the GitHub icon in the header and a green search button.
- Type a GitHub username (try octocat) and click Search. The profile card, language bar, and repo grid should appear with a smooth fade-in animation.
Still seeing unstyled content?
- Confirm the import line in App.jsx says exactly import './styles/App.css'; with the correct relative path.
- Check that your CSS file is at src/styles/App.css (not src/App.css or another location).
- If the dev server crashed, stop it with Ctrl+C and restart with npm run dev.
Need help? My CSS styles aren't showing up in the browser even though I imported the file.
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on to deployment.
ⓧ I'd like to double check the full code
import { useState, useEffect } from 'react';
import SearchBar from './components/SearchBar.jsx';
import ProfileCard from './components/ProfileCard.jsx';
import RepoList from './components/RepoList.jsx';
import LanguageStats from './components/LanguageStats.jsx';
import './styles/App.css';
function App() {
const [username, setUsername] = useState('');
const [user, setUser] = useState(null);
const [repos, setRepos] = useState([]);
const [languages, setLanguages] = useState({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (!username) return;
const fetchData = async () => {
setLoading(true);
setError('');
setUser(null);
setRepos([]);
setLanguages({});
try {
const userRes = await fetch(`https://api.github.com/users/${username}`);
if (!userRes.ok) {
if (userRes.status === 404) {
setError('User not found. Check the username and try again.');
} else if (userRes.status === 403) {
setError('API rate limit exceeded. Please wait a minute and try again.');
} else {
setError('Something went wrong. Please try again.');
}
setLoading(false);
return;
}
const userData = await userRes.json();
setUser(userData);
const reposRes = await fetch(
`https://api.github.com/users/${username}/repos?sort=stars&per_page=6`
);
const reposData = await reposRes.json();
setRepos(reposData);
const langPromises = reposData.map((repo) =>
fetch(`https://api.github.com/repos/${username}/${repo.name}/languages`).then((res) =>
res.json()
)
);
const langResults = await Promise.all(langPromises);
const aggregated = {};
langResults.forEach((repoLangs) => {
Object.entries(repoLangs).forEach(([lang, bytes]) => {
aggregated[lang] = (aggregated[lang] || 0) + bytes;
});
});
setLanguages(aggregated);
} catch (err) {
setError('Network error. Please check your connection.');
}
setLoading(false);
};
fetchData();
}, [username]);
const handleSearch = (searchedUsername) => {
setUsername(searchedUsername.trim());
};
return (
<div className="app">
<header className="app-header">
<h1>
<svg
className="github-icon"
viewBox="0 0 16 16"
width="32"
height="32"
fill="currentColor"
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
GitHub Profile Explorer
</h1>
</header>
<main className="app-main">
<SearchBar onSearch={handleSearch} />
{loading && <p className="loading">Loading...</p>}
{error && <p className="error">{error}</p>}
{user && (
<div className="results fade-in">
<ProfileCard user={user} />
<LanguageStats languages={languages} />
<RepoList repos={repos} />
</div>
)}
</main>
<footer className="app-footer">
<p>Powered by GitHub API</p>
</footer>
</div>
);
}
export default App;
Deploy to Vercel
Your app is polished and working locally. To share it with anyone on the internet, you'll push the code to GitHub and deploy it on Vercel's free Hobby plan.
- Stop the dev server by pressing Ctrl+C in your terminal.
- Initialize a Git repository and commit your code by running these commands:
git init
git add .
git commit -m "Complete GitHub Profile Explorer"
- Go to github.com in your browser.
- Click the + icon in the top right, then select New repository.
- Name it github-explorer (or any name you prefer).
- Leave all checkboxes unchecked (no README, no .gitignore, no license).
- Click Create repository.
- GitHub shows a Quick Setup page. Copy the HTTPS URL it provides.
- Back in your terminal, connect and push by running these commands (replace the URL with the one you copied):
git remote add origin [[REPO_URL="https://github.com/your-username/github-explorer.git"]]
git branch -M main
git push -u origin main
What do these commands do?
- git remote add origin connects your local repo to the GitHub repository you just created.
- git branch -M main renames your branch to main (the standard default).
- git push -u origin main uploads all your code to GitHub.
- Refresh your GitHub repository page. You should see all your project files listed.
Push failed or asking for authentication?
- If Git asks for a username and password, you need a personal access token. Go to GitHub Settings > Developer settings > Personal access tokens and create one with repo scope. Use it as your password.
- If you see "remote origin already exists", run git remote set-url origin YOUR_URL instead.
Need help pushing to GitHub? Help me push my code to GitHub from the terminal.
Now you'll deploy the repo to Vercel so it's live on the internet.
- Go to vercel.com in your browser.
- Click Sign Up and choose Continue with GitHub. Authorize the connection when prompted.
- Once signed in, click Add New... in the top right, then select Project.
- Find your github-explorer repository in the list and click Import.
- Vercel auto-detects that this is a Vite project. Leave all settings at their defaults.
- Click Deploy.
What is Vercel?
Vercel is a hosting platform designed for frontend apps. It connects to your GitHub repo, builds your project automatically, and gives you a live URL. The free Hobby plan includes 100 deployments per day, which is more than enough for personal projects.
- Wait for the build to complete (usually 30-60 seconds).
- Once deployed, Vercel shows a Production URL (something like https://github-explorer-abc123.vercel.app).
- Click the URL to open your live app. Search for a username to confirm everything works.
- Copy this URL and save it. This is your shareable link.
Build failed on Vercel?
- Check the build logs in the Vercel dashboard for specific error messages.
- Make sure all your imports in App.jsx match the actual file paths (case-sensitive on Linux servers).
- Verify that npm run build succeeds locally in your terminal before pushing again.
Still stuck? Help me fix my Vercel deployment build failure.
Secret mission
Add Search History with localStorage
Your explorer works great, but every time you refresh the page you lose track of who you searched for. Can you add a persistent search history that remembers usernames across sessions and lets you re-search with a single click?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally and deploys on Vercel's free Hobby plan, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Local github-explorer project folder on your Desktop.
- GitHub repository connected to your Vercel deployment.
- Vercel project hosting your live deployment on the free Hobby plan.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep your live URL accessible.
- Your Vercel deployment stays live at no cost on the free Hobby plan.
- Your GitHub repository remains intact with full commit history.
- Your local project files stay on your Desktop for future edits.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files and deployment stay intact so you can resume later.
- In your terminal, press Ctrl+C to stop the Vite dev server if it is still running.
- Your Vercel deployment remains live at no cost. No action needed there.
- Your local files and GitHub repo remain untouched for when you return.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to fully clean up.
- Stop the Vite dev server by pressing Ctrl+C in the terminal if it is still running.
- Delete your local project folder by running this command:
macOS
rm -rf ~/Desktop/github-explorer
Windows
Remove-Item -Recurse -Force ~\Desktop\github-explorer
- Delete your Vercel project:
- Go to vercel.com/dashboard and select your project.
- Click Settings in the sidebar.
- Scroll to the bottom of the General page and find the Delete Project section.
- Click Delete, enter your project name to confirm, then click Continue.
- Delete your GitHub repository:
- Open your repository on GitHub and click Settings.
- Scroll down to the Danger Zone section and click Delete this repository.
- Click I want to delete this repository, then click I have read and understand these effects.
- Type the repository name to confirm, then click Delete this repository.
That's a Wrap!
That's a Wrap!
Nice work! You just built and deployed a full GitHub Profile Explorer from scratch using React and the GitHub REST API.
You've learned how to:
- Integrate a real REST API into a React app, fetching user profiles, repositories, and language data from multiple endpoints.
- Handle async operations with loading states, error handling, and parallel API calls using Promise.all.
- Deploy a polished, responsive dark-theme app live on Vercel for free with a shareable URL.
- Secret Mission: Added persistent search history using localStorage with clickable chips for quick re-searching.
Ready to quiz yourself?