Build a GitHub Profile Explorer

Build a React app that fetches and displays GitHub profiles and repos.

Introduction

30 Second Summary

Every developer has a GitHub profile, but have you ever wondered how apps pull that information and display it in real time? That's exactly what you'll build today.

In this project, you will create a React app that fetches any GitHub user's profile and repositories from a live API, then displays them in a polished, interactive interface.

What You'll Build

A single-page GitHub Profile Explorer built with React, TypeScript, and Vite that fetches real data from the GitHub REST API and renders it as styled profile and repository cards.

By the end of this project, you'll have:

  • A React + TypeScript project scaffolded with Vite, the modern build tool that replaces the deprecated Create React App.
  • A set of reusable components (SearchBar, ProfileCard, RepoList) that pass data through props and manage state.
  • Working API data fetching with loading states, error handling, and conditional rendering.
  • Secret Mission: Add sorting and filtering to the repository list so users can find repos by stars or language.

Are there any prerequisites?

This project assumes you have basic JavaScript and TypeScript knowledge. You should also have Node.js and VS Code already installed on your machine.

No API keys or paid accounts are needed. The GitHub REST API endpoints used in this project are completely free and require no authentication.

Set Up Your React Project with Vite

Every React app needs a build tool to bundle your code and a development server to preview changes instantly.

In this step, you'll scaffold a React + TypeScript project using Vite, start the development server, and clear out the default boilerplate so you have a clean slate to build on.

In this step, get ready to:
  • Scaffold a React + TypeScript project with Vite.
  • Start the development server and verify it works.
  • Clean up boilerplate code to start fresh.
Scaffold your React project

Vite is a modern build tool that scaffolds a ready-to-go project structure with one command.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open a terminal inside VS Code by pressing Ctrl+` (backtick key, top-left of your keyboard). This works on both 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-ts

What does this command do?

  • npm create vite@latest runs the Vite project scaffolder.
  • github-explorer is the name of the folder it creates.
  • -- --template react-ts tells Vite to use the React + TypeScript template, skipping the interactive prompts.
  • Move into the new project folder and install its dependencies by running these commands:
cd github-explorer
npm install
  • Confirm the project was created by running:
ls
  • You should see files like package.json, tsconfig.json, vite.config.ts, and a src folder in the output.

Seeing 'npm: command not found'?

This means Node.js isn't properly installed or isn't on your system PATH. Reinstall Node.js from nodejs.org and make sure you check the option to add it to PATH during installation.

Help me fix npm not being found on my system

  • Now open the project folder in VS Code by clicking File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the github-explorer folder, and click Open.

Why open the folder this way?

Opening a folder in VS Code makes it your workspace. The file explorer on the left shows all your project files, and any new terminal you open starts inside that folder automatically.

Start the development server

Vite includes a development server that serves your app locally and refreshes the browser whenever you save a change.

  • Open a new terminal in VS Code by pressing Ctrl+` (backtick). It should open inside the github-explorer folder.
  • Start the dev server by running:
npm run dev
  • Open your browser and navigate to http://localhost:5173.
  • You should see the default Vite + React page with a spinning logo and a counter button.

Page not loading at localhost:5173?

Check your terminal output. Vite prints the local URL when it starts. If another app is using port 5173, Vite may pick a different port (like 5174). Use whatever URL your terminal shows.

If you see an error about missing dependencies, make sure you ran npm install before npm run dev.

Help me get my Vite dev server running

Clean up the boilerplate

The default template includes demo code you don't need. You'll replace it with a minimal starting point for your GitHub Explorer app.

  • In the VS Code Explorer sidebar on the left, expand the src folder.
  • Click on App.tsx to open it.
  • Select all the existing code with Cmd+A (macOS) or Ctrl+A (Windows), then delete it.
  • Replace it with the following code:
function App() {
  return (
    <div>
      <h1>GitHub Explorer</h1>
    </div>
  )
}

export default App

What does this code do?

This defines a React component called App that returns a simple heading. JSX (the HTML-like syntax inside the return statement) describes what the component renders to the screen.

The export default App line makes this component available for other files to import.

  • Save App.tsx with Cmd+S (macOS) or Ctrl+S (Windows).
  • Check your browser. You should see the page update instantly to show just the text "GitHub Explorer". This is hot module replacement in action.

Browser still showing the old page?

Make sure you saved the file. Unsaved files in VS Code have a dot on their tab. If the page still doesn't update, try a hard refresh with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows).

Help me figure out why my browser isn't updating

Now clean up the remaining boilerplate files.

  • Right-click on App.css in the Explorer sidebar and select Delete. Confirm the deletion if prompted.
  • Open index.css in the src folder.
  • Select all the content with Cmd+A (macOS) or Ctrl+A (Windows) and delete it.
  • Save the now-empty index.css file.
  • Open main.tsx in the src folder. Find the import line that says:
import App from './App.tsx'
  • Remove the .tsx extension so it reads:
import App from './App'
  • Save main.tsx.
  • Check your browser. You should still see "GitHub Explorer" and the page background should now be plain white (since index.css is empty).

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved before moving on.

ⓧ I'd like to double check the full code

Here is what each file in your src folder should look like right now.

src/App.tsx

function App() {
  return (
    <div>
      <h1>GitHub Explorer</h1>
    </div>
  )
}

export default App

src/main.tsx

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

src/index.css should be completely empty (no content at all).

src/App.css should be deleted. If you still see it in the sidebar, right-click it and select Delete.

