Build an Offline Space Encyclopedia PWA
Build a SvelteKit PWA with offline space data, dynamic routes, and service workers.
Introduction
30 Second Summary
Ever wanted to look up facts about distant planets or nebulae while camping, on a flight, or anywhere without Wi-Fi? The universe shouldn't require an internet connection to explore.
In this project, you will build an installable offline encyclopedia app that bundles curated space data from open-source astronomical databases. You'll create a space-themed interface for browsing planets, moons, stars, asteroids, and deep-sky objects.
What You'll Build
A SvelteKit Progressive Web App that serves as a personal space encyclopedia, installable on desktop and mobile, with zero API dependencies or ongoing costs.
By the end of this project, you'll have:
- A SvelteKit app with file-based routing for browsing 28+ celestial objects across 5 categories.
- A space-themed UI with an animated star background, glowing cards, and responsive design.
- A service worker that caches all app assets for full offline functionality on any device.
- Secret Mission: Add a real-time search bar with filtering across all categories and a localStorage-powered favorites system.
Are there any prerequisites?
You'll need basic HTML and CSS knowledge. No prior experience with SvelteKit, PWAs, or JavaScript frameworks is required.
Before We Start
Before We Start...
Set Up Your Development Environment
To build an offline Progressive Web App, you need a JavaScript development environment on your machine.
In this step, you will verify that Node.js is ready on your system, scaffold a new SvelteKit project, and install the plugins that turn your app into an installable PWA.
In this step, get ready to:
- Verify Node.js and npm are installed.
- Create a new SvelteKit project called Cosmic Atlas.
- Install the PWA plugin and static adapter.
Verify Node.js and npm
Node.js is the JavaScript runtime that powers your development server and build tools. Let's confirm it's installed.
- Press the Super key to open the Activities search.
- Type Terminal and press Enter to open it.
- Check whether Node.js and npm are installed by running these commands:
node --version
npm --version
Each command should print a version number like v22.x.x and 10.x.x.
✔️ I see version numbers
Node.js and npm are ready. You can move on to the next substep.
ⓧ Command not found
Node.js is not installed yet. On Fedora 44, Node.js 22 is the default available version.
- Install Node.js 22 and its companion packages by running:
sudo dnf install nodejs22 nodejs22-bin nodejs22-npm-bin
- Enter your password when prompted.
- When asked to confirm the installation, type y and press Enter.
- After the installation finishes, verify it worked by running:
node --version
You should now see a version number starting with v22.
Create the SvelteKit project
SvelteKit provides the app framework, routing, and build system for your encyclopedia. You will use the official Svelte CLI to scaffold a minimal project.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the Cosmic Atlas project by running:
npx sv create cosmic-atlas --template minimal --no-types --no-add-ons
What does this command do?
- npx sv create runs the Svelte CLI's project scaffolding tool.
- cosmic-atlas is the name of the folder it creates.
- --template minimal gives you a clean starting point without demo content.
- --no-types skips TypeScript setup so you work in plain JavaScript.
- --no-add-ons skips optional extras like linting or testing frameworks.
npx package prompt
If npx displays a message asking to install a package with the text Ok to proceed? (y), press Enter to confirm.
- Confirm the project folder was created by running:
ls
You should see cosmic-atlas listed in the output.
Don't see cosmic-atlas?
- Make sure you ran cd ~/Desktop first. The folder is created in whatever directory your terminal is currently in.
- Check your internet connection. The npx command needs to download the Svelte CLI package the first time.
Still stuck? Help me troubleshoot the npx sv create command on Fedora 44.
- Move into the project folder and install its dependencies by running these commands:
cd cosmic-atlas
npm install
You should see output confirming packages were added, such as a count of installed packages and a completion time.
npm install failed?
- If you see permission errors, make sure you are inside the cosmic-atlas folder. Run pwd to confirm your current directory ends with /cosmic-atlas.
- If you see network errors, check your internet connection and try again.
Need more help? Help me fix npm install errors in my SvelteKit project on Fedora.
- Open the project in VS Code by running:
code .
VS Code opens with your cosmic-atlas project loaded in the Explorer sidebar.
code command not found?
If the code command is not recognized, open VS Code manually instead.
- Press the Super key, type VS Code, and press Enter.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to Desktop and select the cosmic-atlas folder, then click Open.
Install the PWA plugin and static adapter
Two extra packages turn your SvelteKit app into a PWA that works offline. The Vite PWA plugin generates a service worker, and the static adapter lets SvelteKit build your app as plain files.
- Open VS Code's integrated terminal by pressing Ctrl+` (backtick key, usually above Tab).
- Install the two packages as dev dependencies by running:
npm install -D vite-plugin-pwa@1.3.0 @sveltejs/adapter-static
What are these packages?
- vite-plugin-pwa@1.3.0 automatically generates a service worker that caches your app for offline use.
- @sveltejs/adapter-static tells SvelteKit to output static HTML, CSS, and JS files instead of a server-side app.
You should see output confirming the packages were added to your project.
Installation errors?
- Confirm you are inside the cosmic-atlas folder by checking the terminal prompt. It should show cosmic-atlas in the path.
- If you see version conflicts, try deleting node_modules and package-lock.json, then run npm install again.
Still stuck? Help me resolve npm dependency errors for vite-plugin-pwa in my SvelteKit project.
- Start the development server to confirm everything is working by running:
npm run dev
- Open your browser and go to http://localhost:5173.
You should see the SvelteKit welcome page with the text "Welcome to SvelteKit" or a simple greeting.
Page not loading?
- Check your terminal for error messages. If the server failed to start, a dependency might be missing. Try stopping the server with Ctrl+C and running npm install again.
- Make sure no other process is using port 5173. The terminal output shows the exact URL to visit.
Need help? Help me get the SvelteKit dev server running on localhost:5173.
Your development environment is fully set up. Next, you will build the space data collection that powers your entire encyclopedia.
Build Your Space Data Collection
Your development environment is ready to go. Now it's time to build the foundation of your space encyclopedia: the data itself.
Instead of relying on a remote API that requires internet access, you'll bundle all your astronomical data directly into the app as JavaScript modules. This means your encyclopedia works anywhere, on any device, even without a network connection.
In this step, get ready to:
- Create a data module with curated astronomical information across 5 categories.
- Build helper functions for looking up categories and individual objects.
- Verify the data imports correctly in the browser console.
Create the space data module
Your app needs a local database of celestial objects. You'll store everything in a single JavaScript file organized by category: planets, moons, stars, asteroids, and deep-sky objects.
Why bundled data instead of an API?
Most apps fetch data from a remote server. That works when you have internet, but breaks the moment you go offline.
Since your entire dataset is small (about 50KB of curated facts from NASA and open-source astronomical databases), bundling it directly into the app costs almost nothing in file size. The tradeoff is worth it: your encyclopedia loads instantly and works everywhere without a backend, API keys, or subscriptions.
- Create a new file at src/lib/data.js by running this command in your terminal:
touch src/lib/data.js
- Open src/lib/data.js in the VS Code Explorer sidebar by expanding src then lib and clicking data.js.
- Add the category definitions by pasting this code into data.js:
export const categories = {
planets: {
name: 'Planets',
icon: '🪐',
description: 'The eight major planets of our solar system'
},
moons: {
name: 'Moons',
icon: '🌙',
description: 'Notable natural satellites orbiting planets'
},
stars: {
name: 'Stars',
icon: '⭐',
description: 'Remarkable stars visible from Earth'
},
asteroids: {
name: 'Asteroids & Comets',
icon: '☄️',
description: 'Rocky remnants and icy travelers of the solar system'
},
deepsky: {
name: 'Deep Sky Objects',
icon: '🌌',
description: 'Galaxies, nebulae, and star clusters beyond our solar system'
}
};
What does this code do?
The categories object defines the five sections of your encyclopedia. Each category has a display name, an emoji icon, and a short description.
The keys (planets, moons, stars, asteroids, deepsky) act as IDs you'll use throughout the app to look up category information and route between pages.
- Save data.js.
- In the VS Code Explorer sidebar, confirm you see data.js listed under src/lib/.
Don't see the lib folder?
Make sure you ran the touch command from your cosmic-atlas project root (where package.json lives). The src/lib directory should already exist from the SvelteKit template.
Help me find my src/lib directory in the cosmic-atlas project.
- Now add the full astronomical dataset below the categories object. Paste this code at the bottom of data.js:
export const cosmicData = {
planets: [
{
id: 'mercury',
name: 'Mercury',
type: 'Terrestrial Planet',
description: 'The smallest planet in our solar system and closest to the Sun. Mercury has no atmosphere to retain heat, causing extreme temperature swings between day and night.',
facts: {
'Diameter': '4,879 km',
'Distance from Sun': '57.9 million km',
'Orbital Period': '88 Earth days',
'Day Length': '59 Earth days',
'Moons': '0',
'Temperature': '-180°C to 430°C'
},
funFact: 'A year on Mercury is shorter than a single day on Mercury when measured in Earth time.'
},
{
id: 'venus',
name: 'Venus',
type: 'Terrestrial Planet',
description: 'Often called Earth\'s twin due to similar size, Venus has a thick toxic atmosphere that traps heat, making it the hottest planet in our solar system.',
facts: {
'Diameter': '12,104 km',
'Distance from Sun': '108.2 million km',
'Orbital Period': '225 Earth days',
'Day Length': '243 Earth days',
'Moons': '0',
'Surface Temperature': '465°C average'
},
funFact: 'Venus rotates backwards compared to most planets, so the Sun rises in the west and sets in the east.'
},
{
id: 'earth',
name: 'Earth',
type: 'Terrestrial Planet',
description: 'Our home planet and the only known world to harbor life. Earth\'s liquid water, moderate temperature, and protective magnetic field create conditions uniquely suited for living organisms.',
facts: {
'Diameter': '12,742 km',
'Distance from Sun': '149.6 million km',
'Orbital Period': '365.25 days',
'Day Length': '24 hours',
'Moons': '1',
'Surface Temperature': '-88°C to 58°C'
},
funFact: 'Earth is the only planet not named after a Greek or Roman deity.'
},
{
id: 'mars',
name: 'Mars',
type: 'Terrestrial Planet',
description: 'The Red Planet gets its color from iron oxide on its surface. Mars has the largest volcano and canyon in the solar system, and evidence suggests it once had liquid water.',
facts: {
'Diameter': '6,779 km',
'Distance from Sun': '227.9 million km',
'Orbital Period': '687 Earth days',
'Day Length': '24 hours 37 minutes',
'Moons': '2',
'Surface Temperature': '-140°C to 20°C'
},
funFact: 'Olympus Mons on Mars is nearly three times the height of Mount Everest.'
},
{
id: 'jupiter',
name: 'Jupiter',
type: 'Gas Giant',
description: 'The largest planet in our solar system, Jupiter is a massive ball of gas with no solid surface. Its Great Red Spot is a storm larger than Earth that has raged for centuries.',
facts: {
'Diameter': '139,820 km',
'Distance from Sun': '778.5 million km',
'Orbital Period': '11.86 Earth years',
'Day Length': '9 hours 56 minutes',
'Known Moons': '95',
'Temperature (cloud tops)': '-110°C'
},
funFact: 'Jupiter has such strong gravity that it acts as a cosmic vacuum cleaner, protecting inner planets from many asteroid impacts.'
},
{
id: 'saturn',
name: 'Saturn',
type: 'Gas Giant',
description: 'Famous for its stunning ring system made of ice and rock particles. Saturn is the least dense planet and would float in water if you could find a bathtub large enough.',
facts: {
'Diameter': '116,460 km',
'Distance from Sun': '1.43 billion km',
'Orbital Period': '29.46 Earth years',
'Day Length': '10 hours 42 minutes',
'Known Moons': '146',
'Ring Span': '282,000 km'
},
funFact: 'Saturn\'s rings are incredibly thin relative to their width, averaging only about 10 meters thick.'
},
{
id: 'uranus',
name: 'Uranus',
type: 'Ice Giant',
description: 'Uranus rotates on its side, likely due to a massive ancient collision. This ice giant has a blue-green color from methane in its atmosphere.',
facts: {
'Diameter': '50,724 km',
'Distance from Sun': '2.87 billion km',
'Orbital Period': '84 Earth years',
'Day Length': '17 hours 14 minutes',
'Known Moons': '28',
'Temperature': '-224°C'
},
funFact: 'Uranus has an axial tilt of 98 degrees, meaning it essentially orbits the Sun rolling on its side.'
},
{
id: 'neptune',
name: 'Neptune',
type: 'Ice Giant',
description: 'The windiest planet in our solar system with speeds reaching 2,100 km/h. Neptune was the first planet located through mathematical prediction rather than observation.',
facts: {
'Diameter': '49,244 km',
'Distance from Sun': '4.5 billion km',
'Orbital Period': '164.8 Earth years',
'Day Length': '16 hours 6 minutes',
'Known Moons': '16',
'Temperature': '-214°C'
},
funFact: 'Neptune has only completed one orbit around the Sun since its discovery in 1846.'
}
],
moons: [
{
id: 'luna',
name: 'The Moon (Luna)',
type: 'Natural Satellite of Earth',
description: 'Earth\'s only natural satellite and the fifth largest moon in the solar system. It stabilizes Earth\'s axial tilt and creates ocean tides.',
facts: {
'Diameter': '3,474 km',
'Distance from Earth': '384,400 km',
'Orbital Period': '27.3 days',
'Parent Planet': 'Earth',
'Surface Temperature': '-173°C to 127°C',
'Gravity': '1/6 of Earth'
},
funFact: 'The Moon is slowly drifting away from Earth at a rate of about 3.8 cm per year.'
},
{
id: 'europa',
name: 'Europa',
type: 'Natural Satellite of Jupiter',
description: 'One of Jupiter\'s four largest moons, Europa has a smooth icy surface beneath which lies a vast saltwater ocean, making it one of the best candidates for extraterrestrial life.',
facts: {
'Diameter': '3,121 km',
'Distance from Jupiter': '671,100 km',
'Orbital Period': '3.55 days',
'Parent Planet': 'Jupiter',
'Surface Temperature': '-160°C',
'Ocean Depth': 'Estimated 60-150 km'
},
funFact: 'Europa\'s subsurface ocean contains about twice the amount of water found in all of Earth\'s oceans combined.'
},
{
id: 'titan',
name: 'Titan',
type: 'Natural Satellite of Saturn',
description: 'Saturn\'s largest moon and the only moon with a dense atmosphere. Titan has lakes of liquid methane and ethane on its surface, with a weather cycle similar to Earth\'s water cycle.',
facts: {
'Diameter': '5,149 km',
'Distance from Saturn': '1.22 million km',
'Orbital Period': '15.9 days',
'Parent Planet': 'Saturn',
'Surface Temperature': '-179°C',
'Atmosphere': 'Nitrogen (95%), Methane (5%)'
},
funFact: 'Titan is larger than the planet Mercury and is the only moon where a spacecraft (Huygens probe) has landed.'
},
{
id: 'ganymede',
name: 'Ganymede',
type: 'Natural Satellite of Jupiter',
description: 'The largest moon in our solar system, even bigger than Mercury. Ganymede is the only moon known to have its own magnetic field.',
facts: {
'Diameter': '5,268 km',
'Distance from Jupiter': '1.07 million km',
'Orbital Period': '7.15 days',
'Parent Planet': 'Jupiter',
'Surface Temperature': '-163°C',
'Composition': 'Rock and ice in roughly equal parts'
},
funFact: 'Ganymede generates its own aurora, visible proof of its unique magnetic field among moons.'
},
{
id: 'enceladus',
name: 'Enceladus',
type: 'Natural Satellite of Saturn',
description: 'A small icy moon that shoots geysers of water vapor into space from cracks near its south pole, revealing a subsurface ocean with potential hydrothermal activity.',
facts: {
'Diameter': '504 km',
'Distance from Saturn': '238,000 km',
'Orbital Period': '1.37 days',
'Parent Planet': 'Saturn',
'Surface Temperature': '-198°C',
'Geyser Height': 'Up to 500 km'
},
funFact: 'Enceladus\'s geysers supply material to Saturn\'s E ring, making this tiny moon responsible for an entire planetary ring.'
}
],
stars: [
{
id: 'sirius',
name: 'Sirius',
type: 'Main Sequence Star (Binary System)',
description: 'The brightest star in Earth\'s night sky, located in the constellation Canis Major. Sirius is actually a binary system consisting of a bright main-sequence star and a faint white dwarf companion.',
facts: {
'Distance': '8.6 light-years',
'Apparent Magnitude': '-1.46',
'Spectral Class': 'A1V',
'Mass': '2.1 solar masses',
'Constellation': 'Canis Major',
'Luminosity': '25x the Sun'
},
funFact: 'Ancient Egyptians based their calendar on Sirius, as its annual appearance coincided with the flooding of the Nile.'
},
{
id: 'betelgeuse',
name: 'Betelgeuse',
type: 'Red Supergiant',
description: 'A massive dying star in the constellation Orion, nearing the end of its life. Betelgeuse is so large that if placed at the center of our solar system, its surface would extend past the orbit of Jupiter.',
facts: {
'Distance': '~700 light-years',
'Apparent Magnitude': '0.0 to 1.6 (variable)',
'Spectral Class': 'M1-M2',
'Mass': '11-12 solar masses',
'Constellation': 'Orion',
'Radius': '~900x the Sun'
},
funFact: 'Betelgeuse will explode as a supernova within the next 100,000 years, briefly becoming as bright as the full Moon.'
},
{
id: 'polaris',
name: 'Polaris',
type: 'Yellow Supergiant (Cepheid Variable)',
description: 'The North Star, located nearly directly above Earth\'s north pole. Polaris has guided travelers for centuries and is actually a triple star system.',
facts: {
'Distance': '~430 light-years',
'Apparent Magnitude': '1.98',
'Spectral Class': 'F7Ib',
'Mass': '5.4 solar masses',
'Constellation': 'Ursa Minor',
'System': 'Triple star system'
},
funFact: 'Polaris was not always the North Star and will not always be. Earth\'s axis precesses like a top over a 26,000-year cycle.'
},
{
id: 'proxima-centauri',
name: 'Proxima Centauri',
type: 'Red Dwarf',
description: 'The closest star to our Sun, part of the Alpha Centauri triple system. Despite being the nearest star, it is too faint to see with the naked eye.',
facts: {
'Distance': '4.24 light-years',
'Apparent Magnitude': '11.13',
'Spectral Class': 'M5.5Ve',
'Mass': '0.12 solar masses',
'Constellation': 'Centaurus',
'Known Exoplanets': '2 confirmed'
},
funFact: 'Even traveling at the speed of light, it would take over 4 years to reach Proxima Centauri from Earth.'
},
{
id: 'vega',
name: 'Vega',
type: 'Main Sequence Star',
description: 'One of the brightest stars in the northern sky and part of the Summer Triangle asterism. Vega was once the North Star about 12,000 years ago and will be again in about 13,700 years.',
facts: {
'Distance': '25 light-years',
'Apparent Magnitude': '0.03',
'Spectral Class': 'A0V',
'Mass': '2.1 solar masses',
'Constellation': 'Lyra',
'Rotation': 'Spins very rapidly (12.5 hr)'
},
funFact: 'Vega was the first star (other than the Sun) to be photographed and the first to have its spectrum recorded.'
}
],
asteroids: [
{
id: 'ceres',
name: 'Ceres',
type: 'Dwarf Planet / Asteroid',
description: 'The largest object in the asteroid belt between Mars and Jupiter. Ceres is massive enough for gravity to make it round, earning dual classification as both a dwarf planet and the largest asteroid.',
facts: {
'Diameter': '940 km',
'Location': 'Asteroid Belt',
'Orbital Period': '4.6 Earth years',
'Discovery': '1801 by Giuseppe Piazzi',
'Mass': '~1/3 of asteroid belt total',
'Surface': 'Mix of water ice and minerals'
},
funFact: 'The Dawn spacecraft discovered bright spots on Ceres that turned out to be deposits of sodium carbonate, a type of salt.'
},
{
id: 'vesta',
name: 'Vesta',
type: 'Asteroid',
description: 'The second-largest asteroid and the brightest asteroid visible from Earth. Vesta has a giant impact crater at its south pole so deep it exposes the asteroid\'s internal layers.',
facts: {
'Diameter': '525 km',
'Location': 'Asteroid Belt',
'Orbital Period': '3.63 Earth years',
'Discovery': '1807 by Heinrich Olbers',
'Surface Temperature': '-20°C to -190°C',
'Crater': 'Rheasilvia (505 km wide)'
},
funFact: 'Vesta is the only asteroid visible to the naked eye from Earth under good conditions.'
},
{
id: 'halley',
name: "Halley's Comet",
type: 'Short-Period Comet',
description: 'The most famous comet, visible from Earth every 75-79 years. It has been observed and recorded by humans for over 2,000 years.',
facts: {
'Nucleus Size': '15 km x 8 km',
'Orbital Period': '~75-79 years',
'Last Perihelion': '1986',
'Next Perihelion': '2061',
'Speed at Perihelion': '54.5 km/s',
'Tail Length': 'Up to 100 million km'
},
funFact: 'Mark Twain was born during Halley\'s 1835 appearance and died during its 1910 return, just as he predicted.'
},
{
id: 'bennu',
name: 'Bennu',
type: 'Near-Earth Asteroid',
description: 'A carbon-rich asteroid studied closely by NASA\'s OSIRIS-REx mission, which collected a sample and returned it to Earth in 2023. Bennu is classified as potentially hazardous.',
facts: {
'Diameter': '490 meters',
'Location': 'Near-Earth orbit',
'Orbital Period': '1.2 Earth years',
'Discovery': '1999',
'Composition': 'Carbon-rich, hydrated minerals',
'Potential Impact': 'Late 2100s (very low probability)'
},
funFact: 'The OSIRIS-REx sample from Bennu contained amino acids and other organic molecules that predate our solar system.'
},
{
id: 'oumuamua',
name: "'Oumuamua",
type: 'Interstellar Object',
description: 'The first confirmed interstellar object to pass through our solar system, detected in 2017. Its unusual elongated shape and unexpected acceleration sparked intense scientific debate.',
facts: {
'Size': '~100-400 meters long',
'Origin': 'Interstellar space',
'Speed': '26 km/s (relative to Sun)',
'Discovery': 'October 2017',
'Shape': 'Highly elongated or disk-like',
'Classification': '1I/2017 U1'
},
funFact: "The name 'Oumuamua is Hawaiian for 'scout' or 'first distant messenger,' chosen because it was discovered by a Hawaii-based telescope."
}
],
deepsky: [
{
id: 'andromeda',
name: 'Andromeda Galaxy',
type: 'Spiral Galaxy',
description: 'The nearest large galaxy to the Milky Way and the most distant object visible to the naked eye. Andromeda is on a collision course with our galaxy, expected to merge in about 4.5 billion years.',
facts: {
'Designation': 'M31 / NGC 224',
'Distance': '2.537 million light-years',
'Diameter': '~220,000 light-years',
'Stars': '~1 trillion',
'Constellation': 'Andromeda',
'Apparent Magnitude': '3.44'
},
funFact: 'When Andromeda and the Milky Way collide, individual star collisions will be extremely rare because of the vast distances between stars.'
},
{
id: 'orion-nebula',
name: 'Orion Nebula',
type: 'Emission Nebula',
description: 'One of the brightest nebulae visible to the naked eye, located in the sword of the constellation Orion. It is a stellar nursery where new stars are actively being born.',
facts: {
'Designation': 'M42 / NGC 1976',
'Distance': '1,344 light-years',
'Diameter': '~24 light-years',
'Age': '~3 million years',
'Constellation': 'Orion',
'Apparent Magnitude': '4.0'
},
funFact: 'The Orion Nebula contains enough gas and dust to create tens of thousands of stars like our Sun.'
},
{
id: 'crab-nebula',
name: 'Crab Nebula',
type: 'Supernova Remnant',
description: 'The expanding remains of a massive star that exploded as a supernova in 1054 AD, recorded by Chinese and Japanese astronomers. At its center spins a neutron star (pulsar) rotating 30 times per second.',
facts: {
'Designation': 'M1 / NGC 1952',
'Distance': '6,500 light-years',
'Diameter': '~11 light-years',
'Supernova Date': 'July 4, 1054 AD',
'Constellation': 'Taurus',
'Expansion Rate': '~1,500 km/s'
},
funFact: 'The 1054 supernova was so bright it was visible during daytime for 23 days.'
},
{
id: 'pillars-of-creation',
name: 'Eagle Nebula (Pillars of Creation)',
type: 'Emission Nebula / Star-Forming Region',
description: 'Home to the famous "Pillars of Creation" photographed by the Hubble Space Telescope. These towering columns of gas and dust are incubators for new stars.',
facts: {
'Designation': 'M16 / NGC 6611',
'Distance': '6,500 light-years',
'Pillar Height': '~4-5 light-years each',
'Age': '~5.5 million years',
'Constellation': 'Serpens',
'Apparent Magnitude': '6.0'
},
funFact: 'The pillars may have already been destroyed by a nearby supernova, but the light showing their destruction has not yet reached us.'
},
{
id: 'sagittarius-a',
name: 'Sagittarius A*',
type: 'Supermassive Black Hole',
description: 'The supermassive black hole at the center of our Milky Way galaxy. It has a mass of about 4 million times that of our Sun and was first imaged by the Event Horizon Telescope in 2022.',
facts: {
'Distance': '26,000 light-years',
'Mass': '~4 million solar masses',
'Event Horizon': '~12 million km diameter',
'Location': 'Center of Milky Way',
'Constellation': 'Sagittarius',
'First Imaged': '2022'
},
funFact: 'Despite its enormous mass, Sagittarius A* is surprisingly quiet compared to black holes in other galaxies.'
}
]
};
What does this data look like?
The cosmicData object contains 28 celestial objects across 5 categories. Each object follows the same structure:
- id is a URL-friendly identifier used for routing (e.g. proxima-centauri).
- facts is a key-value object where the keys are human-readable labels displayed in the UI.
- funFact provides an engaging highlight for each object's detail page.
- Save data.js.
You won't see a change in the browser yet. The next file creates functions to look up this data.
✔️ Awesome, I've got everything!
Great. Double check you've saved src/lib/data.js before moving on.
ⓧ I'd like to double check the full code
export const categories = {
planets: {
name: 'Planets',
icon: '🪐',
description: 'The eight major planets of our solar system'
},
moons: {
name: 'Moons',
icon: '🌙',
description: 'Notable natural satellites orbiting planets'
},
stars: {
name: 'Stars',
icon: '⭐',
description: 'Remarkable stars visible from Earth'
},
asteroids: {
name: 'Asteroids & Comets',
icon: '☄️',
description: 'Rocky remnants and icy travelers of the solar system'
},
deepsky: {
name: 'Deep Sky Objects',
icon: '🌌',
description: 'Galaxies, nebulae, and star clusters beyond our solar system'
}
};
export const cosmicData = {
planets: [
{
id: 'mercury',
name: 'Mercury',
type: 'Terrestrial Planet',
description: 'The smallest planet in our solar system and closest to the Sun. Mercury has no atmosphere to retain heat, causing extreme temperature swings between day and night.',
facts: {
'Diameter': '4,879 km',
'Distance from Sun': '57.9 million km',
'Orbital Period': '88 Earth days',
'Day Length': '59 Earth days',
'Moons': '0',
'Temperature': '-180°C to 430°C'
},
funFact: 'A year on Mercury is shorter than a single day on Mercury when measured in Earth time.'
},
{
id: 'venus',
name: 'Venus',
type: 'Terrestrial Planet',
description: 'Often called Earth\'s twin due to similar size, Venus has a thick toxic atmosphere that traps heat, making it the hottest planet in our solar system.',
facts: {
'Diameter': '12,104 km',
'Distance from Sun': '108.2 million km',
'Orbital Period': '225 Earth days',
'Day Length': '243 Earth days',
'Moons': '0',
'Surface Temperature': '465°C average'
},
funFact: 'Venus rotates backwards compared to most planets, so the Sun rises in the west and sets in the east.'
},
{
id: 'earth',
name: 'Earth',
type: 'Terrestrial Planet',
description: 'Our home planet and the only known world to harbor life. Earth\'s liquid water, moderate temperature, and protective magnetic field create conditions uniquely suited for living organisms.',
facts: {
'Diameter': '12,742 km',
'Distance from Sun': '149.6 million km',
'Orbital Period': '365.25 days',
'Day Length': '24 hours',
'Moons': '1',
'Surface Temperature': '-88°C to 58°C'
},
funFact: 'Earth is the only planet not named after a Greek or Roman deity.'
},
{
id: 'mars',
name: 'Mars',
type: 'Terrestrial Planet',
description: 'The Red Planet gets its color from iron oxide on its surface. Mars has the largest volcano and canyon in the solar system, and evidence suggests it once had liquid water.',
facts: {
'Diameter': '6,779 km',
'Distance from Sun': '227.9 million km',
'Orbital Period': '687 Earth days',
'Day Length': '24 hours 37 minutes',
'Moons': '2',
'Surface Temperature': '-140°C to 20°C'
},
funFact: 'Olympus Mons on Mars is nearly three times the height of Mount Everest.'
},
{
id: 'jupiter',
name: 'Jupiter',
type: 'Gas Giant',
description: 'The largest planet in our solar system, Jupiter is a massive ball of gas with no solid surface. Its Great Red Spot is a storm larger than Earth that has raged for centuries.',
facts: {
'Diameter': '139,820 km',
'Distance from Sun': '778.5 million km',
'Orbital Period': '11.86 Earth years',
'Day Length': '9 hours 56 minutes',
'Known Moons': '95',
'Temperature (cloud tops)': '-110°C'
},
funFact: 'Jupiter has such strong gravity that it acts as a cosmic vacuum cleaner, protecting inner planets from many asteroid impacts.'
},
{
id: 'saturn',
name: 'Saturn',
type: 'Gas Giant',
description: 'Famous for its stunning ring system made of ice and rock particles. Saturn is the least dense planet and would float in water if you could find a bathtub large enough.',
facts: {
'Diameter': '116,460 km',
'Distance from Sun': '1.43 billion km',
'Orbital Period': '29.46 Earth years',
'Day Length': '10 hours 42 minutes',
'Known Moons': '146',
'Ring Span': '282,000 km'
},
funFact: 'Saturn\'s rings are incredibly thin relative to their width, averaging only about 10 meters thick.'
},
{
id: 'uranus',
name: 'Uranus',
type: 'Ice Giant',
description: 'Uranus rotates on its side, likely due to a massive ancient collision. This ice giant has a blue-green color from methane in its atmosphere.',
facts: {
'Diameter': '50,724 km',
'Distance from Sun': '2.87 billion km',
'Orbital Period': '84 Earth years',
'Day Length': '17 hours 14 minutes',
'Known Moons': '28',
'Temperature': '-224°C'
},
funFact: 'Uranus has an axial tilt of 98 degrees, meaning it essentially orbits the Sun rolling on its side.'
},
{
id: 'neptune',
name: 'Neptune',
type: 'Ice Giant',
description: 'The windiest planet in our solar system with speeds reaching 2,100 km/h. Neptune was the first planet located through mathematical prediction rather than observation.',
facts: {
'Diameter': '49,244 km',
'Distance from Sun': '4.5 billion km',
'Orbital Period': '164.8 Earth years',
'Day Length': '16 hours 6 minutes',
'Known Moons': '16',
'Temperature': '-214°C'
},
funFact: 'Neptune has only completed one orbit around the Sun since its discovery in 1846.'
}
],
moons: [
{
id: 'luna',
name: 'The Moon (Luna)',
type: 'Natural Satellite of Earth',
description: 'Earth\'s only natural satellite and the fifth largest moon in the solar system. It stabilizes Earth\'s axial tilt and creates ocean tides.',
facts: {
'Diameter': '3,474 km',
'Distance from Earth': '384,400 km',
'Orbital Period': '27.3 days',
'Parent Planet': 'Earth',
'Surface Temperature': '-173°C to 127°C',
'Gravity': '1/6 of Earth'
},
funFact: 'The Moon is slowly drifting away from Earth at a rate of about 3.8 cm per year.'
},
{
id: 'europa',
name: 'Europa',
type: 'Natural Satellite of Jupiter',
description: 'One of Jupiter\'s four largest moons, Europa has a smooth icy surface beneath which lies a vast saltwater ocean, making it one of the best candidates for extraterrestrial life.',
facts: {
'Diameter': '3,121 km',
'Distance from Jupiter': '671,100 km',
'Orbital Period': '3.55 days',
'Parent Planet': 'Jupiter',
'Surface Temperature': '-160°C',
'Ocean Depth': 'Estimated 60-150 km'
},
funFact: 'Europa\'s subsurface ocean contains about twice the amount of water found in all of Earth\'s oceans combined.'
},
{
id: 'titan',
name: 'Titan',
type: 'Natural Satellite of Saturn',
description: 'Saturn\'s largest moon and the only moon with a dense atmosphere. Titan has lakes of liquid methane and ethane on its surface, with a weather cycle similar to Earth\'s water cycle.',
facts: {
'Diameter': '5,149 km',
'Distance from Saturn': '1.22 million km',
'Orbital Period': '15.9 days',
'Parent Planet': 'Saturn',
'Surface Temperature': '-179°C',
'Atmosphere': 'Nitrogen (95%), Methane (5%)'
},
funFact: 'Titan is larger than the planet Mercury and is the only moon where a spacecraft (Huygens probe) has landed.'
},
{
id: 'ganymede',
name: 'Ganymede',
type: 'Natural Satellite of Jupiter',
description: 'The largest moon in our solar system, even bigger than Mercury. Ganymede is the only moon known to have its own magnetic field.',
facts: {
'Diameter': '5,268 km',
'Distance from Jupiter': '1.07 million km',
'Orbital Period': '7.15 days',
'Parent Planet': 'Jupiter',
'Surface Temperature': '-163°C',
'Composition': 'Rock and ice in roughly equal parts'
},
funFact: 'Ganymede generates its own aurora, visible proof of its unique magnetic field among moons.'
},
{
id: 'enceladus',
name: 'Enceladus',
type: 'Natural Satellite of Saturn',
description: 'A small icy moon that shoots geysers of water vapor into space from cracks near its south pole, revealing a subsurface ocean with potential hydrothermal activity.',
facts: {
'Diameter': '504 km',
'Distance from Saturn': '238,000 km',
'Orbital Period': '1.37 days',
'Parent Planet': 'Saturn',
'Surface Temperature': '-198°C',
'Geyser Height': 'Up to 500 km'
},
funFact: 'Enceladus\'s geysers supply material to Saturn\'s E ring, making this tiny moon responsible for an entire planetary ring.'
}
],
stars: [
{
id: 'sirius',
name: 'Sirius',
type: 'Main Sequence Star (Binary System)',
description: 'The brightest star in Earth\'s night sky, located in the constellation Canis Major. Sirius is actually a binary system consisting of a bright main-sequence star and a faint white dwarf companion.',
facts: {
'Distance': '8.6 light-years',
'Apparent Magnitude': '-1.46',
'Spectral Class': 'A1V',
'Mass': '2.1 solar masses',
'Constellation': 'Canis Major',
'Luminosity': '25x the Sun'
},
funFact: 'Ancient Egyptians based their calendar on Sirius, as its annual appearance coincided with the flooding of the Nile.'
},
{
id: 'betelgeuse',
name: 'Betelgeuse',
type: 'Red Supergiant',
description: 'A massive dying star in the constellation Orion, nearing the end of its life. Betelgeuse is so large that if placed at the center of our solar system, its surface would extend past the orbit of Jupiter.',
facts: {
'Distance': '~700 light-years',
'Apparent Magnitude': '0.0 to 1.6 (variable)',
'Spectral Class': 'M1-M2',
'Mass': '11-12 solar masses',
'Constellation': 'Orion',
'Radius': '~900x the Sun'
},
funFact: 'Betelgeuse will explode as a supernova within the next 100,000 years, briefly becoming as bright as the full Moon.'
},
{
id: 'polaris',
name: 'Polaris',
type: 'Yellow Supergiant (Cepheid Variable)',
description: 'The North Star, located nearly directly above Earth\'s north pole. Polaris has guided travelers for centuries and is actually a triple star system.',
facts: {
'Distance': '~430 light-years',
'Apparent Magnitude': '1.98',
'Spectral Class': 'F7Ib',
'Mass': '5.4 solar masses',
'Constellation': 'Ursa Minor',
'System': 'Triple star system'
},
funFact: 'Polaris was not always the North Star and will not always be. Earth\'s axis precesses like a top over a 26,000-year cycle.'
},
{
id: 'proxima-centauri',
name: 'Proxima Centauri',
type: 'Red Dwarf',
description: 'The closest star to our Sun, part of the Alpha Centauri triple system. Despite being the nearest star, it is too faint to see with the naked eye.',
facts: {
'Distance': '4.24 light-years',
'Apparent Magnitude': '11.13',
'Spectral Class': 'M5.5Ve',
'Mass': '0.12 solar masses',
'Constellation': 'Centaurus',
'Known Exoplanets': '2 confirmed'
},
funFact: 'Even traveling at the speed of light, it would take over 4 years to reach Proxima Centauri from Earth.'
},
{
id: 'vega',
name: 'Vega',
type: 'Main Sequence Star',
description: 'One of the brightest stars in the northern sky and part of the Summer Triangle asterism. Vega was once the North Star about 12,000 years ago and will be again in about 13,700 years.',
facts: {
'Distance': '25 light-years',
'Apparent Magnitude': '0.03',
'Spectral Class': 'A0V',
'Mass': '2.1 solar masses',
'Constellation': 'Lyra',
'Rotation': 'Spins very rapidly (12.5 hr)'
},
funFact: 'Vega was the first star (other than the Sun) to be photographed and the first to have its spectrum recorded.'
}
],
asteroids: [
{
id: 'ceres',
name: 'Ceres',
type: 'Dwarf Planet / Asteroid',
description: 'The largest object in the asteroid belt between Mars and Jupiter. Ceres is massive enough for gravity to make it round, earning dual classification as both a dwarf planet and the largest asteroid.',
facts: {
'Diameter': '940 km',
'Location': 'Asteroid Belt',
'Orbital Period': '4.6 Earth years',
'Discovery': '1801 by Giuseppe Piazzi',
'Mass': '~1/3 of asteroid belt total',
'Surface': 'Mix of water ice and minerals'
},
funFact: 'The Dawn spacecraft discovered bright spots on Ceres that turned out to be deposits of sodium carbonate, a type of salt.'
},
{
id: 'vesta',
name: 'Vesta',
type: 'Asteroid',
description: 'The second-largest asteroid and the brightest asteroid visible from Earth. Vesta has a giant impact crater at its south pole so deep it exposes the asteroid\'s internal layers.',
facts: {
'Diameter': '525 km',
'Location': 'Asteroid Belt',
'Orbital Period': '3.63 Earth years',
'Discovery': '1807 by Heinrich Olbers',
'Surface Temperature': '-20°C to -190°C',
'Crater': 'Rheasilvia (505 km wide)'
},
funFact: 'Vesta is the only asteroid visible to the naked eye from Earth under good conditions.'
},
{
id: 'halley',
name: "Halley's Comet",
type: 'Short-Period Comet',
description: 'The most famous comet, visible from Earth every 75-79 years. It has been observed and recorded by humans for over 2,000 years.',
facts: {
'Nucleus Size': '15 km x 8 km',
'Orbital Period': '~75-79 years',
'Last Perihelion': '1986',
'Next Perihelion': '2061',
'Speed at Perihelion': '54.5 km/s',
'Tail Length': 'Up to 100 million km'
},
funFact: 'Mark Twain was born during Halley\'s 1835 appearance and died during its 1910 return, just as he predicted.'
},
{
id: 'bennu',
name: 'Bennu',
type: 'Near-Earth Asteroid',
description: 'A carbon-rich asteroid studied closely by NASA\'s OSIRIS-REx mission, which collected a sample and returned it to Earth in 2023. Bennu is classified as potentially hazardous.',
facts: {
'Diameter': '490 meters',
'Location': 'Near-Earth orbit',
'Orbital Period': '1.2 Earth years',
'Discovery': '1999',
'Composition': 'Carbon-rich, hydrated minerals',
'Potential Impact': 'Late 2100s (very low probability)'
},
funFact: 'The OSIRIS-REx sample from Bennu contained amino acids and other organic molecules that predate our solar system.'
},
{
id: 'oumuamua',
name: "'Oumuamua",
type: 'Interstellar Object',
description: 'The first confirmed interstellar object to pass through our solar system, detected in 2017. Its unusual elongated shape and unexpected acceleration sparked intense scientific debate.',
facts: {
'Size': '~100-400 meters long',
'Origin': 'Interstellar space',
'Speed': '26 km/s (relative to Sun)',
'Discovery': 'October 2017',
'Shape': 'Highly elongated or disk-like',
'Classification': '1I/2017 U1'
},
funFact: "The name 'Oumuamua is Hawaiian for 'scout' or 'first distant messenger,' chosen because it was discovered by a Hawaii-based telescope."
}
],
deepsky: [
{
id: 'andromeda',
name: 'Andromeda Galaxy',
type: 'Spiral Galaxy',
description: 'The nearest large galaxy to the Milky Way and the most distant object visible to the naked eye. Andromeda is on a collision course with our galaxy, expected to merge in about 4.5 billion years.',
facts: {
'Designation': 'M31 / NGC 224',
'Distance': '2.537 million light-years',
'Diameter': '~220,000 light-years',
'Stars': '~1 trillion',
'Constellation': 'Andromeda',
'Apparent Magnitude': '3.44'
},
funFact: 'When Andromeda and the Milky Way collide, individual star collisions will be extremely rare because of the vast distances between stars.'
},
{
id: 'orion-nebula',
name: 'Orion Nebula',
type: 'Emission Nebula',
description: 'One of the brightest nebulae visible to the naked eye, located in the sword of the constellation Orion. It is a stellar nursery where new stars are actively being born.',
facts: {
'Designation': 'M42 / NGC 1976',
'Distance': '1,344 light-years',
'Diameter': '~24 light-years',
'Age': '~3 million years',
'Constellation': 'Orion',
'Apparent Magnitude': '4.0'
},
funFact: 'The Orion Nebula contains enough gas and dust to create tens of thousands of stars like our Sun.'
},
{
id: 'crab-nebula',
name: 'Crab Nebula',
type: 'Supernova Remnant',
description: 'The expanding remains of a massive star that exploded as a supernova in 1054 AD, recorded by Chinese and Japanese astronomers. At its center spins a neutron star (pulsar) rotating 30 times per second.',
facts: {
'Designation': 'M1 / NGC 1952',
'Distance': '6,500 light-years',
'Diameter': '~11 light-years',
'Supernova Date': 'July 4, 1054 AD',
'Constellation': 'Taurus',
'Expansion Rate': '~1,500 km/s'
},
funFact: 'The 1054 supernova was so bright it was visible during daytime for 23 days.'
},
{
id: 'pillars-of-creation',
name: 'Eagle Nebula (Pillars of Creation)',
type: 'Emission Nebula / Star-Forming Region',
description: 'Home to the famous "Pillars of Creation" photographed by the Hubble Space Telescope. These towering columns of gas and dust are incubators for new stars.',
facts: {
'Designation': 'M16 / NGC 6611',
'Distance': '6,500 light-years',
'Pillar Height': '~4-5 light-years each',
'Age': '~5.5 million years',
'Constellation': 'Serpens',
'Apparent Magnitude': '6.0'
},
funFact: 'The pillars may have already been destroyed by a nearby supernova, but the light showing their destruction has not yet reached us.'
},
{
id: 'sagittarius-a',
name: 'Sagittarius A*',
type: 'Supermassive Black Hole',
description: 'The supermassive black hole at the center of our Milky Way galaxy. It has a mass of about 4 million times that of our Sun and was first imaged by the Event Horizon Telescope in 2022.',
facts: {
'Distance': '26,000 light-years',
'Mass': '~4 million solar masses',
'Event Horizon': '~12 million km diameter',
'Location': 'Center of Milky Way',
'Constellation': 'Sagittarius',
'First Imaged': '2022'
},
funFact: 'Despite its enormous mass, Sagittarius A* is surprisingly quiet compared to black holes in other galaxies.'
}
]
};
Create the helper functions
Your data module stores the raw information, but you need utility functions to look things up. A separate helper module keeps your page components clean and focused on rendering.
- Create a new file at src/lib/helpers.js by running this command:
touch src/lib/helpers.js
- Open src/lib/helpers.js in the editor and paste the following code:
import { categories, cosmicData } from './data.js';
export function getCategory(categoryId) {
return categories[categoryId] || null;
}
export function getCategoryItems(categoryId) {
return cosmicData[categoryId] || [];
}
export function getItem(categoryId, itemId) {
const items = cosmicData[categoryId] || [];
return items.find(item => item.id === itemId) || null;
}
export function getAllCategories() {
return Object.entries(categories).map(([id, info]) => ({
id,
...info,
count: (cosmicData[id] || []).length
}));
}
What do these functions do?
- getCategory(categoryId) returns the metadata (name, icon, description) for a single category.
- getCategoryItems(categoryId) returns the array of all objects in a category (e.g. all 8 planets).
- getItem(categoryId, itemId) finds one specific object by its ID within a category.
- getAllCategories() builds a summary list of all categories with their object counts, perfect for the home page.
- Save src/lib/helpers.js.
You won't see a change in the browser yet. The next substep verifies everything imports correctly.
Seeing a red squiggly under the import?
Make sure your import path is './data.js' with the ./ prefix. Without it, the bundler cannot find the file in the same directory.
Help me fix my import in helpers.js.
✔️ Awesome, I've got everything!
Great. Double check you've saved src/lib/helpers.js before moving on.
ⓧ I'd like to double check the full code
import { categories, cosmicData } from './data.js';
export function getCategory(categoryId) {
return categories[categoryId] || null;
}
export function getCategoryItems(categoryId) {
return cosmicData[categoryId] || [];
}
export function getItem(categoryId, itemId) {
const items = cosmicData[categoryId] || [];
return items.find(item => item.id === itemId) || null;
}
export function getAllCategories() {
return Object.entries(categories).map(([id, info]) => ({
id,
...info,
count: (cosmicData[id] || []).length
}));
}
Verify data imports in the browser
Both files are saved, but you haven't confirmed they actually work together yet. A quick console log in the home page will prove the import chain is healthy.
- Open src/routes/+page.svelte in the VS Code Explorer sidebar (under src/routes/).
- Add a temporary script block at the top of the file to test the imports by pasting this code:
<script>
import { getAllCategories } from '$lib/helpers.js';
// Temporary test - remove after verifying
console.log('Categories:', getAllCategories());
</script>
What is $lib?
$lib is a SvelteKit alias that always points to your src/lib folder. It saves you from writing fragile relative paths like ../../lib/helpers.js in deeply nested routes.
- Save +page.svelte.
- Switch to your browser and open localhost:5173 (it should already be running from Step 1).
- Open the browser's Developer Tools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Linux/Windows).
- Click the Console tab.
You should see an array of 5 objects logged, each with an id, name, icon, description, and a count property showing how many objects are in each category (8, 5, 5, 5, 5).
Seeing an error in the console instead?
- If you see a "Failed to resolve import" error, check that both data.js and helpers.js are inside src/lib/ (not src/ or the project root).
- If you see a syntax error, open data.js and look for unmatched braces or brackets. The file is large, so compare it against the double-check tab above.
Help me fix my data import error in Cosmic Atlas.
- Once you've confirmed the data appears in the console, remove the console.log line from +page.svelte (you can delete the entire script block for now).
- Save +page.svelte.
Your data layer is complete. You have 28 celestial objects across 5 categories, all bundled locally with zero API dependencies. Next up, you'll create the visual layout and space theme that brings this data to life.
Create the Space-Themed Layout and Styling
Your data modules are ready, but the app still looks like a default SvelteKit page. A space encyclopedia needs to feel like peering into the cosmos.
In this step, you will build the visual identity for Cosmic Atlas. You will create a dark theme with animated stars, glowing accent colors, and reusable component classes that every page in the app will share.
In this step, get ready to:
- Replace the default stylesheet with a space-themed CSS file including animated stars.
- Build a navigation layout with category links that wraps every page.
- Add PWA-ready meta tags to the HTML template.
Replace the default stylesheet with the space theme
The src/app.css file controls the global styles for your entire app. You will replace its contents with a custom space theme that defines color variables, an animated star-field background, and base typography.
- Open src/app.css in VS Code.
- Select all the existing content and delete it.
- Paste the following CSS that defines the theme variables, body reset, and animated star-field background:
:root {
--bg-primary: #0a0e1a;
--bg-secondary: #111827;
--bg-card: #1a1f35;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--accent-cyan: #06b6d4;
--accent-purple: #a855f7;
--accent-blue: #3b82f6;
--glow-cyan: 0 0 20px rgba(6, 182, 212, 0.3);
--glow-purple: 0 0 20px rgba(168, 85, 247, 0.3);
--border-subtle: rgba(148, 163, 184, 0.1);
--radius: 12px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
}
.stars {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
background:
radial-gradient(1px 1px at 20% 30%, white 0.5px, transparent 1px),
radial-gradient(1px 1px at 40% 70%, white 0.5px, transparent 1px),
radial-gradient(1px 1px at 60% 20%, white 0.5px, transparent 1px),
radial-gradient(1px 1px at 80% 50%, white 0.5px, transparent 1px),
radial-gradient(1.5px 1.5px at 10% 80%, rgba(6, 182, 212, 0.6) 0.5px, transparent 1px),
radial-gradient(1.5px 1.5px at 70% 10%, rgba(168, 85, 247, 0.6) 0.5px, transparent 1px),
radial-gradient(1px 1px at 90% 90%, white 0.5px, transparent 1px),
radial-gradient(1px 1px at 30% 50%, white 0.5px, transparent 1px),
radial-gradient(1px 1px at 50% 40%, white 0.5px, transparent 1px),
radial-gradient(1px 1px at 15% 15%, white 0.5px, transparent 1px);
animation: twinkle 8s ease-in-out infinite alternate;
}
@keyframes twinkle {
0% { opacity: 0.6; }
100% { opacity: 1; }
}
What does this code do?
The :root block defines CSS custom properties (variables) for colors, glows, and spacing that every component can reference. This keeps the theme consistent and easy to change.
The .stars class uses layered radial gradients to create tiny dots that look like stars. The twinkle keyframe animation gently pulses their opacity to simulate twinkling.
- Directly below the code you just pasted, add the layout utilities, component classes, and responsive breakpoints:
.container {
max-width: 1100px;
margin: 0 auto;
padding: 0 1.5rem;
position: relative;
z-index: 1;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
padding: 1.5rem;
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--glow-cyan);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
a {
color: var(--accent-cyan);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
h1 {
font-size: 2rem;
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-purple));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
h2 {
font-size: 1.5rem;
color: var(--text-primary);
margin-bottom: 1rem;
}
.badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-cyan {
background: rgba(6, 182, 212, 0.15);
color: var(--accent-cyan);
border: 1px solid rgba(6, 182, 212, 0.3);
}
.badge-purple {
background: rgba(168, 85, 247, 0.15);
color: var(--accent-purple);
border: 1px solid rgba(168, 85, 247, 0.3);
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.2rem;
border-radius: var(--radius);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.2s;
}
.btn-primary {
background: var(--accent-cyan);
color: var(--bg-primary);
}
.btn-primary:hover {
box-shadow: var(--glow-cyan);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-subtle);
}
.btn-ghost:hover {
color: var(--text-primary);
border-color: var(--accent-cyan);
}
@media (max-width: 640px) {
.grid {
grid-template-columns: 1fr;
}
h1 {
font-size: 1.5rem;
}
}
What does this code do?
- .container centers content at a max width of 1100px and sits above the star background with z-index: 1.
- .card gives elements a dark background with a subtle border that glows cyan on hover.
- .grid creates a responsive grid that auto-fills columns at a minimum of 280px wide.
- The h1 style uses a gradient background clipped to text, creating a cyan-to-purple color effect on headings.
- .badge and .btn classes provide reusable pill-shaped tags and buttons you will use on category and detail pages.
- Save src/app.css.
- Switch to your browser at localhost:5173. You should see the page background turn to a deep dark blue (#0a0e1a) with faint twinkling star dots across the viewport.
Still seeing the default white background?
- Make sure you deleted all the original CSS before pasting. Leftover rules may override your new styles.
- Confirm you saved the file. Press Ctrl+S and check that the dot on the file tab disappears.
- Check that your dev server is still running in the terminal. If it stopped, run npm run dev again.
Why isn't my dark background showing up after saving app.css?
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/app.css before moving on.
ⓧ I'd like to double check the full code
:root {
--bg-primary: #0a0e1a;
--bg-secondary: #111827;
--bg-card: #1a1f35;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--accent-cyan: #06b6d4;
--accent-purple: #a855f7;
--accent-blue: #3b82f6;
--glow-cyan: 0 0 20px rgba(6, 182, 212, 0.3);
--glow-purple: 0 0 20px rgba(168, 85, 247, 0.3);
--border-subtle: rgba(148, 163, 184, 0.1);
--radius: 12px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
}
.stars {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
background:
radial-gradient(1px 1px at 20% 30%, white 0.5px, transparent 1px),
radial-gradient(1px 1px at 40% 70%, white 0.5px, transparent 1px),
radial-gradient(1px 1px at 60% 20%, white 0.5px, transparent 1px),
radial-gradient(1px 1px at 80% 50%, white 0.5px, transparent 1px),
radial-gradient(1.5px 1.5px at 10% 80%, rgba(6, 182, 212, 0.6) 0.5px, transparent 1px),
radial-gradient(1.5px 1.5px at 70% 10%, rgba(168, 85, 247, 0.6) 0.5px, transparent 1px),
radial-gradient(1px 1px at 90% 90%, white 0.5px, transparent 1px),
radial-gradient(1px 1px at 30% 50%, white 0.5px, transparent 1px),
radial-gradient(1px 1px at 50% 40%, white 0.5px, transparent 1px),
radial-gradient(1px 1px at 15% 15%, white 0.5px, transparent 1px);
animation: twinkle 8s ease-in-out infinite alternate;
}
@keyframes twinkle {
0% { opacity: 0.6; }
100% { opacity: 1; }
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 0 1.5rem;
position: relative;
z-index: 1;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius);
padding: 1.5rem;
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--glow-cyan);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
a {
color: var(--accent-cyan);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
h1 {
font-size: 2rem;
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-purple));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
h2 {
font-size: 1.5rem;
color: var(--text-primary);
margin-bottom: 1rem;
}
.badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.badge-cyan {
background: rgba(6, 182, 212, 0.15);
color: var(--accent-cyan);
border: 1px solid rgba(6, 182, 212, 0.3);
}
.badge-purple {
background: rgba(168, 85, 247, 0.15);
color: var(--accent-purple);
border: 1px solid rgba(168, 85, 247, 0.3);
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.2rem;
border-radius: var(--radius);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.2s;
}
.btn-primary {
background: var(--accent-cyan);
color: var(--bg-primary);
}
.btn-primary:hover {
box-shadow: var(--glow-cyan);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-subtle);
}
.btn-ghost:hover {
color: var(--text-primary);
border-color: var(--accent-cyan);
}
@media (max-width: 640px) {
.grid {
grid-template-columns: 1fr;
}
h1 {
font-size: 1.5rem;
}
}
Update the layout component with navigation
SvelteKit uses a +layout.svelte file to wrap every page with shared UI. This is where you will add the star-field div, a sticky navigation bar with category links, and the main content area.
- Open src/routes/+layout.svelte in VS Code. If the file does not exist yet, create it inside the src/routes/ folder.
- Replace any existing content with the following layout component:
<script>
import '../app.css';
let { children } = $props();
</script>
<div class="stars"></div>
<nav class="nav">
<div class="container nav-inner">
<a href="/" class="nav-brand">🌌 Cosmic Atlas</a>
<div class="nav-links">
<a href="/explore/planets">Planets</a>
<a href="/explore/moons">Moons</a>
<a href="/explore/stars">Stars</a>
<a href="/explore/asteroids">Asteroids</a>
<a href="/explore/deepsky">Deep Sky</a>
</div>
</div>
</nav>
<main class="container main-content">
{@render children()}
</main>
<style>
.nav {
position: sticky;
top: 0;
z-index: 100;
background: rgba(10, 14, 26, 0.9);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
padding: 1rem 0;
}
.nav-inner {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
}
.nav-brand {
font-size: 1.2rem;
font-weight: 700;
color: #e2e8f0;
text-decoration: none;
}
.nav-links {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.nav-links a {
font-size: 0.85rem;
color: #94a3b8;
text-decoration: none;
transition: color 0.2s;
}
.nav-links a:hover {
color: #06b6d4;
}
.main-content {
padding-top: 2rem;
padding-bottom: 3rem;
min-height: calc(100vh - 80px);
}
</style>
What does this code do?
- import '../app.css' loads your global stylesheet so it applies to every page in the app.
- let { children } = $props() receives the current page content from SvelteKit using Svelte 5 runes syntax.
- The <div class="stars"> activates the animated star background you defined in the CSS.
- The <nav> creates a sticky header with a frosted glass effect (backdrop-filter: blur) and links to each data category.
- {@render children()} is Svelte 5's snippet syntax that renders whichever page the user is currently viewing.
- Save src/routes/+layout.svelte.
- Refresh your browser. You should see a sticky navigation bar at the top with "Cosmic Atlas" on the left and five category links (Planets, Moons, Stars, Asteroids, Deep Sky) on the right. The twinkling stars should be visible behind the content area.
Navigation not showing up?
- Make sure the file is named exactly +layout.svelte (with the leading plus sign) and lives inside src/routes/.
- Check the terminal for any Svelte compilation errors. A missing closing tag or typo in the <style> block will prevent the component from rendering.
Help me debug why my SvelteKit layout component isn't rendering.
Add PWA meta tags to the HTML template
The src/app.html file is the shell that SvelteKit uses to build every page. Adding meta tags here prepares the app for Progressive Web App installation later.
- Open src/app.html in VS Code.
- Replace the entire file contents with the following updated HTML template:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0a0e1a" />
<meta name="description" content="Cosmic Atlas - An offline space encyclopedia" />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" href="/favicon.png" />
%sveltekit.head%
</head>
<body data-sveltekit-prerender="true">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
What do these meta tags do?
- theme-color sets the browser toolbar color on mobile to match your dark background.
- description provides a brief summary used by search engines and app stores.
- The manifest link points to a manifest.json file (you will create this in a later step) that tells the browser how to install the app.
- Save src/app.html.
- Refresh your browser. Right-click anywhere on the page, select View Page Source (or press Ctrl+U), and confirm you see the theme-color and description meta tags in the <head> section.
Don't see the new meta tags in the source?
- The dev server sometimes needs a hard refresh. Press Ctrl+Shift+R to bypass the cache.
- Make sure the %sveltekit.head% and %sveltekit.body% placeholders are present. Without them the page will not render at all.
My app.html changes are not showing up in the browser source.
Your app now has a complete visual identity with animated stars, a frosted-glass navigation bar, and reusable component classes ready for content. Next up, you will build the home page and category browser to put these styles to work.
Build the Home Page and Category Browser
Your layout and space theme are in place. The nav links point to category URLs, but those routes do not exist yet.
In this step, you will build the two main pages users interact with first: a home page showing all five categories as clickable cards, and a dynamic category page that lists every object inside a selected category.
In this step, get ready to:
- Replace the default home page with a hero section and five category cards.
- Create a dynamic category route that loads and displays objects for any category.
- Test navigation between the home page and all five categories.
Replace the default home page
The default +page.svelte that SvelteKit generated only shows a "Welcome" message. You will replace it with a proper landing page that pulls category data from your helpers module and renders it as interactive cards.
- In VS Code, open src/routes/+page.svelte from the Explorer sidebar.
- Select all the existing content and replace it with the following code:
<script>
import { getAllCategories } from '$lib/helpers.js';
const allCategories = getAllCategories();
</script>
<svelte:head>
<title>Cosmic Atlas - Explore the Universe</title>
</svelte:head>
<section class="hero">
<h1>Explore the Universe</h1>
<p class="hero-subtitle">
Your offline encyclopedia of planets, moons, stars, asteroids, and deep-sky wonders.
All data is open-source. No internet required.
</p>
</section>
<section class="categories">
<div class="grid">
{#each allCategories as category}
<a href="/explore/{category.id}" class="card category-card">
<span class="category-icon">{category.icon}</span>
<h3>{category.name}</h3>
<p class="category-desc">{category.description}</p>
<span class="badge badge-cyan">{category.count} objects</span>
</a>
{/each}
</div>
</section>
<style>
.hero {
text-align: center;
padding: 3rem 0 2.5rem;
}
.hero-subtitle {
color: var(--text-secondary);
font-size: 1.1rem;
max-width: 550px;
margin: 0.75rem auto 0;
line-height: 1.6;
}
.categories {
padding-bottom: 2rem;
}
.category-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
text-decoration: none;
color: inherit;
}
.category-card h3 {
color: var(--text-primary);
font-size: 1.1rem;
}
.category-desc {
color: var(--text-secondary);
font-size: 0.85rem;
line-height: 1.5;
flex: 1;
}
.category-icon {
font-size: 2rem;
}
</style>
What does this code do?
The <script> section imports getAllCategories() from your helpers module. This function returns all five categories with their names, icons, descriptions, and object counts.
The {#each} block loops over the categories and renders each one as a clickable card. Each card links to /explore/{category.id}, which is the dynamic route you will create next.
- Save +page.svelte.
- Switch to your browser and refresh the page at localhost:5173. You should see a gradient heading reading "Explore the Universe" at the top, followed by five dark cards arranged in a grid. Each card shows an emoji icon, a category name, a short description, and a cyan badge with the object count.
Don't see the category cards?
- Check that you saved the file. Unsaved changes will not trigger a hot reload.
- Make sure the import path is exactly $lib/helpers.js. The $lib alias points to src/lib in SvelteKit.
- If you see an error in the terminal about a missing module, confirm that src/lib/helpers.js and src/lib/data.js both exist from the previous step.
Why aren't my category cards showing up on the home page?
Create the category listing route
SvelteKit uses file-based routing. A folder named [category] with brackets tells SvelteKit this is a dynamic route segment. Any URL like /explore/planets or /explore/stars will match this route, with the value available as params.category.
- Create the folder structure for the dynamic route by running this command in your terminal:
mkdir -p src/routes/explore/\[category\]
Why the backslashes?
Square brackets have special meaning in the shell. The backslashes escape them so the folder is literally named [category] on disk. SvelteKit reads the brackets as a dynamic parameter placeholder.
- Create a new file at src/routes/explore/[category]/+page.js and add the following load function:
import { getCategoryItems, getCategory } from '$lib/helpers.js';
import { error } from '@sveltejs/kit';
export function load({ params }) {
// Look up the category metadata and its items
const category = getCategory(params.category);
const items = getCategoryItems(params.category);
// If the URL doesn't match a real category, show a 404
if (!category) {
error(404, 'Category not found');
}
// Pass the data to the Svelte component
return {
category,
categoryId: params.category,
items
};
}
What does this code do?
The load function runs before the page renders. It receives params.category from the URL (e.g. "planets" from /explore/planets) and uses your helper functions to fetch the matching data.
If someone visits an invalid category URL, the error(404, 'Category not found') call tells SvelteKit to show a 404 page instead of crashing.
- Save +page.js.
You will not see a change in the browser yet. The load function needs a companion Svelte component to render its data.
- Create a new file at src/routes/explore/[category]/+page.svelte and add the following component code:
<script>
let { data } = $props();
</script>
<svelte:head>
<title>{data.category.name} - Cosmic Atlas</title>
</svelte:head>
<header class="page-header">
<a href="/" class="btn btn-ghost">← Home</a>
<div class="header-info">
<span class="header-icon">{data.category.icon}</span>
<h1>{data.category.name}</h1>
<p class="header-desc">{data.category.description}</p>
</div>
</header>
<div class="grid">
{#each data.items as item}
<a href="/explore/{data.categoryId}/{item.id}" class="card item-card">
<div class="item-top">
<h3>{item.name}</h3>
<span class="badge badge-purple">{item.type}</span>
</div>
<p class="item-desc">{item.description.slice(0, 120)}...</p>
</a>
{/each}
</div>
<style>
.page-header {
margin-bottom: 2rem;
}
.header-info {
margin-top: 1rem;
}
.header-icon {
font-size: 2.5rem;
}
.header-desc {
color: var(--text-secondary);
margin-top: 0.5rem;
}
.item-card {
display: flex;
flex-direction: column;
gap: 0.75rem;
text-decoration: none;
color: inherit;
}
.item-top {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.item-top h3 {
color: var(--text-primary);
font-size: 1.05rem;
}
.item-desc {
color: var(--text-secondary);
font-size: 0.85rem;
line-height: 1.5;
}
</style>
What does this code do?
The let { data } = $props() line receives everything the load function returned (category metadata, categoryId, and items array).
Each item card links to /explore/{data.categoryId}/{item.id}. That route does not exist yet, but you will build it in the next step. The .slice(0, 120) trims long descriptions so every card stays a consistent height.
- Save +page.svelte.
- In your browser, click **Planets** in the navigation bar. You should see a page with a back button labeled "Home", a large planet emoji, the heading "Planets", and 8 cards below showing names like Mercury, Venus, and Earth with purple type badges.
Seeing a 404 or blank page?
- Confirm the folder is named exactly [category] with square brackets. In the VS Code Explorer sidebar, it should show as src/routes/explore/[category]/.
- Make sure both +page.js and +page.svelte are inside the [category] folder, not inside explore/ directly.
- If the terminal shows an import error, check that the import paths in +page.js use $lib/helpers.js and @sveltejs/kit exactly.
My SvelteKit dynamic route at /explore/[category] is not loading. Help me fix it.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before continuing.
ⓧ I'd like to double check the full code
Here is the complete code for all three files created or modified in this step.
<script>
import { getAllCategories } from '$lib/helpers.js';
const allCategories = getAllCategories();
</script>
<svelte:head>
<title>Cosmic Atlas - Explore the Universe</title>
</svelte:head>
<section class="hero">
<h1>Explore the Universe</h1>
<p class="hero-subtitle">
Your offline encyclopedia of planets, moons, stars, asteroids, and deep-sky wonders.
All data is open-source. No internet required.
</p>
</section>
<section class="categories">
<div class="grid">
{#each allCategories as category}
<a href="/explore/{category.id}" class="card category-card">
<span class="category-icon">{category.icon}</span>
<h3>{category.name}</h3>
<p class="category-desc">{category.description}</p>
<span class="badge badge-cyan">{category.count} objects</span>
</a>
{/each}
</div>
</section>
<style>
.hero {
text-align: center;
padding: 3rem 0 2.5rem;
}
.hero-subtitle {
color: var(--text-secondary);
font-size: 1.1rem;
max-width: 550px;
margin: 0.75rem auto 0;
line-height: 1.6;
}
.categories {
padding-bottom: 2rem;
}
.category-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
text-decoration: none;
color: inherit;
}
.category-card h3 {
color: var(--text-primary);
font-size: 1.1rem;
}
.category-desc {
color: var(--text-secondary);
font-size: 0.85rem;
line-height: 1.5;
flex: 1;
}
.category-icon {
font-size: 2rem;
}
</style>
import { getCategoryItems, getCategory } from '$lib/helpers.js';
import { error } from '@sveltejs/kit';
export function load({ params }) {
const category = getCategory(params.category);
const items = getCategoryItems(params.category);
if (!category) {
error(404, 'Category not found');
}
return {
category,
categoryId: params.category,
items
};
}
<script>
let { data } = $props();
</script>
<svelte:head>
<title>{data.category.name} - Cosmic Atlas</title>
</svelte:head>
<header class="page-header">
<a href="/" class="btn btn-ghost">← Home</a>
<div class="header-info">
<span class="header-icon">{data.category.icon}</span>
<h1>{data.category.name}</h1>
<p class="header-desc">{data.category.description}</p>
</div>
</header>
<div class="grid">
{#each data.items as item}
<a href="/explore/{data.categoryId}/{item.id}" class="card item-card">
<div class="item-top">
<h3>{item.name}</h3>
<span class="badge badge-purple">{item.type}</span>
</div>
<p class="item-desc">{item.description.slice(0, 120)}...</p>
</a>
{/each}
</div>
<style>
.page-header {
margin-bottom: 2rem;
}
.header-info {
margin-top: 1rem;
}
.header-icon {
font-size: 2.5rem;
}
.header-desc {
color: var(--text-secondary);
margin-top: 0.5rem;
}
.item-card {
display: flex;
flex-direction: column;
gap: 0.75rem;
text-decoration: none;
color: inherit;
}
.item-top {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.item-top h3 {
color: var(--text-primary);
font-size: 1.05rem;
}
.item-desc {
color: var(--text-secondary);
font-size: 0.85rem;
line-height: 1.5;
}
</style>
Test the full navigation flow
With both routes in place, the home page and category pages should link together seamlessly. Time to verify all five categories work.
- Navigate to localhost:5173 in your browser to return to the home page.
- Click the **Planets** card. Confirm you see 8 planet cards.
- Click the **Home** button to return to the home page.
- Click the **Moons** card. Confirm you see 5 moon cards.
- Repeat for **Stars** (5 cards), **Asteroids** (5 cards), and **Deep Sky** (5 cards).
What should I see?
Each category page shows the category icon and name at the top, a description, and a grid of cards. Cards glow cyan on hover thanks to the .card:hover rule in your global CSS. Clicking a card does nothing yet because the detail route does not exist.
Your encyclopedia now has a proper front door and a browsing experience for every category. Next up, you will build the detail pages so clicking an object card reveals its full information, key facts, and fun fact.
Build the Object Detail Pages
Your category pages already link to individual objects, but clicking any card right now leads to a 404 error.
In this step, you will create the detail view that shows everything about a single celestial object. Each object page will display its name, type, a full description, a grid of key facts, and a highlighted fun fact.
In this step, get ready to:
- Create a data loader that fetches a single object by its category and ID.
- Build a detail page component with a facts grid, description, and fun fact card.
- Test the full navigation flow from Home to Category to Object Detail and back.
Create the detail page data loader
Just like the category page, each object detail page needs a load function that reads the URL parameters and fetches the right data.
- Create a new folder inside src/routes/explore/[category]/ called [id].
- Create a file called +page.js inside the new [id] folder and add this code:
import { getItem, getCategory } from '$lib/helpers.js';
import { error } from '@sveltejs/kit';
export function load({ params }) {
// Look up the category and specific item from the URL
const category = getCategory(params.category);
const item = getItem(params.category, params.id);
// Show a 404 if either the category or item doesn't exist
if (!category || !item) {
error(404, 'Object not found');
}
// Pass the data to the page component
return {
category,
categoryId: params.category,
item
};
}
What does this code do?
This load function reads two URL parameters: the category slug and the object ID. For example, visiting /explore/planets/mars sets params.category to planets and params.id to mars.
The getItem helper from your data module finds the matching object. If anything is missing, SvelteKit returns a 404 error page automatically.
- Save +page.js.
You won't see a visible change yet. The next chunk builds the component that renders this data.
Build the detail view component
Now you need the visual component that takes the loaded data and displays it as a rich detail page with sections for the description, key facts, and a fun fact highlight.
- Create a file called +page.svelte inside the same src/routes/explore/[category]/[id]/ folder and add this code:
<script>
let { data } = $props();
</script>
<svelte:head>
<title>{data.item.name} - Cosmic Atlas</title>
</svelte:head>
<header class="detail-header">
<a href="/explore/{data.categoryId}" class="btn btn-ghost">← Back to {data.category.name}</a>
</header>
<article class="detail">
<!-- Object name and type badge -->
<div class="detail-top">
<h1>{data.item.name}</h1>
<span class="badge badge-purple">{data.item.type}</span>
</div>
<!-- Full description paragraph -->
<p class="detail-desc">{data.item.description}</p>
<!-- Key facts displayed in a responsive grid -->
<section class="facts-section">
<h2>Key Facts</h2>
<div class="facts-grid">
{#each Object.entries(data.item.facts) as [label, value]}
<div class="fact-item">
<span class="fact-label">{label}</span>
<span class="fact-value">{value}</span>
</div>
{/each}
</div>
</section>
<!-- Highlighted fun fact card -->
<section class="funfact-section">
<div class="funfact-card">
<span class="funfact-icon">💡</span>
<div>
<h3>Fun Fact</h3>
<p>{data.item.funFact}</p>
</div>
</div>
</section>
</article>
<style>
.detail-header {
margin-bottom: 1.5rem;
}
.detail-top {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.detail-desc {
color: var(--text-secondary);
font-size: 1.05rem;
line-height: 1.7;
margin-bottom: 2rem;
}
.facts-section {
margin-bottom: 2rem;
}
.facts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.fact-item {
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.fact-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--accent-cyan);
font-weight: 600;
}
.fact-value {
font-size: 1rem;
color: var(--text-primary);
font-weight: 500;
}
.funfact-card {
background: rgba(168, 85, 247, 0.08);
border: 1px solid rgba(168, 85, 247, 0.2);
border-radius: var(--radius);
padding: 1.5rem;
display: flex;
gap: 1rem;
align-items: flex-start;
}
.funfact-icon {
font-size: 1.5rem;
flex-shrink: 0;
}
.funfact-card h3 {
font-size: 0.9rem;
color: var(--accent-purple);
margin-bottom: 0.5rem;
}
.funfact-card p {
color: var(--text-secondary);
line-height: 1.6;
font-size: 0.95rem;
}
</style>
What does this component do?
- The detail-header provides a back button that links to the parent category page using data.categoryId from the load function.
- The facts-grid loops through each key-value pair in the object's facts dictionary and renders them as styled cards with cyan labels and white values.
- The funfact-card section highlights a single fun fact in a purple-tinted card to make it visually distinct from the factual data.
- Save +page.svelte.
- Switch back to your browser at localhost:5173 and click any category card, then click any object card.
- You should see the object's name as a heading, a purple type badge, the description text, a grid of key facts, and a fun fact card at the bottom.
Seeing a 404 or blank page?
- Check that your folder structure is exactly src/routes/explore/[category]/[id]/ with square brackets around both category and id. The brackets tell SvelteKit these are dynamic route parameters.
- Make sure both +page.js and +page.svelte are inside the [id] folder. If either file is missing, the route will not resolve.
- Confirm the import path in +page.js uses $lib/helpers.js with the dollar sign prefix.
Why is my detail page not loading any data?
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the complete +page.js file:
import { getItem, getCategory } from '$lib/helpers.js';
import { error } from '@sveltejs/kit';
export function load({ params }) {
const category = getCategory(params.category);
const item = getItem(params.category, params.id);
if (!category || !item) {
error(404, 'Object not found');
}
return {
category,
categoryId: params.category,
item
};
}
Here is the complete +page.svelte file:
<script>
let { data } = $props();
</script>
<svelte:head>
<title>{data.item.name} - Cosmic Atlas</title>
</svelte:head>
<header class="detail-header">
<a href="/explore/{data.categoryId}" class="btn btn-ghost">← Back to {data.category.name}</a>
</header>
<article class="detail">
<div class="detail-top">
<h1>{data.item.name}</h1>
<span class="badge badge-purple">{data.item.type}</span>
</div>
<p class="detail-desc">{data.item.description}</p>
<section class="facts-section">
<h2>Key Facts</h2>
<div class="facts-grid">
{#each Object.entries(data.item.facts) as [label, value]}
<div class="fact-item">
<span class="fact-label">{label}</span>
<span class="fact-value">{value}</span>
</div>
{/each}
</div>
</section>
<section class="funfact-section">
<div class="funfact-card">
<span class="funfact-icon">💡</span>
<div>
<h3>Fun Fact</h3>
<p>{data.item.funFact}</p>
</div>
</div>
</section>
</article>
<style>
.detail-header {
margin-bottom: 1.5rem;
}
.detail-top {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.detail-desc {
color: var(--text-secondary);
font-size: 1.05rem;
line-height: 1.7;
margin-bottom: 2rem;
}
.facts-section {
margin-bottom: 2rem;
}
.facts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.fact-item {
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.fact-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--accent-cyan);
font-weight: 600;
}
.fact-value {
font-size: 1rem;
color: var(--text-primary);
font-weight: 500;
}
.funfact-card {
background: rgba(168, 85, 247, 0.08);
border: 1px solid rgba(168, 85, 247, 0.2);
border-radius: var(--radius);
padding: 1.5rem;
display: flex;
gap: 1rem;
align-items: flex-start;
}
.funfact-icon {
font-size: 1.5rem;
flex-shrink: 0;
}
.funfact-card h3 {
font-size: 0.9rem;
color: var(--accent-purple);
margin-bottom: 0.5rem;
}
.funfact-card p {
color: var(--text-secondary);
line-height: 1.6;
font-size: 0.95rem;
}
</style>
Test the full navigation flow
With all five categories containing a total of 28 objects, it is worth confirming the entire path works end to end.
- Return to the home page by clicking the Cosmic Atlas link in the navigation bar.
- Click the Planets category card.
- Click any planet (for example, Jupiter) to open its detail page.
- Confirm you see the planet name, type badge, full description, a grid of 6 key facts, and a fun fact card.
- Click the Back to Planets button at the top to return to the category listing.
- Repeat with at least one object from a different category (for example, Deep Sky and then Andromeda Galaxy) to confirm all routes resolve.
How many objects should I see?
Your data module contains 28 objects across 5 categories: 8 planets, 5 moons, 5 stars, 5 asteroids and comets, and 5 deep sky objects. Each one should load its own detail page without errors.
Your encyclopedia now has a complete browsing experience from home page to individual object. Next up, you will turn this into a fully offline Progressive Web App that can be installed on any device.
Configure PWA and Offline Support
Your space encyclopedia has a complete navigation flow. Users can browse categories, read about individual objects, and move between pages.
Right now, the app only works while the browser has an internet connection. In this step, you will turn Cosmic Atlas into a Progressive Web App that users can install on their device and use completely offline.
In this step, get ready to:
- Create a web app manifest that tells the browser how to install your app.
- Configure the static adapter and PWA plugin to generate a service worker that caches your app for offline use.
- Build and test the production app to confirm it works without internet.
Create the manifest and configure the static adapter
A web app manifest is a JSON file that tells browsers how to display your app when installed. It defines the app name, icons, theme colors, and launch behavior.
- Create a new file called manifest.json inside the static folder of your cosmic-atlas project, then paste the following code:
{
"name": "Cosmic Atlas",
"short_name": "CosmicAtlas",
"description": "An offline space encyclopedia with open-source astronomical data",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0e1a",
"theme_color": "#0a0e1a",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
What does this manifest do?
The name and short_name appear on the device home screen and app switcher. The display: standalone setting makes the app launch without a browser address bar, looking like a native app.
The icons array tells the device which images to use for the app icon. For now these are placeholders. The app will still function without them, but it will not be installable until you add real PNG files at those paths.
- Save static/manifest.json.
- Confirm the file exists by running:
ls static/manifest.json
You should see static/manifest.json printed in the terminal output.
Don't see the file?
Make sure you created the file inside the static folder at the project root, not inside src. SvelteKit serves files from static/ at the site root, so static/manifest.json becomes accessible at /manifest.json.
Help me find where to put my manifest.json file
Next, you need to tell SvelteKit to output a fully static site instead of a server-rendered one. This is what allows the PWA to work without any backend.
- Open svelte.config.js in the root of your cosmic-atlas project and replace its entire contents with:
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
// Use the static adapter to output a fully static site
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: '200.html', // Enables client-side routing for dynamic paths
precompress: false,
strict: true
})
}
};
export default config;
What does this configuration do?
The @sveltejs/adapter-static package outputs your entire app as static HTML, CSS, and JavaScript files. No Node.js server is needed to run it.
The fallback: '200.html' option is important for your dynamic routes like /explore/planets/mars. It generates a catch-all HTML page that handles client-side routing, so navigating directly to any URL still works.
- Save svelte.config.js.
You won't see a visible change yet. The production build in the next substep confirms this configuration works.
Seeing an import error?
If your editor shows a red underline on the import adapter from '@sveltejs/adapter-static' line, make sure the package is installed. It was installed in Step 1 as a dev dependency. Run npm ls @sveltejs/adapter-static to verify it is listed.
Help me fix my adapter-static import error
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the complete static/manifest.json:
{
"name": "Cosmic Atlas",
"short_name": "CosmicAtlas",
"description": "An offline space encyclopedia with open-source astronomical data",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0e1a",
"theme_color": "#0a0e1a",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
And here is the complete svelte.config.js:
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: '200.html',
precompress: false,
strict: true
})
}
};
export default config;
Integrate the PWA plugin for offline caching
The static adapter outputs files, but browsers still need internet to fetch them. A service worker solves this by caching all your app's assets locally so they load even when offline.
- Open vite.config.js in the root of your project and replace its entire contents with:
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
sveltekit(),
// PWA plugin generates a service worker that caches all assets
VitePWA({
registerType: 'autoUpdate',
workbox: {
// Cache all static assets for offline use
globPatterns: ['**/*.{js,css,html,svg,png,woff2}']
},
manifest: {
name: 'Cosmic Atlas',
short_name: 'CosmicAtlas',
description: 'An offline space encyclopedia with open-source astronomical data',
theme_color: '#0a0e1a',
background_color: '#0a0e1a',
display: 'standalone',
start_url: '/',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: '/icon-512.png',
sizes: '512x512',
type: 'image/png'
}
]
}
})
]
});
What does VitePWA do?
The VitePWA plugin automatically generates a service worker during the production build. The registerType: 'autoUpdate' setting means the service worker will silently update cached assets whenever you deploy a new version.
The globPatterns setting tells Workbox which file types to cache. By including js, css, html, svg, png, and woff2, your entire app (code, styles, pages, images, and fonts) is available offline.
The manifest object inside the plugin duplicates the static manifest for the service worker's own registration. Both are needed for full PWA compliance.
- Save vite.config.js.
You won't see a visible change yet. The next substep builds the production app and confirms everything works together.
Editor showing VitePWA import error?
Make sure vite-plugin-pwa is installed as a dev dependency. Run npm ls vite-plugin-pwa to check. If it is missing, run npm install -D vite-plugin-pwa@1.3.0 to add it.
Help me fix my VitePWA import
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before continuing.
ⓧ I'd like to double check the full code
Here is the complete vite.config.js:
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
sveltekit(),
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,woff2}']
},
manifest: {
name: 'Cosmic Atlas',
short_name: 'CosmicAtlas',
description: 'An offline space encyclopedia with open-source astronomical data',
theme_color: '#0a0e1a',
background_color: '#0a0e1a',
display: 'standalone',
start_url: '/',
icons: [
{
src: '/icon-192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: '/icon-512.png',
sizes: '512x512',
type: 'image/png'
}
]
}
})
]
});
Build and test offline mode
The PWA plugin only generates the service worker during a production build. You need to build the app, then preview it locally to confirm offline functionality.
- Stop the dev server if it is still running by pressing Ctrl+C in your terminal.
- Build the production version of your app by running:
npm run build
You should see output ending with a success message and a list of generated files in the build folder.
Build failed?
The most common cause is a mismatch between the adapter import and the installed package. Check that your svelte.config.js imports from '@sveltejs/adapter-static' and that the package is listed in your package.json devDependencies.
Help me fix my build error
- Start the production preview server by running:
npm run preview
This serves your built app locally, simulating how it would behave on a real host.
- Open your browser and navigate to the URL shown in the terminal (usually http://localhost:4173).
- Verify that the home page loads with all 5 category cards.
- Click into a category (like Planets) and then an object (like Mars) to confirm navigation still works.
Now test offline mode to prove the service worker is caching everything.
- Open DevTools by pressing F12 or Ctrl+Shift+I.
- Click the Network tab.
- Check the Offline checkbox (in the toolbar near the top of the Network panel).
- Refresh the page.
Your Cosmic Atlas app should load fully, even with the Offline checkbox enabled. Navigate between categories and object pages to confirm everything works without internet.
Why does this work?
When you first loaded the preview, the service worker downloaded and cached every file matching your globPatterns. Now when the network is offline, the service worker intercepts every request and serves the cached version instead.
All your space data lives inside the bundled JavaScript (from src/lib/data.js), so there are no API calls to fail. The entire encyclopedia is self-contained.
Page goes blank when offline?
Make sure you loaded the page at least once with the network online before checking the Offline box. The service worker needs that first load to cache the assets.
Also check the Application tab in DevTools, then Service Workers. You should see a service worker listed as active. If it shows as "waiting", click skipWaiting then refresh.
Help me debug my offline PWA
Make it installable
To make the app fully installable as a standalone app, add two PNG icons to the static folder: icon-192.png (192x192 pixels) and icon-512.png (512x512 pixels). Any simple image works. Without these, the app still functions offline but the browser will not show an "Install" prompt.
Secret mission
Search, Filter, and Favorites
Your Cosmic Atlas has 28 objects across 5 categories. Can you help users find what they're looking for instantly? Add a real-time search bar that filters across all categories as you type, then build a favorites system with localStorage so users can star objects and revisit them anytime.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The cosmic-atlas project folder on your Desktop (contains all source code, configuration, and dependencies).
- The build folder inside cosmic-atlas (production build output from npm run build).
- The local dev server process (if still running in your terminal).
Keep everything running
No action needed. Choose this if you are still actively building or want to continue experimenting with your Cosmic Atlas app.
- Your cosmic-atlas folder stays on your Desktop, ready to open anytime.
- You can restart the dev server anytime by opening a terminal, navigating to ~/Desktop/cosmic-atlas, and running npm run dev.
- Node.js remains installed on your system for future projects.
Pause - I'll come back to this later
Shut down running processes to free up system memory. Your project files stay intact for when you return.
- If the dev server is still running, switch to that terminal window and press Ctrl+C to stop it.
- If the preview server is still running, switch to that terminal window and press Ctrl+C to stop it.
- Your project folder stays on your Desktop. To resume later, open a terminal, navigate to ~/Desktop/cosmic-atlas, and run npm run dev.
Delete - I don't want to use this again
Remove all project files and start fresh. This cannot be undone.
- If the dev server or preview server is still running, press Ctrl+C in that terminal to stop it.
- Delete the entire project folder by running this command:
rm -rf ~/Desktop/cosmic-atlas
- Confirm the folder is gone by running:
ls ~/Desktop | grep cosmic-atlas
You should see no output, confirming the folder has been removed.
Nice Work!
Nice Work!
Nice work! You just built a fully offline, installable space encyclopedia from scratch using SvelteKit and open-source astronomical data.
You've learned how to:
- Built a Progressive Web App with SvelteKit that is installable on desktop and mobile.
- Created a curated local database of 28+ celestial objects using open-source astronomical data bundled directly into the app.
- Configured service worker caching for full offline functionality with no internet dependency.
- Added real-time search filtering across all categories and a localStorage-powered favorites system.
Ready to quiz yourself?