Build a Space Explorer Dashboard
Build a multi-page React app with NASA APIs, TanStack Query, and Zustand.
Introduction
30 Second Summary
Every day, NASA publishes stunning images of distant galaxies, nebulae, and planets. Meanwhile, thousands of asteroids zip past Earth at incredible speeds. All of this data is freely available through NASA's public APIs.
In this project, you will build a Space Explorer Dashboard that pulls live data from NASA to display space imagery and track near-Earth asteroids. You will use the modern React stack that frontend hiring managers test for in 2026.
What You'll Build
A multi-page React application that fetches real data from NASA's APIs, lets users browse astronomy photos and asteroids, and saves favorites to a client-side store.
By the end of this project, you'll have:
- A deployed, responsive React app with three pages: an APOD Gallery, an Asteroid Tracker, and a Favorites collection.
- Server state management with TanStack Query (caching, loading skeletons, error handling) and client state with Zustand (favorites that persist across navigation).
- A live Vercel URL you can share with employers that proves your proficiency with a modern frontend stack.
- Secret Mission: Add a dark/light theme toggle using Zustand and Tailwind's dark mode utility.
Are there any prerequisites?
This project assumes basic React experience (hooks, components, props) and familiarity with JavaScript/TypeScript fundamentals. If you have built at least one React app before, you are ready to go.
Set Up the Project and Get Your NASA API Key
Every React project starts with a solid foundation. Before you can build pages, fetch data, or manage state, you need a working development environment with the right tools in place.
In this step, you will scaffold a new React project using Vite, install the libraries that power the dashboard (TanStack Query, Zustand, Tailwind CSS, and React Router), and grab a free API key from NASA.
In this step, get ready to:
- Scaffold a Vite + React + TypeScript project.
- Install TanStack Query, Zustand, Tailwind CSS, and React Router.
- Get a free NASA API key and store it in your project.
Scaffold a Vite + React + TypeScript project
Vite is a modern build tool that gets a React project running in seconds. You will use it to generate all the starter files you need.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project by running this command:
npm create vite@latest space-explorer -- --template react-ts
What does this command do?
This tells npm to run the create-vite scaffolding tool. The space-explorer part names your project folder, and --template react-ts tells Vite to set up React with TypeScript.
- Move into your new project folder by running:
cd space-explorer
- Install the base dependencies that Vite generated by running:
npm install
- Confirm the project works by starting the dev server:
npm run dev
You should see output in your terminal showing a local URL (usually http://localhost:5173). Open that URL in your browser to see the default Vite + React page.
- Press Ctrl+C in your terminal to stop the dev server before continuing.
Command not found?
If you see an error when running npm create vite@latest, make sure Node.js is installed by running node --version in your terminal. You need Node.js installed to use npm.
help me fix an error when running npm create vite@latest
Install project dependencies
Your project needs four additional libraries. TanStack Query handles data fetching, Zustand manages client state, Tailwind CSS handles styling, and React Router provides page navigation.
- Install all four libraries plus the Tailwind Vite plugin by running:
npm install @tanstack/react-query@5 zustand@5 react-router@7 tailwindcss@4 @tailwindcss/vite
What are these packages?
- @tanstack/react-query fetches, caches, and synchronizes server data automatically.
- zustand is a tiny state management library for client-side state like favorites.
- react-router enables multi-page navigation without full page reloads.
- tailwindcss and @tailwindcss/vite provide utility-class styling that integrates directly with your Vite build.
Now you need to configure Tailwind CSS to work with Vite. This requires two small changes.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the space-explorer folder on your Desktop, then click Open.
- Open vite.config.ts in the VS Code sidebar and replace its entire contents with this code:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// Vite config with React and Tailwind CSS plugins
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
})
What does this config do?
This tells Vite to use two plugins during development and builds. The react() plugin handles JSX and hot module replacement. The tailwindcss() plugin scans your code for Tailwind utility classes and generates the matching CSS.
- Save vite.config.ts.
- Open src/index.css and replace its entire contents with this single line:
@import "tailwindcss";
What happened to all the old CSS?
Tailwind CSS v4 uses a single import line instead of the older @tailwind directives. This one line gives you access to every Tailwind utility class. No config file needed.
- Save src/index.css.
Finally, update the page title so it reflects your project.
- Open index.html in the project root (not inside src/).
- Find this line:
<title>Vite + React + TS</title>
- Replace it with:
<title>Space Explorer Dashboard</title>
- Save index.html.
Not sure which file is which?
Your space-explorer folder has an index.html at the top level (the project root) and a src/ folder with your TypeScript and CSS files. The index.html you need is at the root level, not inside src/.
help me find the right index.html file in my Vite project
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files: vite.config.ts, src/index.css, and index.html.
ⓧ I'd like to double check the full code
Here is what each file should look like at this point.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// Vite config with React and Tailwind CSS plugins
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
})
@import "tailwindcss";
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Space Explorer Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Get your NASA API key
NASA offers free APIs that serve real space data. You need an API key to authenticate your requests. The key is free and takes less than a minute to get.
- Open https://api.nasa.gov in your browser.
- Scroll down to the Generate API Key section.
- Fill in your first name, last name, and email address.
- Click Signup.
- Copy the API key shown on the page. You will also receive it by email.
What is this API key?
An API key identifies your app to NASA's servers. The free tier gives you 1,000 requests per hour, which is more than enough for development and normal use.
Now store the key in your project so your code can use it without exposing it publicly.
- In VS Code, right-click in the Explorer sidebar (the file list on the left).
- Click New File.
- Name the file .env and press Enter.
- Paste the following into .env, replacing your-api-key-here with the key you just copied:
VITE_NASA_API_KEY=your-api-key-here
Why does the variable start with VITE_?
Vite only exposes environment variables to your browser code if they start with VITE_. This prefix tells Vite it is safe to include in the client bundle. Variables without this prefix stay hidden from the browser.
- Save .env.
- Verify everything works together by starting the dev server again:
npm run dev
Open the URL shown in your terminal. You should see the default React page load without any errors in the browser console.
Seeing an error in the terminal?
If you see an error mentioning @tailwindcss/vite, make sure you ran the install command from the previous substep and that vite.config.ts matches the code shown above.
If the page loads but looks unstyled, that is expected. You have not added any Tailwind classes to your components yet.
help me debug an error when running npm run dev in my Vite React project with Tailwind CSS v4
Your project foundation is in place. You have a working React + TypeScript app with all the libraries installed and your NASA API key ready to go. Next up, you will set up routing and build the app layout that ties all your pages together.
Set Up Routing and App Layout
Your project environment is ready with all the dependencies installed. Now it's time to give your app structure.
A multi-page app needs two things: a routing system that maps URLs to pages, and a layout that wraps every page with shared UI (like a navigation bar). In this step, you'll wire up React Router for navigation, create a dark-themed layout shell, and connect TanStack Query's provider so every page can fetch data.
In this step, get ready to:
- Configure three routes: APOD Gallery, Asteroid Tracker, and Favorites.
- Create a shared layout component with a navigation bar and page wrapper.
- Set up TanStack Query's QueryClientProvider at the app root.
Configure React Router with three routes
Before you define routes, each route needs a component to render. You'll create simple placeholder pages first, then wire them into the router.
- Create a src/pages/ folder inside your src/ directory.
- Inside src/pages/, create a file called ApodGallery.tsx and paste the following:
function ApodGallery() {
return <h2>APOD Gallery</h2>;
}
export default ApodGallery;
- Create a second file in src/pages/ called AsteroidTracker.tsx and paste the following:
function AsteroidTracker() {
return <h2>Asteroid Tracker</h2>;
}
export default AsteroidTracker;
- Create a third file in src/pages/ called Favorites.tsx and paste the following:
function Favorites() {
return <h2>Favorites</h2>;
}
export default Favorites;
What are these placeholder pages?
Each file exports a simple React component that renders a heading. You'll replace these with full implementations in later steps.
They exist now so the router has something to render at each URL. Without them, your app would throw import errors.
Now that your pages exist, you can define the route map. React Router uses a Routes component that matches the current URL to one of its child Route elements.
- Open src/App.tsx (the Vite template created this file) and replace its entire contents with the following:
import { Routes, Route } from "react-router";
import Layout from "./components/Layout";
import ApodGallery from "./pages/ApodGallery";
import AsteroidTracker from "./pages/AsteroidTracker";
import Favorites from "./pages/Favorites";
function App() {
return (
<Routes>
{/* Wrap all pages in a shared Layout */}
<Route element={<Layout />}>
{/* index route renders at '/' */}
<Route index element={<ApodGallery />} />
<Route path="asteroids" element={<AsteroidTracker />} />
<Route path="favorites" element={<Favorites />} />
</Route>
</Routes>
);
}
export default App;
What does this code do?
The outer Route with element={<Layout />} wraps every page in a shared layout component. All three page routes are nested inside it.
The index prop means "render this component at the parent's exact path" (/). The other two routes match /asteroids and /favorites.
- Save src/App.tsx.
You won't see a change yet because the Layout component doesn't exist. You'll create it next.
✔️ Awesome, I've got everything!
Great. Make sure you've saved src/App.tsx before moving on.
ⓧ I'd like to double check the full code
import { Routes, Route } from "react-router";
import Layout from "./components/Layout";
import ApodGallery from "./pages/ApodGallery";
import AsteroidTracker from "./pages/AsteroidTracker";
import Favorites from "./pages/Favorites";
function App() {
return (
<Routes>
<Route element={<Layout />}>
<Route index element={<ApodGallery />} />
<Route path="asteroids" element={<AsteroidTracker />} />
<Route path="favorites" element={<Favorites />} />
</Route>
</Routes>
);
}
export default App;
Create the shared layout component
The Layout component renders the navigation bar at the top of every page. It uses React Router's Outlet component to inject whichever page matches the current URL.
- Create a src/components/ folder inside your src/ directory.
- Inside src/components/, create a file called Layout.tsx and paste the following:
import { NavLink, Outlet } from "react-router";
function Layout() {
return (
<div className="min-h-screen bg-gray-950 text-white">
{/* Navigation bar */}
<nav className="border-b border-gray-800 px-6 py-4">
<div className="mx-auto flex max-w-7xl items-center justify-between">
<h1 className="text-xl font-bold">🚀 Space Explorer</h1>
<div className="flex gap-6">
{/* NavLink highlights the active route automatically */}
<NavLink
to="/"
className={({ isActive }) =>
isActive ? "text-blue-400" : "text-gray-400 hover:text-white"
}
>
APOD Gallery
</NavLink>
<NavLink
to="/asteroids"
className={({ isActive }) =>
isActive ? "text-blue-400" : "text-gray-400 hover:text-white"
}
>
Asteroids
</NavLink>
<NavLink
to="/favorites"
className={({ isActive }) =>
isActive ? "text-blue-400" : "text-gray-400 hover:text-white"
}
>
Favorites
</NavLink>
</div>
</div>
</nav>
{/* Page content renders here */}
<main className="mx-auto max-w-7xl px-6 py-8">
<Outlet />
</main>
</div>
);
}
export default Layout;
What does this code do?
NavLink works like a regular link but adds an isActive flag when its to path matches the current URL. You use this to highlight the current page's link in blue.
Outlet is the slot where React Router injects the matched child route's component. When the user navigates to /asteroids, the AsteroidTracker component renders inside the Outlet.
The Tailwind CSS classes give the page a dark space theme with bg-gray-950 and white text.
- Save src/components/Layout.tsx.
You won't see the layout yet because main.tsx still needs to be updated with the router provider.
✔️ Awesome, I've got everything!
Great. Make sure you've saved src/components/Layout.tsx before moving on.
ⓧ I'd like to double check the full code
import { NavLink, Outlet } from "react-router";
function Layout() {
return (
<div className="min-h-screen bg-gray-950 text-white">
<nav className="border-b border-gray-800 px-6 py-4">
<div className="mx-auto flex max-w-7xl items-center justify-between">
<h1 className="text-xl font-bold">🚀 Space Explorer</h1>
<div className="flex gap-6">
<NavLink
to="/"
className={({ isActive }) =>
isActive ? "text-blue-400" : "text-gray-400 hover:text-white"
}
>
APOD Gallery
</NavLink>
<NavLink
to="/asteroids"
className={({ isActive }) =>
isActive ? "text-blue-400" : "text-gray-400 hover:text-white"
}
>
Asteroids
</NavLink>
<NavLink
to="/favorites"
className={({ isActive }) =>
isActive ? "text-blue-400" : "text-gray-400 hover:text-white"
}
>
Favorites
</NavLink>
</div>
</div>
</nav>
<main className="mx-auto max-w-7xl px-6 py-8">
<Outlet />
</main>
</div>
);
}
export default Layout;
Set up TanStack Query's QueryClientProvider at the app root
TanStack Query needs a QueryClient instance that manages caching and request settings for your entire app. You wrap the app in a QueryClientProvider so every component can use query hooks.
- Open src/main.tsx and replace its entire contents with the following:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import "./index.css";
// Configure TanStack Query: cache data for 5 minutes, retry failed requests once
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
retry: 1,
},
},
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>
);
What does this code do?
QueryClient holds global settings for every query in your app. staleTime: 1000 * 60 * 5 tells TanStack Query to keep fetched data fresh for 5 minutes before refetching.
retry: 1 means a failed request is retried once before showing an error. This avoids hammering NASA's API on transient network failures.
BrowserRouter enables client-side routing so the browser's URL bar updates without full page reloads. It must wrap your App component for Routes and NavLink to work.
Why wrap providers in this order?
QueryClientProvider goes outside BrowserRouter because query state is independent of the current route. Data cached on one page stays available when you navigate to another page.
- Save src/main.tsx.
- Check your terminal where npm run dev is running. If it's not running, start it by running:
npm run dev
- Open the localhost URL from the terminal output in your browser. You should see a dark page with a navigation bar showing "APOD Gallery", "Asteroids", and "Favorites" links.
- Click each nav link. The page heading changes to match the route and the active link turns blue.
Seeing a blank page or compile error?
Make sure all three page files exist in src/pages/ with the exact filenames: ApodGallery.tsx, AsteroidTracker.tsx, Favorites.tsx. Import paths in App.tsx are case-sensitive.
Check that src/components/Layout.tsx exists and the folder is named components (plural).
help me debug a blank page in my React Router app
✔️ Awesome, I've got everything!
Great. Double check you've saved src/main.tsx and can navigate between all three routes.
ⓧ I'd like to double check the full code
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
retry: 1,
},
},
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>
);
Your app now has a working navigation system and a global data-fetching layer. Next up, you'll build the APOD Gallery page that fetches real space images from NASA.
Build the APOD Gallery Page
Your app has routing and a layout with navigation. Now it needs real content.
In this step, you will connect to NASA's Astronomy Picture of the Day (APOD) API and display a gallery of stunning space images. You will use TanStack Query to handle data fetching, caching, and loading states automatically.
In this step, get ready to:
- Create a NASA API service with TypeScript interfaces and fetch functions.
- Set up a favorites store with Zustand so users can save images.
- Build a responsive APOD image grid with loading skeletons and error handling.
Create the NASA API service
Keeping API logic in a dedicated file means your components stay focused on rendering. The service file holds TypeScript interfaces that describe the shape of NASA's response data, plus async functions that make the actual HTTP requests.
- Create a new folder called api inside your src folder.
- Inside src/api, create a new file called nasa.ts and paste the following code:
const API_KEY = import.meta.env.VITE_NASA_API_KEY;
const BASE_URL = "https://api.nasa.gov";
// TypeScript interface describing an APOD response item
export interface ApodItem {
date: string;
title: string;
explanation: string;
url: string;
hdurl?: string;
media_type: string;
copyright?: string;
}
// Interface for a near-Earth object from the NEO feed
export interface NeoObject {
id: string;
name: string;
is_potentially_hazardous_asteroid: boolean;
close_approach_data: {
close_approach_date: string;
relative_velocity: { kilometers_per_hour: string };
miss_distance: { kilometers: string };
}[];
estimated_diameter: {
meters: { estimated_diameter_min: number; estimated_diameter_max: number };
};
}
// Wrapper interface for the NEO feed endpoint response
export interface NeoFeedResponse {
near_earth_objects: Record<string, NeoObject[]>;
element_count: number;
}
// Fetches APOD entries for a date range
export async function fetchApod(startDate: string, endDate: string): Promise<ApodItem[]> {
const response = await fetch(
`${BASE_URL}/planetary/apod?api_key=${API_KEY}&start_date=${startDate}&end_date=${endDate}`
);
if (!response.ok) {
throw new Error(`NASA API error: ${response.status}`);
}
return response.json();
}
// Fetches near-Earth objects for a date range
export async function fetchNeoFeed(startDate: string, endDate: string): Promise<NeoFeedResponse> {
const response = await fetch(
`${BASE_URL}/neo/rest/v1/feed?api_key=${API_KEY}&start_date=${startDate}&end_date=${endDate}`
);
if (!response.ok) {
throw new Error(`NASA API error: ${response.status}`);
}
return response.json();
}
What does this code do?
This file centralizes all NASA API communication in one place.
- API_KEY reads the key you stored in .env during Step 1. Vite exposes environment variables prefixed with VITE_ through import.meta.env.
- The three interfaces (ApodItem, NeoObject, NeoFeedResponse) give TypeScript the exact shape of NASA's JSON responses. Your editor will autocomplete fields and catch typos.
- fetchApod and fetchNeoFeed are async functions that call NASA's endpoints and throw an error if the response status is not OK.
- Save src/api/nasa.ts.
- You won't see a visual change yet. The next chunk uses these functions to load data into the gallery.
Seeing a red underline on import.meta.env?
Vite projects include a vite-env.d.ts file that declares import.meta.env types. Make sure you have src/vite-env.d.ts in your project (Vite's scaffold creates it automatically). If it is missing, create it with the single line /// <reference types="vite/client" />.
help me fix the import.meta.env type error in my nasa.ts file
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/api/nasa.ts before moving on.
ⓧ I'd like to double check the full code
const API_KEY = import.meta.env.VITE_NASA_API_KEY;
const BASE_URL = "https://api.nasa.gov";
export interface ApodItem {
date: string;
title: string;
explanation: string;
url: string;
hdurl?: string;
media_type: string;
copyright?: string;
}
export interface NeoObject {
id: string;
name: string;
is_potentially_hazardous_asteroid: boolean;
close_approach_data: {
close_approach_date: string;
relative_velocity: { kilometers_per_hour: string };
miss_distance: { kilometers: string };
}[];
estimated_diameter: {
meters: { estimated_diameter_min: number; estimated_diameter_max: number };
};
}
export interface NeoFeedResponse {
near_earth_objects: Record<string, NeoObject[]>;
element_count: number;
}
export async function fetchApod(startDate: string, endDate: string): Promise<ApodItem[]> {
const response = await fetch(
`${BASE_URL}/planetary/apod?api_key=${API_KEY}&start_date=${startDate}&end_date=${endDate}`
);
if (!response.ok) {
throw new Error(`NASA API error: ${response.status}`);
}
return response.json();
}
export async function fetchNeoFeed(startDate: string, endDate: string): Promise<NeoFeedResponse> {
const response = await fetch(
`${BASE_URL}/neo/rest/v1/feed?api_key=${API_KEY}&start_date=${startDate}&end_date=${endDate}`
);
if (!response.ok) {
throw new Error(`NASA API error: ${response.status}`);
}
return response.json();
}
Create the favorites store
The gallery lets users save images to a favorites collection. Zustand manages this client-side state in a tiny store that any component can read from without prop drilling.
- Create a new folder called store inside your src folder.
- Inside src/store, create a new file called favorites.ts and paste the following code:
import { create } from "zustand";
import type { ApodItem, NeoObject } from "../api/nasa";
// A favorite can be either an APOD image or an asteroid
export type FavoriteItem =
| { type: "apod"; data: ApodItem }
| { type: "asteroid"; data: NeoObject };
// Shape of the favorites store
interface FavoritesState {
favorites: FavoriteItem[];
addFavorite: (item: FavoriteItem) => void;
removeFavorite: (id: string) => void;
isFavorite: (id: string) => boolean;
}
// Helper to extract a unique ID from either item type
function getItemId(item: FavoriteItem): string {
return item.type === "apod" ? item.data.date : item.data.id;
}
// Create the Zustand store
export const useFavoritesStore = create<FavoritesState>((set, get) => ({
favorites: [],
addFavorite: (item) =>
set((state) => ({ favorites: [...state.favorites, item] })),
removeFavorite: (id) =>
set((state) => ({
favorites: state.favorites.filter((fav) => getItemId(fav) !== id),
})),
isFavorite: (id) => get().favorites.some((fav) => getItemId(fav) === id),
}));
What does this code do?
This creates a global store that holds an array of saved items. Any component can call useFavoritesStore() to read or modify favorites without passing props through the component tree.
- FavoriteItem is a union type. Each item is tagged with "apod" or "asteroid" so the Favorites page can group them later.
- addFavorite appends to the array. removeFavorite filters by ID. isFavorite checks if an item is already saved.
- getItemId uses the APOD date field or the asteroid id field as the unique identifier.
- Save src/store/favorites.ts.
- You won't see a change yet. The next chunk builds the gallery component that uses both the API service and this store.
Red underline on the zustand import?
Make sure you installed Zustand in Step 1. If the import can't resolve, run npm install zustand in your terminal and restart your dev server.
help me fix the zustand import error in my favorites store
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/store/favorites.ts before moving on.
ⓧ I'd like to double check the full code
import { create } from "zustand";
import type { ApodItem, NeoObject } from "../api/nasa";
export type FavoriteItem =
| { type: "apod"; data: ApodItem }
| { type: "asteroid"; data: NeoObject };
interface FavoritesState {
favorites: FavoriteItem[];
addFavorite: (item: FavoriteItem) => void;
removeFavorite: (id: string) => void;
isFavorite: (id: string) => boolean;
}
function getItemId(item: FavoriteItem): string {
return item.type === "apod" ? item.data.date : item.data.id;
}
export const useFavoritesStore = create<FavoritesState>((set, get) => ({
favorites: [],
addFavorite: (item) =>
set((state) => ({ favorites: [...state.favorites, item] })),
removeFavorite: (id) =>
set((state) => ({
favorites: state.favorites.filter((fav) => getItemId(fav) !== id),
})),
isFavorite: (id) => get().favorites.some((fav) => getItemId(fav) === id),
}));
Build the APOD Gallery component
Now you will bring everything together. The gallery component uses TanStack Query to fetch the last 7 days of APOD images, shows animated loading skeletons while the request is in flight, and renders a responsive grid of space photos with a save button on each card.
- Open src/pages/ApodGallery.tsx and replace its entire contents with the following code:
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchApod } from "../api/nasa";
import { useFavoritesStore } from "../store/favorites";
// Calculates today's date and 6 days ago for the API request
function getDateRange() {
const end = new Date();
const start = new Date();
start.setDate(end.getDate() - 6);
return {
start: start.toISOString().split("T")[0],
end: end.toISOString().split("T")[0],
};
}
function ApodGallery() {
// Initialize date range once on mount
const [dateRange] = useState(getDateRange);
const { addFavorite, removeFavorite, isFavorite } = useFavoritesStore();
// TanStack Query fetches and caches the APOD data
const { data, isLoading, error } = useQuery({
queryKey: ["apod", dateRange.start, dateRange.end],
queryFn: () => fetchApod(dateRange.start, dateRange.end),
});
// Loading state: animated skeleton cards
if (isLoading) {
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-72 animate-pulse rounded-lg bg-gray-800" />
))}
</div>
);
}
// Error state: red banner
if (error) {
return (
<div className="rounded-lg bg-red-900/30 p-6 text-red-300">
Failed to load APOD data. Please try again later.
</div>
);
}
// Main render: responsive image grid
return (
<div>
<h2 className="mb-6 text-2xl font-bold">Astronomy Picture of the Day</h2>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{data?.filter((item) => item.media_type === "image").map((item) => (
<div key={item.date} className="overflow-hidden rounded-lg bg-gray-900">
<img
src={item.url}
alt={item.title}
className="h-48 w-full object-cover"
/>
<div className="p-4">
<h3 className="font-semibold">{item.title}</h3>
<p className="mt-1 text-sm text-gray-400">{item.date}</p>
<button
onClick={() => {
if (isFavorite(item.date)) {
removeFavorite(item.date);
} else {
addFavorite({ type: "apod", data: item });
}
}}
className={`mt-3 rounded px-3 py-1 text-sm ${
isFavorite(item.date)
? "bg-yellow-600 text-white"
: "bg-gray-700 text-gray-300 hover:bg-gray-600"
}`}
>
{isFavorite(item.date) ? "★ Saved" : "☆ Save"}
</button>
</div>
</div>
))}
</div>
</div>
);
}
export default ApodGallery;
What does this code do?
This component has three render paths: loading, error, and success.
- getDateRange() calculates the last 7 days as ISO date strings. Passing a function to useState means it only runs once on mount.
- useQuery takes a queryKey (used for caching) and a queryFn (the actual fetch). TanStack Query handles retries, background refetching, and deduplication.
- The loading skeleton renders 6 animated gray boxes that pulse while data is in flight.
- The grid filters out non-image entries (some APOD days return videos) and renders each image as a card with a save/unsave toggle button.
- Save src/pages/ApodGallery.tsx.
- Open your browser at http://localhost:5173 (or refresh if it is already open). You should see a grid of space images with titles, dates, and a Save button on each card.
Seeing a blank page or console error?
- Check that your dev server is running. If it stopped, run npm run dev in your terminal.
- Open the browser console (F12). If you see a 403 error from NASA, your API key may be invalid or missing. Double check .env contains VITE_NASA_API_KEY= followed by your key, and restart the dev server (environment variable changes require a restart).
- If you see an import error for ../store/favorites, make sure you created src/store/favorites.ts in the previous substep.
help me debug why my APOD gallery is not loading images
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/pages/ApodGallery.tsx and confirmed the gallery loads in your browser.
ⓧ I'd like to double check the full code
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchApod } from "../api/nasa";
import { useFavoritesStore } from "../store/favorites";
function getDateRange() {
const end = new Date();
const start = new Date();
start.setDate(end.getDate() - 6);
return {
start: start.toISOString().split("T")[0],
end: end.toISOString().split("T")[0],
};
}
function ApodGallery() {
const [dateRange] = useState(getDateRange);
const { addFavorite, removeFavorite, isFavorite } = useFavoritesStore();
const { data, isLoading, error } = useQuery({
queryKey: ["apod", dateRange.start, dateRange.end],
queryFn: () => fetchApod(dateRange.start, dateRange.end),
});
if (isLoading) {
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-72 animate-pulse rounded-lg bg-gray-800" />
))}
</div>
);
}
if (error) {
return (
<div className="rounded-lg bg-red-900/30 p-6 text-red-300">
Failed to load APOD data. Please try again later.
</div>
);
}
return (
<div>
<h2 className="mb-6 text-2xl font-bold">Astronomy Picture of the Day</h2>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{data?.filter((item) => item.media_type === "image").map((item) => (
<div key={item.date} className="overflow-hidden rounded-lg bg-gray-900">
<img
src={item.url}
alt={item.title}
className="h-48 w-full object-cover"
/>
<div className="p-4">
<h3 className="font-semibold">{item.title}</h3>
<p className="mt-1 text-sm text-gray-400">{item.date}</p>
<button
onClick={() => {
if (isFavorite(item.date)) {
removeFavorite(item.date);
} else {
addFavorite({ type: "apod", data: item });
}
}}
className={`mt-3 rounded px-3 py-1 text-sm ${
isFavorite(item.date)
? "bg-yellow-600 text-white"
: "bg-gray-700 text-gray-300 hover:bg-gray-600"
}`}
>
{isFavorite(item.date) ? "★ Saved" : "☆ Save"}
</button>
</div>
</div>
))}
</div>
</div>
);
}
export default ApodGallery;
Why TanStack Query instead of useEffect?
A plain useEffect fetch requires you to manually manage loading booleans, error state, caching, and race conditions. TanStack Query handles all of that with a single useQuery call.
It also caches responses by queryKey. If the user navigates away and comes back, the cached data renders instantly while a background refetch happens silently. This is the standard data-fetching pattern in production React apps.
Your gallery is live and pulling real space imagery from NASA. Next up, you will build the Asteroid Tracker page to display near-Earth objects with hazard filtering.
Build the Asteroid Tracker Page
Your APOD Gallery is live and fetching real space imagery. Now it's time to build the second major page of your dashboard.
The Asteroid Tracker pulls data from NASA's Near-Earth Object (NEO) Feed, which returns asteroids grouped by date. You'll flatten that nested structure into a single list, add a hazard filter, and display key details like size, speed, and miss distance.
In this step, get ready to:
- Fetch and process near-Earth asteroid data using TanStack Query.
- Build a "potentially hazardous" filter toggle.
- Display asteroid cards with name, size, velocity, miss distance, and hazard status.
Fetch and process asteroid data
The fetchNeoFeed function you created in src/api/nasa.ts already handles the API call. Now you need a page component that uses it.
NASA's NEO Feed returns asteroids grouped by date inside a near_earth_objects object. You'll flatten all dates into one array, then let the user filter by hazard level.
- Open src/pages/AsteroidTracker.tsx in your editor.
- Replace the entire file contents with the following code:
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchNeoFeed } from "../api/nasa";
import { useFavoritesStore } from "../store/favorites";
import type { NeoObject } from "../api/nasa";
// Helper to get today's date and 2 days ahead
function getTodayRange() {
const today = new Date();
const end = new Date();
end.setDate(today.getDate() + 2);
return {
start: today.toISOString().split("T")[0],
end: end.toISOString().split("T")[0],
};
}
function AsteroidTracker() {
// Date range state initialized once on mount
const [dateRange] = useState(getTodayRange);
// Toggle for filtering hazardous asteroids
const [showHazardousOnly, setShowHazardousOnly] = useState(false);
const { addFavorite, removeFavorite, isFavorite } = useFavoritesStore();
// Fetch asteroid data for the date range
const { data, isLoading, error } = useQuery({
queryKey: ["neo", dateRange.start, dateRange.end],
queryFn: () => fetchNeoFeed(dateRange.start, dateRange.end),
});
// Flatten the date-grouped response into a single array
const asteroids: NeoObject[] = data
? Object.values(data.near_earth_objects).flat()
: [];
// Apply the hazardous filter
const filtered = showHazardousOnly
? asteroids.filter((a) => a.is_potentially_hazardous_asteroid)
: asteroids;
// Loading skeleton while data is being fetched
if (isLoading) {
return (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-24 animate-pulse rounded-lg bg-gray-800" />
))}
</div>
);
}
// Error state if the API call fails
if (error) {
return (
<div className="rounded-lg bg-red-900/30 p-6 text-red-300">
Failed to load asteroid data. Please try again later.
</div>
);
}
What does this code do?
The getTodayRange helper calculates today and two days ahead. This gives you a small window of asteroid data without hitting API rate limits.
The useQuery hook fetches asteroid data with the same caching and retry logic as the APOD page. The Object.values(data.near_earth_objects).flat() line flattens the date-grouped response into one flat array for easier rendering.
You won't see a change yet because the component doesn't have a return statement for the main UI. The next chunk adds that.
Build the filter toggle and asteroid cards
With data flowing in, you need the visual layer. This includes a header with the hazardous-only checkbox and a card for each asteroid showing its key stats.
- In the same file, directly below the error return block, add the main return statement and close the component:
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h2 className="text-2xl font-bold">Near-Earth Asteroids</h2>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={showHazardousOnly}
onChange={(e) => setShowHazardousOnly(e.target.checked)}
className="rounded"
/>
Hazardous only
</label>
</div>
<div className="space-y-4">
{filtered.map((asteroid) => {
const approach = asteroid.close_approach_data[0];
const diameter = asteroid.estimated_diameter.meters;
return (
<div
key={asteroid.id}
className="flex items-center justify-between rounded-lg bg-gray-900 p-4"
>
<div>
<h3 className="font-semibold">
{asteroid.name}
{asteroid.is_potentially_hazardous_asteroid && (
<span className="ml-2 rounded bg-red-700 px-2 py-0.5 text-xs">
Hazardous
</span>
)}
</h3>
<p className="mt-1 text-sm text-gray-400">
Size: {diameter.estimated_diameter_min.toFixed(0)}-
{diameter.estimated_diameter_max.toFixed(0)}m | Speed:{" "}
{parseFloat(approach.relative_velocity.kilometers_per_hour).toFixed(0)} km/h
</p>
<p className="text-sm text-gray-400">
Miss distance: {parseFloat(approach.miss_distance.kilometers).toLocaleString()} km |
Date: {approach.close_approach_date}
</p>
</div>
<button
onClick={() => {
if (isFavorite(asteroid.id)) {
removeFavorite(asteroid.id);
} else {
addFavorite({ type: "asteroid", data: asteroid });
}
}}
className={`rounded px-3 py-1 text-sm ${
isFavorite(asteroid.id)
? "bg-yellow-600 text-white"
: "bg-gray-700 text-gray-300 hover:bg-gray-600"
}`}
>
{isFavorite(asteroid.id) ? "★ Saved" : "☆ Save"}
</button>
</div>
);
})}
{filtered.length === 0 && (
<p className="text-center text-gray-500">No asteroids match your filters.</p>
)}
</div>
</div>
);
}
export default AsteroidTracker;
What does this code do?
- The header row places the page title and a checkbox side by side using Tailwind's flex and justify-between utilities.
- The checkbox toggles showHazardousOnly, which controls whether the filtered array includes all asteroids or only hazardous ones.
- Each card extracts the first close approach entry and the diameter estimate, then formats the numbers into human-readable km/h and km values.
- The Save button works identically to the APOD page, toggling favorites via the Zustand store.
- The empty-state message at the bottom appears only when the filtered list has zero items.
- Save src/pages/AsteroidTracker.tsx.
- In your browser, click Asteroids in the navigation bar.
- You should see a list of asteroid cards with names, sizes, speeds, and miss distances. A "Hazardous only" checkbox appears in the top right corner.
Don't see any asteroid cards?
- Check your browser console for API errors. If you see a 403 status, confirm your VITE_NASA_API_KEY is set correctly in .env and that you restarted the dev server after adding it.
- If you see a TypeScript error about useFavoritesStore, make sure src/store/favorites.ts exists from the previous step.
- If the page loads but shows no cards and no error, the NEO Feed may have returned zero asteroids for that date range. Try toggling the filter checkbox off.
help me debug my Asteroid Tracker page
✔️ 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
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchNeoFeed } from "../api/nasa";
import { useFavoritesStore } from "../store/favorites";
import type { NeoObject } from "../api/nasa";
function getTodayRange() {
const today = new Date();
const end = new Date();
end.setDate(today.getDate() + 2);
return {
start: today.toISOString().split("T")[0],
end: end.toISOString().split("T")[0],
};
}
function AsteroidTracker() {
const [dateRange] = useState(getTodayRange);
const [showHazardousOnly, setShowHazardousOnly] = useState(false);
const { addFavorite, removeFavorite, isFavorite } = useFavoritesStore();
const { data, isLoading, error } = useQuery({
queryKey: ["neo", dateRange.start, dateRange.end],
queryFn: () => fetchNeoFeed(dateRange.start, dateRange.end),
});
const asteroids: NeoObject[] = data
? Object.values(data.near_earth_objects).flat()
: [];
const filtered = showHazardousOnly
? asteroids.filter((a) => a.is_potentially_hazardous_asteroid)
: asteroids;
if (isLoading) {
return (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-24 animate-pulse rounded-lg bg-gray-800" />
))}
</div>
);
}
if (error) {
return (
<div className="rounded-lg bg-red-900/30 p-6 text-red-300">
Failed to load asteroid data. Please try again later.
</div>
);
}
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h2 className="text-2xl font-bold">Near-Earth Asteroids</h2>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={showHazardousOnly}
onChange={(e) => setShowHazardousOnly(e.target.checked)}
className="rounded"
/>
Hazardous only
</label>
</div>
<div className="space-y-4">
{filtered.map((asteroid) => {
const approach = asteroid.close_approach_data[0];
const diameter = asteroid.estimated_diameter.meters;
return (
<div
key={asteroid.id}
className="flex items-center justify-between rounded-lg bg-gray-900 p-4"
>
<div>
<h3 className="font-semibold">
{asteroid.name}
{asteroid.is_potentially_hazardous_asteroid && (
<span className="ml-2 rounded bg-red-700 px-2 py-0.5 text-xs">
Hazardous
</span>
)}
</h3>
<p className="mt-1 text-sm text-gray-400">
Size: {diameter.estimated_diameter_min.toFixed(0)}-
{diameter.estimated_diameter_max.toFixed(0)}m | Speed:{" "}
{parseFloat(approach.relative_velocity.kilometers_per_hour).toFixed(0)} km/h
</p>
<p className="text-sm text-gray-400">
Miss distance: {parseFloat(approach.miss_distance.kilometers).toLocaleString()} km |
Date: {approach.close_approach_date}
</p>
</div>
<button
onClick={() => {
if (isFavorite(asteroid.id)) {
removeFavorite(asteroid.id);
} else {
addFavorite({ type: "asteroid", data: asteroid });
}
}}
className={`rounded px-3 py-1 text-sm ${
isFavorite(asteroid.id)
? "bg-yellow-600 text-white"
: "bg-gray-700 text-gray-300 hover:bg-gray-600"
}`}
>
{isFavorite(asteroid.id) ? "★ Saved" : "☆ Save"}
</button>
</div>
);
})}
{filtered.length === 0 && (
<p className="text-center text-gray-500">No asteroids match your filters.</p>
)}
</div>
</div>
);
}
export default AsteroidTracker;
Your Asteroid Tracker is complete. Next up, you'll build the Favorites page that ties the APOD Gallery and Asteroid Tracker together with a shared collection.
Add Favorites with Zustand
Your APOD Gallery and Asteroid Tracker are pulling live data from NASA. Users can see stunning space imagery and track near-Earth asteroids, but there's no way to save the items they find interesting.
In this step, you'll create a Zustand store that manages a favorites collection in memory. Once the store exists, the Save buttons on your existing pages will connect automatically, and you'll build a dedicated Favorites page to display saved items.
In this step, get ready to:
- Create a Zustand store with add, remove, and check-if-favorite actions.
- Verify that the Save buttons on both pages connect to the store.
- Build the Favorites page with grouped display and remove functionality.
Create the Zustand favorites store
Your app needs a place to hold the user's saved items that persists across page navigation. A Zustand store lives outside of React's component tree, so favorites stay in memory even when the user switches between routes.
- Create a new folder called store inside your src directory.
- Create a file called favorites.ts inside src/store/ and add the following code:
import { create } from "zustand";
import type { ApodItem, NeoObject } from "../api/nasa";
// A favorite can be either an APOD image or an asteroid
export type FavoriteItem =
| { type: "apod"; data: ApodItem }
| { type: "asteroid"; data: NeoObject };
// Shape of the store's state and actions
interface FavoritesState {
favorites: FavoriteItem[];
addFavorite: (item: FavoriteItem) => void;
removeFavorite: (id: string) => void;
isFavorite: (id: string) => boolean;
}
// Helper to extract a unique ID from either item type
function getItemId(item: FavoriteItem): string {
return item.type === "apod" ? item.data.date : item.data.id;
}
export const useFavoritesStore = create<FavoritesState>((set, get) => ({
favorites: [],
addFavorite: (item) =>
set((state) => ({ favorites: [...state.favorites, item] })),
removeFavorite: (id) =>
set((state) => ({
favorites: state.favorites.filter((fav) => getItemId(fav) !== id),
})),
isFavorite: (id) => get().favorites.some((fav) => getItemId(fav) === id),
}));
What does this code do?
This file defines your entire favorites system in about 30 lines.
- FavoriteItem is a discriminated union type. Each favorite is tagged as either "apod" or "asteroid", so TypeScript knows which data shape to expect.
- getItemId extracts a unique identifier from either type. APOD items use their date as ID, asteroids use their id field.
- create() from Zustand builds the store. The set function updates state immutably, and get reads the current state (used by isFavorite).
Why Zustand instead of Redux?
Zustand requires zero boilerplate. There are no providers, no reducers, no action creators. You define your state and actions in one function call, then use the store as a hook anywhere in your app.
For client state like favorites that doesn't come from a server, Zustand is the standard pairing with TanStack Query in modern React apps. TanStack Query handles server state (API data). Zustand handles client state (user preferences).
- Save src/store/favorites.ts.
- Check your terminal where the dev server is running. The compile errors should clear now that the store file exists.
Still seeing compile errors?
- Make sure the file is at src/store/favorites.ts (not src/stores/ or src/store/favorites.tsx). The import path in your other files is ../store/favorites.
- Confirm the file exports both FavoriteItem (the type) and useFavoritesStore (the hook). Both are used by your page components.
- help me fix compile errors in my Zustand store file
Verify the Save buttons connect to the store
Your APOD Gallery and Asteroid Tracker pages already have Save buttons that import from the store you just created. Now that the file exists, those buttons are fully functional.
- Open your browser and navigate to the APOD Gallery (the home page).
- Click the ☆ Save button on any image card.
- The button should change to ★ Saved with a yellow background.
- Navigate to the Asteroids page and save an asteroid too.
- Switch back to the APOD Gallery. Your previously saved item should still show ★ Saved.
Why do favorites persist across pages?
Zustand stores live in a JavaScript module outside React's component tree. When you navigate between pages, React unmounts and remounts components, but the store's state stays in memory. This is exactly the kind of state that should NOT live in a component's local useState.
Button not changing when clicked?
- Open your browser's developer console and check for errors. A common issue is a typo in the store file that prevents the hook from returning properly.
- Make sure your dev server reloaded after you saved favorites.ts. Try stopping and restarting it if needed.
- help me debug my Save button not working with Zustand
Build the Favorites page
Users can save items, but they need a dedicated page to view and manage their collection. The Favorites page groups saved items by type and lets users remove anything they no longer want.
- Open src/pages/Favorites.tsx and replace all of its contents with the following code:
import { useFavoritesStore } from "../store/favorites";
function Favorites() {
const { favorites, removeFavorite } = useFavoritesStore();
// Split favorites into two groups by type
const apodFavorites = favorites.filter((f) => f.type === "apod");
const asteroidFavorites = favorites.filter((f) => f.type === "asteroid");
// Show empty state when no favorites exist
if (favorites.length === 0) {
return (
<div className="text-center text-gray-500">
<p className="text-lg">No favorites yet.</p>
<p className="mt-2">Browse the APOD Gallery or Asteroid Tracker and save items you like.</p>
</div>
);
}
return (
<div>
<h2 className="mb-6 text-2xl font-bold">Your Favorites</h2>
{apodFavorites.length > 0 && (
<section className="mb-8">
<h3 className="mb-4 text-lg font-semibold text-blue-400">APOD Images</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{apodFavorites.map((fav) => {
if (fav.type !== "apod") return null;
return (
<div key={fav.data.date} className="overflow-hidden rounded-lg bg-gray-900">
<img src={fav.data.url} alt={fav.data.title} className="h-40 w-full object-cover" />
<div className="p-3">
<h4 className="font-semibold">{fav.data.title}</h4>
<button
onClick={() => removeFavorite(fav.data.date)}
className="mt-2 text-sm text-red-400 hover:text-red-300"
>
Remove
</button>
</div>
</div>
);
})}
</div>
</section>
)}
{asteroidFavorites.length > 0 && (
<section>
<h3 className="mb-4 text-lg font-semibold text-blue-400">Asteroids</h3>
<div className="space-y-3">
{asteroidFavorites.map((fav) => {
if (fav.type !== "asteroid") return null;
return (
<div key={fav.data.id} className="flex items-center justify-between rounded-lg bg-gray-900 p-4">
<div>
<h4 className="font-semibold">{fav.data.name}</h4>
{fav.data.is_potentially_hazardous_asteroid && (
<span className="text-xs text-red-400">Potentially Hazardous</span>
)}
</div>
<button
onClick={() => removeFavorite(fav.data.id)}
className="text-sm text-red-400 hover:text-red-300"
>
Remove
</button>
</div>
);
})}
</div>
</section>
)}
</div>
);
}
export default Favorites;
What does this code do?
- The component pulls favorites and removeFavorite from the Zustand store. It only needs these two values since this page displays and removes (not adds).
- The .filter() calls split the array into APOD and asteroid groups so each section renders independently.
- The empty state (favorites.length === 0) gives users clear guidance instead of showing a blank page.
- Each item's Remove button calls removeFavorite with the item's unique ID. The store filters it out, and React re-renders automatically.
- Save src/pages/Favorites.tsx.
- In your browser, save a couple of items from the APOD Gallery and Asteroid Tracker pages first.
- Click Favorites in the navigation bar. You should see your saved items grouped under "APOD Images" and "Asteroids" headings.
- Click Remove on any item. It should disappear from the list immediately.
Favorites page showing empty state even after saving items?
- If you refreshed the browser, your favorites are gone. Zustand stores data in memory only, so a full page refresh clears everything. Save items again without refreshing.
- Make sure you clicked ☆ Save (not just hovered) on the APOD or Asteroid pages. The button should show ★ Saved before you navigate to Favorites.
- help me debug my Favorites page not showing saved items
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the full code for both files you created or updated in this step.
import { create } from "zustand";
import type { ApodItem, NeoObject } from "../api/nasa";
export type FavoriteItem =
| { type: "apod"; data: ApodItem }
| { type: "asteroid"; data: NeoObject };
interface FavoritesState {
favorites: FavoriteItem[];
addFavorite: (item: FavoriteItem) => void;
removeFavorite: (id: string) => void;
isFavorite: (id: string) => boolean;
}
function getItemId(item: FavoriteItem): string {
return item.type === "apod" ? item.data.date : item.data.id;
}
export const useFavoritesStore = create<FavoritesState>((set, get) => ({
favorites: [],
addFavorite: (item) =>
set((state) => ({ favorites: [...state.favorites, item] })),
removeFavorite: (id) =>
set((state) => ({
favorites: state.favorites.filter((fav) => getItemId(fav) !== id),
})),
isFavorite: (id) => get().favorites.some((fav) => getItemId(fav) === id),
}));
import { useFavoritesStore } from "../store/favorites";
function Favorites() {
const { favorites, removeFavorite } = useFavoritesStore();
const apodFavorites = favorites.filter((f) => f.type === "apod");
const asteroidFavorites = favorites.filter((f) => f.type === "asteroid");
if (favorites.length === 0) {
return (
<div className="text-center text-gray-500">
<p className="text-lg">No favorites yet.</p>
<p className="mt-2">Browse the APOD Gallery or Asteroid Tracker and save items you like.</p>
</div>
);
}
return (
<div>
<h2 className="mb-6 text-2xl font-bold">Your Favorites</h2>
{apodFavorites.length > 0 && (
<section className="mb-8">
<h3 className="mb-4 text-lg font-semibold text-blue-400">APOD Images</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{apodFavorites.map((fav) => {
if (fav.type !== "apod") return null;
return (
<div key={fav.data.date} className="overflow-hidden rounded-lg bg-gray-900">
<img src={fav.data.url} alt={fav.data.title} className="h-40 w-full object-cover" />
<div className="p-3">
<h4 className="font-semibold">{fav.data.title}</h4>
<button
onClick={() => removeFavorite(fav.data.date)}
className="mt-2 text-sm text-red-400 hover:text-red-300"
>
Remove
</button>
</div>
</div>
);
})}
</div>
</section>
)}
{asteroidFavorites.length > 0 && (
<section>
<h3 className="mb-4 text-lg font-semibold text-blue-400">Asteroids</h3>
<div className="space-y-3">
{asteroidFavorites.map((fav) => {
if (fav.type !== "asteroid") return null;
return (
<div key={fav.data.id} className="flex items-center justify-between rounded-lg bg-gray-900 p-4">
<div>
<h4 className="font-semibold">{fav.data.name}</h4>
{fav.data.is_potentially_hazardous_asteroid && (
<span className="text-xs text-red-400">Potentially Hazardous</span>
)}
</div>
<button
onClick={() => removeFavorite(fav.data.id)}
className="text-sm text-red-400 hover:text-red-300"
>
Remove
</button>
</div>
);
})}
</div>
</section>
)}
</div>
);
}
export default Favorites;
All three pages of your Space Explorer Dashboard are fully functional. Users can browse NASA imagery, track asteroids, and curate a personal favorites collection. Next up, you'll deploy this to Vercel so the world can see it.
Deploy to Vercel
Your Space Explorer Dashboard is fully built and running locally. The final step is getting it live on the internet so anyone can visit it.
You will push your code to GitHub and connect the repository to Vercel. Vercel automatically builds and deploys your app every time you push new code.
In this step, get ready to:
- Push your project to a GitHub repository.
- Connect the repository to Vercel and configure your NASA API key as an environment variable.
- Verify your live deployment works end-to-end.
Push the project to GitHub
Before pushing to GitHub, you need to make sure your .env file (which contains your NASA API key) does not get committed. The default Vite .gitignore does not include .env, so you need to add it.
- Open .gitignore in your project root (inside your space-explorer folder).
- Add .env on a new line at the end of the file.
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment variables
.env
Why exclude .env?
Your .env file contains your NASA API key. Committing secrets to GitHub exposes them publicly. You will add the key as a secure environment variable in Vercel instead.
- Save .gitignore.
- Initialize a git repository and make your first commit by running these commands in your terminal:
git init
git add .
git commit -m "Initial commit: Space Explorer Dashboard"
- Open GitHub in your browser and create a new repository.
- Name it space-explorer (or any name you prefer).
- Leave it set to Public so employers can see it.
- Do not check any boxes for README, .gitignore, or license (you already have these locally).
- Click Create repository.
GitHub will show you commands to push an existing repository. Copy and run the two commands it provides:
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/space-explorer.git"]]
git push -u origin main
Push failed with an error?
- If you see a message about the branch name, your default branch might be called master instead of main. Run git branch -M main to rename it, then push again.
- If you see an authentication error, make sure you are signed into GitHub in your terminal. GitHub requires a personal access token or SSH key for pushing.
Still stuck? Help me push my code to GitHub
- Refresh your GitHub repository page. You should see all your project files listed (src/, index.html, package.json) but no .env file.
Connect your repository to Vercel
Vercel connects directly to your GitHub repository. Every time you push new code, Vercel rebuilds and redeploys your app automatically.
- Open vercel.com and sign in with your GitHub account.
- From the dashboard, click Add New... and select Project.
- Find your space-explorer repository in the list and click Import.
Vercel will show you the project configuration page. Before deploying, you need to add your NASA API key as an environment variable.
- Scroll down to the Environment Variables section on the configuration page.
- In the Key field, enter VITE_NASA_API_KEY.
- In the Value field, paste your actual NASA API key (the same value from your local .env file).
- Leave the environment selection as the default (all environments).
- Click Deploy.
Why VITE_NASA_API_KEY specifically?
Vite only exposes environment variables to client-side code if they start with VITE_. Your src/api/nasa.ts file reads this variable using import.meta.env.VITE_NASA_API_KEY. The name in Vercel must match exactly.
Vercel will now build your project. This takes about 30-60 seconds. You will see a build log with progress updates.
Build failed?
- Check the build log for the specific error. TypeScript errors are the most common cause.
- Make sure your project builds locally first by running npm run build in your terminal. Fix any errors that appear, commit, and push again.
- Verify the Framework Preset is set to Vite on the project configuration page. Vercel usually detects this automatically.
Need help? Help me debug my Vercel build failure
Verify the live deployment
Once the build succeeds, Vercel gives you a live URL for your app.
- Click the deployment URL that Vercel shows you (it looks like your-project.vercel.app).
- Verify the APOD Gallery loads with space images on the home page.
- Click Asteroids in the navigation bar. Verify that asteroid data loads.
- Save a few items to favorites, then click Favorites to confirm they appear.
- Copy your live URL. This is what you share with employers.
Pages load but no images or data appear?
- Open your browser's developer tools (F12) and check the Console tab for errors.
- If you see a 403 error from the NASA API, your VITE_NASA_API_KEY environment variable is missing or misspelled in Vercel. Go to your project's Settings tab, then Environment Variables in the sidebar, and confirm the key name and value are correct.
- After fixing the variable, you must redeploy for changes to take effect. Go to the Deployments tab and click the three-dot menu on the latest deployment to redeploy.
Still stuck? Help me debug why my NASA API calls fail on Vercel
Secret mission
Dark/Light Theme Toggle
Your dashboard looks great in dark mode. But what if a user wants to switch to a lighter look? In this secret mission, you'll add a theme toggle that demonstrates Zustand for UI state management alongside Tailwind's dark mode utility.
Clean Up Your Resources
Clean Up Your Resources
This project runs locally and uses free services (NASA API, Vercel free tier, GitHub). There are no ongoing costs, so you can safely leave everything as-is.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Local project folder with your React app source code.
- GitHub repository storing your project.
- Vercel project hosting your live deployment.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep your live Vercel URL available to share with employers.
- Your Vercel deployment stays live at its URL indefinitely on the free tier.
- Your GitHub repository remains accessible.
- Your local project folder stays on your machine.
Pause - I'll come back to this later
Free up local resources while keeping your cloud services intact so you can resume later.
- Your Vercel deployment and GitHub repo cost nothing to keep. Leave them running.
- Close VS Code and any running terminal sessions to free up memory.
- If you ran a dev server, press Ctrl+C in the terminal to stop it.
Delete - I don't want to use this again
Remove all project resources permanently. This cannot be undone.
- Delete the local project folder by running the appropriate command for your operating system.
- On macOS/Linux:
cd ~/Desktop
rm -rf space-explorer
- On Windows:
cd ~\Desktop
rmdir /s /q space-explorer
- Delete your Vercel project by opening your Vercel dashboard, selecting the project, then navigating to Settings.
- Scroll down to the Danger Zone section and click Delete.
- Type the confirmation phrase when prompted, then confirm the deletion.
- Delete your GitHub repository by opening the repository page and clicking Settings.
- Scroll to the Danger Zone section and click Delete this repository.
- Type your repository name to confirm, then click Delete this repository again.
That's a Wrap!
That's a Wrap!
Nice work! You just built and deployed a production-quality React application that pulls live data from NASA's APIs.
This is the exact type of project frontend hiring managers use as mid-level take-home challenges. You now have a live URL to prove you can build it.
You've learned how to:
- Fetch and cache live API data with TanStack Query, including loading skeletons and error states that handle real-world network conditions.
- Manage client-side state with Zustand, building a favorites collection that persists across page navigation without prop drilling.
- Deploy a React app to Vercel with environment variables, giving you a shareable live URL that proves your frontend skills.
- Secret Mission: Added a dark/light theme toggle demonstrating UI state management with Zustand and Tailwind's dark mode utility.
Ready to quiz yourself?