Your React project is ready to build on. Next up, you'll create your first custom component: a search bar that accepts GitHub usernames.

Build the SearchBar Component

Your dev server is running and you have a working React app in the browser. Now it's time to build the first interactive piece: a search bar that accepts a GitHub username.

In React, you build UIs by composing small, reusable pieces called components. The SearchBar component will manage its own input value using useState and communicate upward to the parent through a prop.

In this step, get ready to:
  • Create a SearchBar component with its own local state.
  • Wire the SearchBar into your App so it can communicate user input.
  • Test the data flow by confirming input reaches the browser console.
Create the SearchBar component

React apps organise components into a dedicated folder. You will create a components folder inside src to keep things tidy as the project grows.

  • In VS Code's Explorer sidebar, right-click the src folder.
  • Select New Folder and name it components.
  • Right-click the new components folder and select New File.
  • Name the file SearchBar.tsx and press Enter.
  • Add the following code to SearchBar.tsx:
import { useState } from 'react'

// Define the shape of props this component accepts
interface SearchBarProps {
  onSearch: (username: string) => void
}

function SearchBar({ onSearch }: SearchBarProps) {
  // Track the current value of the input field
  const [username, setUsername] = useState('')

  function handleSubmit(e: React.FormEvent) {
    // Stop the browser from refreshing the page
    e.preventDefault()
    const trimmed = username.trim()
    if (trimmed) {
      // Pass the username up to the parent component
      onSearch(trimmed)
    }
  }

  return (
    <form className="search-bar" onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="Enter a GitHub username..."
        value={username}
        onChange={(e) => setUsername(e.target.value)}
      />
      <button type="submit">Search</button>
    </form>
  )
}

export default SearchBar

What does this code do?

SearchBarProps is a TypeScript interface that describes what the parent component must pass in. Here it requires a single onSearch function that accepts a username string.

useState creates a username variable and a setUsername updater. Every keystroke calls setUsername, which re-renders the input with the latest value.

e.preventDefault() stops the form from triggering a full page reload. After trimming whitespace, the function only calls onSearch if the user actually typed something.

  • Save SearchBar.tsx.

You won't see a change in the browser yet because the component isn't rendered anywhere. The next substep connects it to your App.

Seeing a red underline in VS Code?

If VS Code highlights the useState import, make sure the very first line reads import { useState } from 'react' with single quotes and curly braces.

help me fix a red underline on my useState import in SearchBar.tsx

✔️ Awesome, I've got everything!

Great. Make sure you have saved SearchBar.tsx before moving on.

ⓧ I'd like to double check the full code

import { useState } from 'react'

interface SearchBarProps {
  onSearch: (username: string) => void
}

function SearchBar({ onSearch }: SearchBarProps) {
  const [username, setUsername] = useState('')

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    const trimmed = username.trim()
    if (trimmed) {
      onSearch(trimmed)
    }
  }

  return (
    <form className="search-bar" onSubmit={handleSubmit}>
      <input
        type="text"
        placeholder="Enter a GitHub username..."
        value={username}
        onChange={(e) => setUsername(e.target.value)}
      />
      <button type="submit">Search</button>
    </form>
  )
}

export default SearchBar
Import and render SearchBar in App.tsx

A component only appears on screen when a parent renders it. You need to import SearchBar into App.tsx and pass it a callback function so the two components can communicate.

  • Switch to src/App.tsx in VS Code.
  • Replace the entire file contents with this code:
import SearchBar from './components/SearchBar'

function App() {
  return (
    <div className="app">
      <h1>GitHub Profile Explorer</h1>
      {/* Pass a function that logs the username for now */}
      <SearchBar onSearch={(username) => console.log(username)} />
    </div>
  )
}

export default App

What's happening here?

The import statement pulls in the SearchBar component you just created. React uses JSX to render it like an HTML tag: <SearchBar />.

The onSearch prop receives an arrow function that calls console.log. This is a temporary placeholder. In a later step, you will replace it with a function that fetches real data from the GitHub API.

  • Save App.tsx.
  • Check your browser at http://localhost:5173. You should see the heading "GitHub Profile Explorer" and an input field with a Search button.

Don't see the search bar?

Make sure you saved both files. Check that the import path in App.tsx reads './components/SearchBar' and that the file is named exactly SearchBar.tsx (capital S, capital B).

help me debug why my SearchBar is not showing up in the browser

✔️ Awesome, I've got everything!

Great. You should see the input and button in your browser. Time to test.

ⓧ I'd like to double check the full code

import SearchBar from './components/SearchBar'

function App() {
  return (
    <div className="app">
      <h1>GitHub Profile Explorer</h1>
      <SearchBar onSearch={(username) => console.log(username)} />
    </div>
  )
}

export default App
Test the search bar in your browser

The SearchBar component manages its own text input, and the App passes a callback through props. Time to confirm the full loop works.

  • In your browser, open DevTools by pressing Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
  • Click in the search input and type octocat.
  • Click the Search button.

You should see octocat printed in the Console tab of DevTools. This confirms that the input state is tracked correctly and the onSearch prop fires on submit.

Nothing appearing in the console?

Make sure you are looking at the Console tab in DevTools, not the Elements or Network tab. Also confirm you clicked the Search button (or pressed Enter) after typing. Just typing does not trigger submit.

help me figure out why nothing shows in my browser console when I click Search

Why use props instead of fetching directly in SearchBar?

SearchBar only knows about text input. The parent (App) decides what to do with the submitted value. This separation keeps components reusable. You could drop this same SearchBar into any project and wire it to a different action without changing its code.

Your first component is wired up and communicating with its parent. Next, you will replace that console.log with a real function that fetches profile data from the GitHub API.

Fetch GitHub Data and Manage App State

Your SearchBar captures usernames beautifully, but right now it just logs them to the console.

In this step, you'll connect your app to the real GitHub REST API. You'll define the shape of the data you expect, set up state to hold it, and write the function that fetches it.

In this step, get ready to:
  • Define TypeScript interfaces for GitHub API responses.
  • Fetch user profiles and repositories from the GitHub REST API.
  • Verify real data arrives by rendering it in the browser.
Define TypeScript interfaces and state variables

TypeScript interfaces let you describe the exact shape of data you expect from an API. Your editor can then autocomplete field names and catch typos before you even run the app.

  • In src/App.tsx, replace the two import lines at the top of the file with the following code:
import { useState } from 'react'
import SearchBar from './components/SearchBar'

// Define the shape of a GitHub user profile
export interface GitHubUser {
  login: string
  avatar_url: string
  name: string | null
  bio: string | null
  html_url: string
  public_repos: number
  followers: number
  following: number
}

// Define the shape of a GitHub repository
export interface GitHubRepo {
  id: number
  name: string
  html_url: string
  description: string | null
  stargazers_count: number
  language: string | null
  forks_count: number
}

What does this code do?

You're importing useState from React so you can track data inside your component.

The two interfaces (GitHubUser and GitHubRepo) describe the fields that GitHub's API returns. The export keyword makes them available to other components you'll build later.

Now you need state variables to hold the fetched data. useState gives React a way to remember values between renders.

  • Inside the function App() block, add these four state declarations at the very top (above the return statement):
  // State for the fetched user profile
  const [user, setUser] = useState<GitHubUser | null>(null)
  // State for the fetched repository list
  const [repos, setRepos] = useState<GitHubRepo[]>([])}
  // Track whether a fetch is in progress
  const [loading, setLoading] = useState(false)
  // Store any error message from a failed fetch
  const [error, setError] = useState<string | null>(null)

What does this code do?

  • user starts as null and will hold the profile data once fetched.
  • repos starts as an empty array and will hold the list of repositories.
  • loading tracks whether a fetch is currently happening (for showing a spinner later).
  • error holds any error message to display if something goes wrong.
  • Save src/App.tsx.
  • Check the terminal where your dev server is running. You should see no errors.

Seeing a red error in the terminal?

  • Make sure the import { useState } from 'react' line is at the very top of the file.
  • Check that both interfaces are placed between the import lines and the function App() line.
  • Confirm the state declarations are inside the function body, not outside it.

Help me fix a TypeScript error in my App.tsx state declarations.

Write the searchUser function

This function is the brain of your app. When the learner submits a username, it calls two GitHub API endpoints, parses the responses, and updates all four state variables.

  • In src/App.tsx, add this function below the state declarations and above the return statement:
  async function searchUser(username: string) {
    // Reset state before starting a new search
    setLoading(true)
    setError(null)
    setUser(null)
    setRepos([])

    try {
      // Fetch the user's profile
      const userResponse = await fetch(
        `https://api.github.com/users/${username}`
      )

      if (!userResponse.ok) {
        if (userResponse.status === 404) {
          throw new Error(`User "${username}" not found.`)
        }
        throw new Error('Failed to fetch user data.')
      }

      const userData: GitHubUser = await userResponse.json()

      // Fetch the user's repositories
      const reposResponse = await fetch(
        `https://api.github.com/users/${username}/repos?per_page=10&sort=updated`
      )

      if (!reposResponse.ok) {
        throw new Error('Failed to fetch repositories.')
      }

      const reposData: GitHubRepo[] = await reposResponse.json()

      // Update state with the fetched data
      setUser(userData)
      setRepos(reposData)
    } catch (err) {
      setError(err instanceof Error ? err.message : 'An unexpected error occurred.')
    } finally {
      setLoading(false)
    }
  }

What does this code do?

  • async/await lets you write asynchronous code that reads top-to-bottom, pausing at each await until the network request finishes.
  • The function resets all state first so stale data from a previous search disappears immediately.
  • It hits two endpoints: one for the user profile (/users/{username}) and one for their repos (/users/{username}/repos).
  • A 404 status means the username doesn't exist. Any other non-ok status gets a generic error.
  • finally runs whether the fetch succeeds or fails, so loading always gets set back to false.

Why not useEffect?

You might have seen tutorials that use useEffect for data fetching. That pattern is for fetching data when a component first appears on screen (on mount).

Here, the fetch only happens when the user clicks the search button. A regular async function triggered by a form submit is simpler and more correct for this use case.

Now you need to wire this function to your SearchBar so it actually gets called.

  • In the return statement, find this line:
      <SearchBar onSearch={(username) => console.log(username)} />
  • Replace (username) => console.log(username) with searchUser, so the line now looks like this:
      <SearchBar onSearch={searchUser} />
  • Save src/App.tsx.
  • Check the terminal for errors. The browser won't show any visible change yet because you're not rendering the fetched data.

Seeing a type error about searchUser?

  • Make sure searchUser is defined inside the App function, not outside it. It needs access to the state setters.
  • Confirm the function signature is async function searchUser(username: string). The parameter type must match what SearchBar passes.

Help me debug my searchUser function wiring.

Test with a real GitHub username

The function is wired up, but you can't see the data yet because the JSX doesn't render it. A quick JSON.stringify will dump the raw data onto the page so you can confirm everything works.

  • In src/App.tsx, find the <SearchBar onSearch={searchUser} /> line.
  • Add this line directly below it (still inside the <div className="app">):
      {user && <pre>{JSON.stringify(user, null, 2)}</pre>}

What does this line do?

The && pattern is called conditional rendering. React only renders the <pre> element if user is not null. This is temporary. You'll replace it with proper components in the next step.

  • Save src/App.tsx.
  • Switch to your browser at http://localhost:5173.
  • Type octocat into the search field and click Search.

You should see a block of raw JSON appear below the search bar. It contains fields like login, avatar_url, and public_repos.

No JSON appearing after clicking Search?

  • Open your browser's DevTools console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows). Look for red network errors or CORS issues.
  • Make sure the fetch URLs use backticks (template literals), not regular quotes. The ${username} part only works inside backtick strings.
  • Check that the {user && <pre>...} line is inside the <div className="app"> wrapper, not outside it.

Help me debug why my GitHub fetch isn't working.

What about the rate limit?

The GitHub API allows 60 requests per hour without authentication. That's plenty for building and testing this project. If you hit the limit, just wait a few minutes.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here's the complete src/App.tsx at this point:

import { useState } from 'react'
import SearchBar from './components/SearchBar'

export interface GitHubUser {
  login: string
  avatar_url: string
  name: string | null
  bio: string | null
  html_url: string
  public_repos: number
  followers: number
  following: number
}

export interface GitHubRepo {
  id: number
  name: string
  html_url: string
  description: string | null
  stargazers_count: number
  language: string | null
  forks_count: number
}

function App() {
  const [user, setUser] = useState<GitHubUser | null>(null)
  const [repos, setRepos] = useState<GitHubRepo[]>([])}
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  async function searchUser(username: string) {
    setLoading(true)
    setError(null)
    setUser(null)
    setRepos([])

    try {
      const userResponse = await fetch(
        `https://api.github.com/users/${username}`
      )

      if (!userResponse.ok) {
        if (userResponse.status === 404) {
          throw new Error(`User "${username}" not found.`)
        }
        throw new Error('Failed to fetch user data.')
      }

      const userData: GitHubUser = await userResponse.json()

      const reposResponse = await fetch(
        `https://api.github.com/users/${username}/repos?per_page=10&sort=updated`
      )

      if (!reposResponse.ok) {
        throw new Error('Failed to fetch repositories.')
      }

      const reposData: GitHubRepo[] = await reposResponse.json()

      setUser(userData)
      setRepos(reposData)
    } catch (err) {
      setError(err instanceof Error ? err.message : 'An unexpected error occurred.')
    } finally {
      setLoading(false)
    }
  }

  return (
    <div className="app">
      <h1>GitHub Profile Explorer</h1>
      <SearchBar onSearch={searchUser} />
      {user && <pre>{JSON.stringify(user, null, 2)}</pre>}
    </div>
  )
}

export default App

Your app is now connected to a real API and pulling live data. Next up, you'll build the ProfileCard and RepoList components to display this data in a polished layout instead of raw JSON.

Build the ProfileCard and RepoList Components

In the last step, you connected your app to the GitHub API and confirmed real data is flowing in. Right now that data shows up as raw JSON text, which is useful for debugging but not for users.

In this step, you'll build two new components that transform that raw data into a visual profile card and a list of repository cards. You'll also add conditional rendering so the app shows the right thing at the right time.

In this step, get ready to:
  • Create a ProfileCard component that displays the user's avatar, name, bio, and stats.
  • Create a RepoList component that renders each repository as a clickable card.
  • Wire both components into App.tsx with conditional rendering for loading, error, and success states.
Create the ProfileCard component

A React component that accepts data through props can render that data however it wants. The ProfileCard will receive the full user object as a prop and display it as a card with the avatar, name, bio, and follower stats.

  • Create a new file called ProfileCard.tsx inside the src/components folder (next to SearchBar.tsx).
  • Paste the following code into ProfileCard.tsx:
import { GitHubUser } from '../App'

// Define the props this component accepts
interface ProfileCardProps {
  user: GitHubUser
}

function ProfileCard({ user }: ProfileCardProps) {
  return (
    <div className="profile-card">
      {/* User avatar image */}
      <img
        src={user.avatar_url}
        alt={`${user.login}'s avatar`}
        className="avatar"
      />
      <div className="profile-info">
        {/* Display name if available, otherwise fall back to login */}
        <h2>{user.name || user.login}</h2>
        {/* Only render bio if the user has one */}
        {user.bio && <p className="bio">{user.bio}</p>}
        <a href={user.html_url} target="_blank" rel="noopener noreferrer">
          View on GitHub
        </a>
        {/* User stats row */}
        <div className="stats">
          <span>{user.public_repos} repos</span>
          <span>{user.followers} followers</span>
          <span>{user.following} following</span>
        </div>
      </div>
    </div>
  )
}

export default ProfileCard

What does this code do?

  • ProfileCardProps defines the shape of the prop this component expects. It reuses the GitHubUser interface you already exported from App.tsx.
  • user.name || user.login displays the user's display name if they have one, or their login username as a fallback.
  • user.bio && <p>...</p> only renders the bio paragraph if the user has a bio. This pattern is called conditional rendering.
  • Save ProfileCard.tsx.

You won't see a change in the browser yet because this component isn't imported into App.tsx. You'll wire it up in the third substep.

Seeing a red underline on the import?

If VS Code shows an error on import { GitHubUser } from '../App', make sure your App.tsx has export in front of interface GitHubUser. Without the export keyword, other files can't access it.

Help me fix the import error in ProfileCard.tsx

✔️ Awesome, I've got everything!

Great. Make sure you've saved ProfileCard.tsx before moving on.

ⓧ I'd like to double check the full code

import { GitHubUser } from '../App'

interface ProfileCardProps {
  user: GitHubUser
}

function ProfileCard({ user }: ProfileCardProps) {
  return (
    <div className="profile-card">
      <img
        src={user.avatar_url}
        alt={`${user.login}'s avatar`}
        className="avatar"
      />
      <div className="profile-info">
        <h2>{user.name || user.login}</h2>
        {user.bio && <p className="bio">{user.bio}</p>}
        <a href={user.html_url} target="_blank" rel="noopener noreferrer">
          View on GitHub
        </a>
        <div className="stats">
          <span>{user.public_repos} repos</span>
          <span>{user.followers} followers</span>
          <span>{user.following} following</span>
        </div>
      </div>
    </div>
  )
}

export default ProfileCard
Create the RepoList component

When you have an array of data and need to render each item, React uses JavaScript's .map() method. Each mapped element needs a unique key prop so React can efficiently track which items changed, were added, or were removed.

  • Create a new file called RepoList.tsx inside the src/components folder.
  • Paste the following code into RepoList.tsx:
import { GitHubRepo } from '../App'

// Define the props - an array of repository objects
interface RepoListProps {
  repos: GitHubRepo[]
}

function RepoList({ repos }: RepoListProps) {
  return (
    <div className="repo-list">
      <h3>Repositories</h3>
      <div className="repo-grid">
        {/* Map over each repo and render a card */}
        {repos.map((repo) => (
          <div key={repo.id} className="repo-card">
            {/* Repo name as a clickable link to GitHub */}
            <a
              href={repo.html_url}
              target="_blank"
              rel="noopener noreferrer"
              className="repo-name"
            >
              {repo.name}
            </a>
            {/* Only show description if one exists */}
            {repo.description && (
              <p className="repo-description">{repo.description}</p>
            )}
            {/* Metadata row: language, stars, forks */}
            <div className="repo-meta">
              {repo.language && <span className="language">{repo.language}</span>}
              <span className="stars">&#9733; {repo.stargazers_count}</span>
              <span className="forks">&#9741; {repo.forks_count}</span>
            </div>
          </div>
        ))}
      </div>
    </div>
  )
}

export default RepoList

What does this code do?

  • repos.map((repo) => ...) loops through the array and returns a card for each repository.
  • key={repo.id} gives each card a unique identifier so React can efficiently update the list when data changes.
  • The conditional patterns (repo.description && and repo.language &&) skip rendering elements when those fields are null.
  • &#9733; and &#9741; are HTML entities that render a star and fork icon next to the counts.
  • Save RepoList.tsx.

Like ProfileCard, this component won't appear in the browser until you import it into App.tsx in the next substep.

Seeing a TypeScript error on the map callback?

Make sure you have the closing parenthesis after the .map() callback. The pattern is repos.map((repo) => (...)) with parentheses wrapping the returned JSX, not curly braces.

Help me fix the TypeScript error in my RepoList component

✔️ Awesome, I've got everything!

Great. Make sure you've saved RepoList.tsx before moving on.

ⓧ I'd like to double check the full code

import { GitHubRepo } from '../App'

interface RepoListProps {
  repos: GitHubRepo[]
}

function RepoList({ repos }: RepoListProps) {
  return (
    <div className="repo-list">
      <h3>Repositories</h3>
      <div className="repo-grid">
        {repos.map((repo) => (
          <div key={repo.id} className="repo-card">
            <a
              href={repo.html_url}
              target="_blank"
              rel="noopener noreferrer"
              className="repo-name"
            >
              {repo.name}
            </a>
            {repo.description && (
              <p className="repo-description">{repo.description}</p>
            )}
            <div className="repo-meta">
              {repo.language && <span className="language">{repo.language}</span>}
              <span className="stars">&#9733; {repo.stargazers_count}</span>
              <span className="forks">&#9741; {repo.forks_count}</span>
            </div>
          </div>
        ))}
      </div>
    </div>
  )
}

export default RepoList
Add conditional rendering in App.tsx

Your app has three possible states: loading, error, and success. Conditional rendering lets you show different UI for each state so the user always knows what's happening.

  • In src/App.tsx, find the import line at the top:
import SearchBar from './components/SearchBar'
  • Add these two import lines directly below it:
import ProfileCard from './components/ProfileCard'
import RepoList from './components/RepoList'
  • Now find the return block at the bottom of the App function. It currently looks like this:
  return (
    <div className="app">
      <h1>GitHub Profile Explorer</h1>
      <SearchBar onSearch={searchUser} />
      {user && <pre>{JSON.stringify(user, null, 2)}</pre>}
    </div>
  )
  • Replace the entire return block with this updated version:
  return (
    <div className="app">
      <h1>GitHub Profile Explorer</h1>
      <SearchBar onSearch={searchUser} />

      {/* Show loading message while fetching */}
      {loading && <p className="status">Loading...</p>}
      {/* Show error message if something went wrong */}
      {error && <p className="status error">{error}</p>}
      {/* Show profile and repos when data is available */}
      {user && (
        <>
          <ProfileCard user={user} />
          {repos.length > 0 ? (
            <RepoList repos={repos} />
          ) : (
            <p className="status">This user has no public repositories.</p>
          )}
        </>
      )}
    </div>
  )

What's happening here?

  • loading && <p>...</p> only renders the loading text when loading is true. Once the fetch finishes, it disappears.
  • error && <p>...</p> shows the error message only when something went wrong (like a 404 for a user that doesn't exist).
  • The <>...</> wrapper (called a Fragment) lets you return multiple sibling elements without adding an extra <div> to the DOM.
  • The ternary repos.length > 0 ? ... : ... shows the repo list if repos exist, or a "no public repositories" message if the array is empty.
  • Save App.tsx.
  • Switch to your browser at http://localhost:5173. Type octocat in the search bar and click Search.
  • You should see the profile card with an avatar, name, bio, and stats. Below it, a list of repository cards appears with names, descriptions, and star counts.
  • Now try searching a username that doesn't exist (like thisuserdoesnotexist12345). You should see an error message instead.

Page is blank or showing an error in the terminal?

  • Check that both import paths match the filenames exactly: './components/ProfileCard' and './components/RepoList'. Capitalization matters.
  • Make sure you removed the old {user && <pre>{JSON.stringify(user, null, 2)}</pre>} line. Leaving it in alongside the new code can cause unexpected layout.
  • If you see "Cannot find module" in the terminal, confirm that ProfileCard.tsx and RepoList.tsx are saved inside src/components/ (not in src/ directly).

Help me debug why my ProfileCard or RepoList isn't rendering

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files before moving on.

ⓧ I'd like to double check the full code

import { useState } from 'react'
import SearchBar from './components/SearchBar'
import ProfileCard from './components/ProfileCard'
import RepoList from './components/RepoList'

export interface GitHubUser {
  login: string
  avatar_url: string
  name: string | null
  bio: string | null
  html_url: string
  public_repos: number
  followers: number
  following: number
}

export interface GitHubRepo {
  id: number
  name: string
  html_url: string
  description: string | null
  stargazers_count: number
  language: string | null
  forks_count: number
}

function App() {
  const [user, setUser] = useState<GitHubUser | null>(null)
  const [repos, setRepos] = useState<GitHubRepo[]>([])
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  async function searchUser(username: string) {
    setLoading(true)
    setError(null)
    setUser(null)
    setRepos([])

    try {
      const userResponse = await fetch(
        `https://api.github.com/users/${username}`
      )

      if (!userResponse.ok) {
        if (userResponse.status === 404) {
          throw new Error(`User "${username}" not found.`)
        }
        throw new Error('Failed to fetch user data.')
      }

      const userData: GitHubUser = await userResponse.json()

      const reposResponse = await fetch(
        `https://api.github.com/users/${username}/repos?per_page=10&sort=updated`
      )

      if (!reposResponse.ok) {
        throw new Error('Failed to fetch repositories.')
      }

      const reposData: GitHubRepo[] = await reposResponse.json()

      setUser(userData)
      setRepos(reposData)
    } catch (err) {
      setError(err instanceof Error ? err.message : 'An unexpected error occurred.')
    } finally {
      setLoading(false)
    }
  }

  return (
    <div className="app">
      <h1>GitHub Profile Explorer</h1>
      <SearchBar onSearch={searchUser} />

      {loading && <p className="status">Loading...</p>}
      {error && <p className="status error">{error}</p>}
      {user && (
        <>
          <ProfileCard user={user} />
          {repos.length > 0 ? (
            <RepoList repos={repos} />
          ) : (
            <p className="status">This user has no public repositories.</p>
          )}
        </>
      )}
    </div>
  )
}

export default App

Your components are rendering real GitHub data. Next up, you'll add CSS styles to make the profile card and repository list look polished and professional.

Style and Polish the App

Your app fetches real data from GitHub and displays it in structured components. Right now though, everything renders as unstyled browser defaults.

In this step, you will add CSS to transform the raw HTML into a polished, professional-looking interface. You will style each component individually and add responsive behaviour so the app looks good on both desktop and mobile.

In this step, get ready to:
  • Add global styles and search bar styling.
  • Style the profile card and repository cards with hover effects.
  • Add responsive styles for mobile devices.
Add global styles and layout foundation

Every polished app starts with a CSS reset and base typography. These global styles remove inconsistent browser defaults and set the visual tone for the entire page.

  • In VS Code, open src/index.css (it should be empty).
  • Add the following global styles:
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background-color: #f6f8fa;
  color: #24292f;
  line-height: 1.6;
}

.app {
  max-width: 800px;
  margin: 0 auto;
  padding: 2rem 1rem;
}

h1 {
  text-align: center;
  margin-bottom: 1.5rem;
  font-size: 1.8rem;
}

What does this code do?

The * selector resets all margins and padding to zero and enables border-box sizing so padding never makes elements wider than you expect.

The body rule sets a system font stack (the same fonts GitHub uses), a light grey background, and comfortable line spacing.

The .app class constrains content to 800px and centres it horizontally with margin: 0 auto.

  • Save src/index.css.
  • Switch to your browser. You should see the heading centred on a light grey background with a clean sans-serif font.

Page still looks unstyled?

Check that src/main.tsx contains import './index.css' near the top. Without this import, Vite will not load your stylesheet.

Also confirm you saved the file. Hot module replacement only picks up saved changes.

help me figure out why my CSS isn't loading

The search bar and status messages are the primary interactive elements. They need clear visual separation and feedback states.

  • In src/index.css, add the following styles below the h1 rule:
.search-bar {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 2rem;
}

.search-bar input {
  flex: 1;
  padding: 0.75rem 1rem;
  font-size: 1rem;
  border: 1px solid #d0d7de;
  border-radius: 6px;
  outline: none;
}

.search-bar input:focus {
  border-color: #0969da;
  box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.15);
}

.search-bar button {
  padding: 0.75rem 1.5rem;
  font-size: 1rem;
  background-color: #2da44e;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-weight: 600;
}

.search-bar button:hover {
  background-color: #218838;
}

.status {
  text-align: center;
  padding: 1rem;
  font-size: 1.1rem;
}

.status.error {
  color: #cf222e;
}

What does this code do?

The .search-bar uses flexbox to place the input and button side by side. The input stretches to fill available space with flex: 1.

The :focus pseudo-class adds a blue ring when the input is active. This gives the user clear visual feedback about where they are typing.

The .status.error class turns the text red for error messages like "User not found."

  • Save src/index.css and switch to your browser.
  • You should see a styled search bar with a rounded input field and a green button. Click into the input to confirm the blue focus ring appears.

Button and input not side by side?

Make sure your .search-bar selector matches the className="search-bar" on the <form> element in SearchBar.tsx. CSS class names are case-sensitive.

help me debug my search bar layout

Style the profile card and repository cards

The profile card needs a clear visual hierarchy with the avatar on the left and user info on the right. Repository cards should be scannable with subtle hover feedback.

  • In src/index.css, add the profile card styles below the .status.error rule:
.profile-card {
  display: flex;
  gap: 1.5rem;
  padding: 1.5rem;
  background: white;
  border: 1px solid #d0d7de;
  border-radius: 8px;
  margin-bottom: 2rem;
}

.avatar {
  width: 120px;
  height: 120px;
  border-radius: 50%;
}

.profile-info h2 {
  margin-bottom: 0.25rem;
}

.profile-info .bio {
  color: #57606a;
  margin-bottom: 0.5rem;
}

.profile-info a {
  color: #0969da;
  text-decoration: none;
  font-size: 0.9rem;
}

.profile-info a:hover {
  text-decoration: underline;
}

.stats {
  display: flex;
  gap: 1rem;
  margin-top: 0.75rem;
  font-size: 0.9rem;
  color: #57606a;
}

What does this code do?

The .profile-card uses flexbox to place the avatar and info side by side with a gap between them. The white background and border create a card effect.

The .avatar class makes the image 120px square with border-radius: 50% to create a perfect circle. The .stats row displays repo count, followers, and following in a horizontal line.

  • Save src/index.css.
  • Search for octocat in your browser. You should see a white card with a circular avatar on the left and the user's name, bio, and stats on the right.

Avatar not circular?

Confirm your .avatar rule has both width: 120px and height: 120px. The image must be square for border-radius: 50% to produce a circle.

help me fix my avatar styling

Now add the repository card styles to make each repo visually distinct and interactive.

  • In src/index.css, add the repo list styles below the .stats rule:
.repo-list h3 {
  margin-bottom: 1rem;
}

.repo-grid {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

.repo-card {
  padding: 1rem 1.25rem;
  background: white;
  border: 1px solid #d0d7de;
  border-radius: 8px;
  transition: border-color 0.2s;
}

.repo-card:hover {
  border-color: #0969da;
}

.repo-name {
  font-size: 1.1rem;
  font-weight: 600;
  color: #0969da;
  text-decoration: none;
}

.repo-name:hover {
  text-decoration: underline;
}

.repo-description {
  color: #57606a;
  font-size: 0.9rem;
  margin-top: 0.4rem;
}

.repo-meta {
  display: flex;
  gap: 1rem;
  margin-top: 0.6rem;
  font-size: 0.85rem;
  color: #57606a;
}

.language {
  font-weight: 500;
}

What does this code do?

The .repo-grid stacks cards vertically with a small gap between each one. Each .repo-card has a white background and a transition property that smoothly animates the border colour on hover.

The .repo-name links use GitHub's signature blue colour. The .repo-meta row displays language, stars, and forks in a compact horizontal line.

  • Save src/index.css and switch to your browser.
  • With octocat still loaded, you should see a list of white repo cards below the profile. Hover over a card to see the border turn blue.

Hover effect not visible?

Make sure your .repo-card rule includes transition: border-color 0.2s and that .repo-card:hover sets border-color: #0969da. Without the transition, the colour change still happens but won't animate smoothly.

help me debug my hover styles

Add responsive styles and verify all states

On mobile devices, the side-by-side profile card layout gets cramped. A media query lets you switch to a stacked layout on smaller screens.

  • At the bottom of src/index.css, add this responsive media query:
@media (max-width: 600px) {
  .profile-card {
    flex-direction: column;
    align-items: center;
    text-align: center;
  }

  .stats {
    justify-content: center;
  }
}

What does this code do?

When the browser window is 600px wide or less, the profile card switches from a horizontal layout to a vertical one. The avatar stacks above the info text, and everything centres.

The rest of the app already works on mobile because the search bar, status messages, and repo cards use flexible units like rem and percentages that scale naturally.

  • Save src/index.css.
  • In your browser, search for octocat to load the full UI. Resize the window to a narrow width (below 600px). The profile card should stack vertically with the avatar centred above the text.
  • Test the error state by searching for a username that does not exist (like thisisnotarealuser999). You should see a red error message centred on the page.

Profile card not stacking on mobile?

Open your browser's DevTools and use the responsive/device toolbar to set the viewport width below 600px. Confirm the @media rule appears in the CSS panel for .profile-card. If it does not appear, check for a typo in @media (max-width: 600px).

help me fix my responsive styles

✔️ Awesome, I've got everything!

Great. Double check you have saved src/index.css and your browser shows the fully styled app.

ⓧ I'd like to double check the full code

Here is the complete src/index.css file. Compare yours against it:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background-color: #f6f8fa;
  color: #24292f;
  line-height: 1.6;
}

.app {
  max-width: 800px;
  margin: 0 auto;
  padding: 2rem 1rem;
}

h1 {
  text-align: center;
  margin-bottom: 1.5rem;
  font-size: 1.8rem;
}

.search-bar {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 2rem;
}

.search-bar input {
  flex: 1;
  padding: 0.75rem 1rem;
  font-size: 1rem;
  border: 1px solid #d0d7de;
  border-radius: 6px;
  outline: none;
}

.search-bar input:focus {
  border-color: #0969da;
  box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.15);
}

.search-bar button {
  padding: 0.75rem 1.5rem;
  font-size: 1rem;
  background-color: #2da44e;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-weight: 600;
}

.search-bar button:hover {
  background-color: #218838;
}

.status {
  text-align: center;
  padding: 1rem;
  font-size: 1.1rem;
}

.status.error {
  color: #cf222e;
}

.profile-card {
  display: flex;
  gap: 1.5rem;
  padding: 1.5rem;
  background: white;
  border: 1px solid #d0d7de;
  border-radius: 8px;
  margin-bottom: 2rem;
}

.avatar {
  width: 120px;
  height: 120px;
  border-radius: 50%;
}

.profile-info h2 {
  margin-bottom: 0.25rem;
}

.profile-info .bio {
  color: #57606a;
  margin-bottom: 0.5rem;
}

.profile-info a {
  color: #0969da;
  text-decoration: none;
  font-size: 0.9rem;
}

.profile-info a:hover {
  text-decoration: underline;
}

.stats {
  display: flex;
  gap: 1rem;
  margin-top: 0.75rem;
  font-size: 0.9rem;
  color: #57606a;
}

.repo-list h3 {
  margin-bottom: 1rem;
}

.repo-grid {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}

.repo-card {
  padding: 1rem 1.25rem;
  background: white;
  border: 1px solid #d0d7de;
  border-radius: 8px;
  transition: border-color 0.2s;
}

.repo-card:hover {
  border-color: #0969da;
}

.repo-name {
  font-size: 1.1rem;
  font-weight: 600;
  color: #0969da;
  text-decoration: none;
}

.repo-name:hover {
  text-decoration: underline;
}

.repo-description {
  color: #57606a;
  font-size: 0.9rem;
  margin-top: 0.4rem;
}

.repo-meta {
  display: flex;
  gap: 1rem;
  margin-top: 0.6rem;
  font-size: 0.85rem;
  color: #57606a;
}

.language {
  font-weight: 500;
}

@media (max-width: 600px) {
  .profile-card {
    flex-direction: column;
    align-items: center;
    text-align: center;
  }

  .stats {
    justify-content: center;
  }
}

Secret mission

Add Sorting and Filtering

Your app fetches repositories, but users have no way to explore them efficiently. What if someone has repos in five different languages and you only care about TypeScript? Add sort and filter controls that let users slice the repo list without making extra API calls.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, 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:

  • The github-explorer project folder on your Desktop.
  • The Vite dev server process running in your terminal.

Keep everything running

No action needed. Choose this if you are still actively building or want to continue experimenting with the app.

  • Your dev server stays running at http://localhost:5173 and will reflect any code changes you make.
  • Your github-explorer folder remains on your Desktop, ready to open in VS Code anytime.

Pause - I'll come back to this later

Shut down the running dev server to free up your terminal. Your project files stay on disk so you can resume later.

  • Switch to the terminal where the dev server is running.
  • Press Ctrl+C to stop the dev server.
  • When you come back, open the github-explorer folder in VS Code, open a terminal, and run npm run dev to start the server again.

Delete - I don't want to use this again

Remove the project folder and stop all running processes to start fresh.

  • Switch to the terminal where the dev server is running.
  • Press Ctrl+C to stop the dev server.
  • Delete the project folder by running these commands (macOS/Linux):
cd ~/Desktop
rm -rf github-explorer

On Windows, run this instead:

cd ~\Desktop
rmdir /s /q github-explorer
  • Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) on your Desktop. You should no longer see github-explorer in the output.

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully functional GitHub Profile Explorer that fetches real data from a live API and displays it in a polished, responsive interface.

You've learned how to:

  • Built a complete React + TypeScript application from scratch using Vite, creating reusable components (SearchBar, ProfileCard, RepoList) with typed props.
  • Managed application state with useState and fetched data from the GitHub REST API using async/await and the fetch API.
  • Implemented conditional rendering for loading, error, and success states, and rendered dynamic lists with .map() and key props.
  • Secret Mission: Added client-side sorting and filtering with derived state.

Ready to quiz yourself?