JOUR 73361
Coding the News
Learn how America's top news organizations escape rigid publishing systems to design beautiful data-driven stories on deadline.
Database Explorer
How to build a searchable directory with detail pages for individual records
March 30, 2026
In Week 5, you saw how ProPublica's "Dollars for Docs" project drew worldwide attention by turning a massive, obscure dataset of pharmaceutical company payments to doctors into a searchable, browsable website.
Examples of projects like this are everywhere. These include the Washington Post's police shootings database, USA Today's NCAA salaries tracker and the college rankings published by U.S. News and World Report.
What these tools have in common is a simple pattern: a list page where you can search and filter, and a detail page where you can drill into an individual record. That's what you're going to build today.
Part 1: Introducing the dataset
We'll be working with citations from the New York City Department of Housing Preservation and Development, known as HPD. When an inspector finds a hazard, the agency issues a notice and the building owner is expected to address the problem.
The dataset on NYC Open Data contains millions of individual violation records going back years, covering a wide range of hazards, from rodent infestations to broken elevators.
There are already ways to search and explore this dataset. HPD's tool lets you look up individual buildings.
JustFix is a non-profit that provides tenant organizers and legal aides with a tool to build cases against negligent landlords by merging HPD data with other sources.
You'll try to provide a service these sites don't.
You'll narrow your focus to a single hazard, lead paint. It's a hazard that is particularly dangerous for children and worthy of greater focus.
To zoom in even closer, you'll focus on just one borough — the Bronx — which has the highest concentration of housing violations in the city.
Then you'll do something that government sites rarely do: Tally up the total number of open lead paint violations at each building and highlight the most frequent offenders.
In a separate repository, I've downloaded the full dataset, applied the necessary filters and rolled up the violations at the building level. I limited the results to buildings with five or more open violations and then merged them with the city's PLUTO mapping database to get the latitude and longitude coordinates for each.
The final output is a JSON file with one record per building, including the address, ZIP Code, violation count, coordinates, plus a nested list of all the individual violations.
This puts you in the position that's common for newsroom developers. Your colleagues have done the work to get the data into a usable format. Your job is to pick it up and build a site that makes it engaging and accessible.
Part 2: Loading the data
Open your browser and navigate to our template repository at github.com/palewire/cuny-jour-static-site-template. Click "Use this template" and create a new repository called bronx-lead-paint. Make sure "Public" is selected, then click "Create repository."
Clone it onto your computer, open it in Visual Studio Code, and run npm install in the terminal. Start the development server with npm run dev and open your browser to localhost:5173.
Download the data file I've created called bronx_buildings.json and save it in your project. You should place it at src/lib/data/bronx_buildings.json.
You'll see an array of objects, each representing a building. Here's what one looks like:
{
"id": "62821",
"address": "526 EAST 138 STREET",
"zip": "10454",
"violationCount": 5,
"latestDate": "2023-12-06",
"lat": 40.807,
"lng": -73.9187,
"violations": [
{
"violationId": "16510086",
"orderNumber": "617",
"description": "§ 27-2056.6 ADM CODE - CORRECT THE LEAD-BASED PAINT HAZARD - PAINT THAT TESTED POSITIVE FOR LEAD CONTENT AND THAT IS PEELING OR ON A DETERIORATED SUBSURFACE - USING WORK PRACTICES SET FORTH IN 28 RCNY §11-06(B)(2)",
"date": "2023-12-06",
"currentStatus": "FIRST NO ACCESS TO RE- INSPECT VIOLATION",
"specificDescription": "South wall, east wall, 1st window frame from north at east wall in the bathroom located at apt 19, 5th story, 1st apartment from east at south"
},
{
"violationId": "16271082",
"orderNumber": "616",
"description": "§ 27-2056.6 ADM CODE - CORRECT THE LEAD-BASED PAINT HAZARD - PRESUMED LEAD PAINT THAT IS PEELING OR ON A DETERIORATED SUBSURFACE USING WORK PRACTICES SET FORTH IN 28 RCNY §11-06(B)(2)",
"date": "2023-09-25",
"currentStatus": "FIRST NO ACCESS TO RE- INSPECT VIOLATION",
"specificDescription": "Ceiling in the kitchen located at apt 16, 4th story, 1st apartment from south at west"
},
{
"violationId": "16271084",
"orderNumber": "616",
"description": "§ 27-2056.6 ADM CODE - CORRECT THE LEAD-BASED PAINT HAZARD - PRESUMED LEAD PAINT THAT IS PEELING OR ON A DETERIORATED SUBSURFACE USING WORK PRACTICES SET FORTH IN 28 RCNY §11-06(B)(2)",
"date": "2023-09-25",
"currentStatus": "FIRST NO ACCESS TO RE- INSPECT VIOLATION",
"specificDescription": "North wall in the 3rd room from north located at apt 16, 4th story, 1st apartment from south at west"
},
{
"violationId": "16271083",
"orderNumber": "616",
"description": "§ 27-2056.6 ADM CODE - CORRECT THE LEAD-BASED PAINT HAZARD - PRESUMED LEAD PAINT THAT IS PEELING OR ON A DETERIORATED SUBSURFACE USING WORK PRACTICES SET FORTH IN 28 RCNY §11-06(B)(2)",
"date": "2023-09-25",
"currentStatus": "FIRST NO ACCESS TO RE- INSPECT VIOLATION",
"specificDescription": "Ceiling, east wall in the foyer located at apt 16, 4th story, 1st apartment from south at west"
},
{
"violationId": "15674301",
"orderNumber": "616",
"description": "§ 27-2056.6 ADM CODE - CORRECT THE LEAD-BASED PAINT HAZARD - PRESUMED LEAD PAINT THAT IS PEELING OR ON A DETERIORATED SUBSURFACE USING WORK PRACTICES SET FORTH IN 28 RCNY §11-06(B)(2)",
"date": "2022-12-31",
"currentStatus": "DEFECT LETTER ISSUED",
"specificDescription": "Ceiling in the bathroom located at apt 15, 4th story, 2nd apartment from east at south"
}
],
"rank": 1377
}Now load the data into your page. Following the pattern we first covered in Week 5, open src/routes/+page.js and load our JSON file into the page's properties.
import violations from '$lib/data/bronx_buildings.json';
export function load() {
return {
showHeader: true,
showFooter: true,
violations,
};
}That will make it available in src/routes/+page.svelte in the $props() object. Open up the boilerplate page, clear it out and start over with this clean slate, which does nothing more than load the data and log it to the console:
<script>
let { data } = $props();
console.log(`Loaded ${data.violations.length} buildings`);
console.log("First record:", data.violations[0]);
</script>Save the file, reload the page and open your browser's developer console. You should see the number of buildings and the first record logged out.
Part 3: Adding a database header
You've got a blank canvas. Now it's time to start designing your site.
Like most newsroom developers, you don't have to start from scratch. As we discussed in Week 3, most teams maintain a library of pre-made, ready-to-serve components that they can pull into projects.
They are often collected and documented in a playbook shared among developers. One common tool for building and browsing these component libraries is called Storybook. It lets you see every component in isolation, experiment with its props, and copy the usage pattern directly into your project.
Our class has just such a site, packaged alongside our components in the static-site template. You can view it at palewire.github.io/cuny-jour-static-site-template/storybook/.
The DatabaseHeader component is the first piece you want to use today. It renders a full-width hero banner designed for database pages, with props for a headline, a description, a byline, and a date.
It's intended to mimic the design pattern used by ProPublica and many other news sites to frame their database projects, which often lack compelling photography, with a bold top.
Import the component
Open src/routes/+page.svelte. Your script block currently logs the data to the console. Add an import for DatabaseHeader at the top:
<script>
import DatabaseHeader from '$lib/components/DatabaseHeader.svelte';
let { data } = $props();
console.log(`Loaded ${data.violations.length} buildings`);
console.log("First record:", data.violations[0]);
</script>Add the component below the closing </script> tag, beginning with just the headline and description.
<script>
import DatabaseHeader from '$lib/components/DatabaseHeader.svelte';
let { data } = $props();
console.log(`Loaded ${data.violations.length} buildings`);
console.log("First record:", data.violations[0]);
</script>
<DatabaseHeader
kicker="Data Explorer"
headline="Tainted Home Tracker"
description="The Bronx buildings with the most lead paint violations"
/>Save and look at your browser. A pale banner appears across the top of the page with the headline and deck set in large type. That's the component's default style, just like we saw on the Storybook page.
Now credit the work with a byline and date.
<DatabaseHeader
kicker="Data Explorer"
headline="Tainted Home Tracker"
description="The Bronx buildings with the most lead paint violations"
byline="NYCity News Service"
date="March 2026"
/>The header now reads like a real news project.
Part 4: Building the list
Now let's display the buildings on the page. Rather than looping over every building, you will start by focusing on the top 20 by open violation count.
Once again, we have a pre-made component for this pattern. The RankingCard and the parent RankingList components are designed to show ranked lists of items. Like our DatabaseHeader, they are documented in Storybook with live previews.
That will work great in this case. Start by importing them into your script block in +page.svelte.
<script>
import DatabaseHeader from '$lib/components/DatabaseHeader.svelte';
import RankingList from '$lib/components/RankingList.svelte';
import RankingCard from '$lib/components/RankingCard.svelte';
let { data } = $props();
console.log(`Loaded ${data.violations.length} buildings`);
console.log("First record:", data.violations[0]);
</script>
<DatabaseHeader
kicker="Data Explorer"
headline="Tainted Home Tracker"
description="The Bronx buildings with the most lead paint violations"
byline="NYCity News Service"
date="March 2026"
/>Your goal is to publish a ranking. So you need to sort the buildings by violation count and trim down to the buildings with the most violations. Replace the console logs with this code, which creates a new variable called top20.
<script>
import DatabaseHeader from '$lib/components/DatabaseHeader.svelte';
import RankingList from '$lib/components/RankingList.svelte';
import RankingCard from '$lib/components/RankingCard.svelte';
let { data } = $props();
let top20 = data.violations
.sort((a, b) => a.rank - b.rank)
.slice(0, 20);
</script>
<DatabaseHeader
kicker="Data Explorer"
headline="Tainted Home Tracker"
description="The Bronx buildings with the most lead paint violations"
byline="NYCity News Service"
date="March 2026"
/>Each record in the JSON already has a rank field, so we can use that. The .sort() method arranges them in ascending order. Then .slice(0, 20) trims the array down to the top 20. This is a textbook example of JavaScript's rudimentary data manipulation tools at work.
Now replace everything below your headline with a RankingList wrapping an {#each} loop that renders a RankingCard for each building, just as we've done in your past projects with raw HTML elements.
<script>
import DatabaseHeader from '$lib/components/DatabaseHeader.svelte';
import RankingList from '$lib/components/RankingList.svelte';
import RankingCard from '$lib/components/RankingCard.svelte';
let { data } = $props();
let top20 = data.violations
.sort((a, b) => a.rank - b.rank)
.slice(0, 20);
</script>
<DatabaseHeader
kicker="Data Explorer"
headline="Tainted Home Tracker"
description="The Bronx buildings with the most lead paint violations"
byline="NYCity News Service"
date="March 2026"
/>
<RankingList title="Top 20 buildings by open violations">
{#each top20 as building (building.id)}
<RankingCard
rank={building.rank}
title={building.address}
value={building.violationCount}
/>
{/each}
</RankingList>Save and check your browser. You should see a ranked list, but it stretches edge to edge across the full width of the page.
That's because, like many components, RankingList doesn't impose a maximum width on itself. We need to wrap it in a container that matches the width of the DatabaseHeader above it.
Add a <div> around the RankingList and a <style> block at the bottom of +page.svelte to constrain it:
<script>
import DatabaseHeader from '$lib/components/DatabaseHeader.svelte';
import RankingList from '$lib/components/RankingList.svelte';
import RankingCard from '$lib/components/RankingCard.svelte';
let { data } = $props();
let top20 = data.violations
.sort((a, b) => a.rank - b.rank)
.slice(0, 20);
</script>
<DatabaseHeader
kicker="Data Explorer"
headline="Tainted Home Tracker"
description="The Bronx buildings with the most lead paint violations"
byline="NYCity News Service"
date="March 2026"
/>
<div class="container">
<RankingList title="Top 20 buildings by open violations">
{#each top20 as building (building.id)}
<RankingCard
rank={building.rank}
title={building.address}
value={building.violationCount}
/>
{/each}
</RankingList>
</div>
<style>
.container {
max-width: var(--max-width-wide);
margin: 0 auto;
}
</style>The --max-width-wide variable is defined in the template's global stylesheet at 1200px. Using it here keeps the list consistent with other wide elements like the DatabaseHeader. The margin: 0 auto centers the container on the page.
Save again and the list should now sit comfortably within the page layout.
Part 5: Adding search
A ranking of the top 20 buildings is clear and newsy, but you can offer your readers more.
Your dataset has the full list of every building with at least five open lead paint violations in the Bronx, so why not let readers search to find addresses they're interested in? That's what separates a database explorer from a static news story.
Our Storybook component library includes a SearchInput component, which is already styled and ready to configure.
All you need to do is link it up with the reactive tools you learned in Week 4: $state for tracking what the user has typed, and $derived for automatically recalculating the filtered list based on the search term.
Start by importing SearchInput and adding two new variables to your script block. Replace top20 with a search state variable and a filtered derived variable:
<script>
import DatabaseHeader from '$lib/components/DatabaseHeader.svelte';
import RankingList from '$lib/components/RankingList.svelte';
import RankingCard from '$lib/components/RankingCard.svelte';
import SearchInput from '$lib/components/SearchInput.svelte';
let { data } = $props();
let search = $state('');
let filtered = $derived(
data.violations
.filter(b => b.address.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) => a.rank - b.rank)
.slice(0, 20)
);
</script>The $derived expression does two things. First, it filters the buildings array to only include those whose address contains the search term. The .toLowerCase() calls on both sides make the search case-insensitive, so typing "grand" will match "GRAND CONCOURSE." Second, it sorts the remaining results by rank, just as before, and then slices down to the top 20 matches.
Whenever search changes — because the user typed something — Svelte will automatically re-run the $derived expression and update everything on the page that depends on it.
To put the pieces into action, nest the SearchInput inside DatabaseHeader so it appears directly below the headline. Like other parent components, the header allows for children for exactly this purpose.
The SearchInput has a bind:value prop that creates a two-way connection between the input field and the search variable. Whenever the user types, search updates, which triggers the $derived filter, which updates filtered, which re-renders both the count in the header and the cards in the list.
<DatabaseHeader
kicker="Data Explorer"
headline="Tainted Home Tracker"
description="The Bronx buildings with the most lead paint violations"
byline="NYCity News Service"
date="March 2026"
>
<SearchInput bind:value={search} placeholder="Search by address..." />
</DatabaseHeader>
<div class="container">
<RankingList title={search ? `Showing top ${filtered.length} results` : 'Top 20 buildings by open violations'}>
{#each filtered as building (building.id)}
<RankingCard
rank={building.rank}
title={building.address}
value={building.violationCount}
/>
{/each}
</RankingList>
</div>Note that the variable you loop over in the {#each} block also needs to change from top20 to filtered so it reflects the search results.
A final flourish can be seen in the RankingList title, which now changes based on whether the user has typed a search term. If they have, it shows how many results are being displayed. If not, it defaults to "Top 20 buildings by open violations."
Save and try it. Type an address into the search box and watch the list narrow in real time.
It works, but the search box stretches to fill the full width of the header. Like you did with the RankingList, constrain it by wrapping it in a <div> with a max-width.
Update the <style> block to add a rule:
<style>
.container {
max-width: var(--max-width-wide);
margin: 0 auto;
}
.search-wrapper {
max-width: 600px;
}
</style>Then wrap the SearchInput inside the DatabaseHeader.
<DatabaseHeader
kicker="Data Explorer"
headline="Tainted Home Tracker"
description="The Bronx buildings with the most lead paint violations"
byline="NYCity News Service"
date="March 2026"
>
<div class="search-wrapper">
<SearchInput bind:value={search} placeholder="Search by address..." />
</div>
</DatabaseHeader>That should do it.
Part 6: Adding a methodology
If we're going to make big claims, we should explain how we can justify them. We owe our readers transparency, if we expect them to believe us. A simple way to do that is to include a methodology box on the page that describes where the data came from, how it was processed, and any caveats or limitations.
Our Storybook library includes a component specifically for this. It's called a MethodologyBox.
As with the other components, you start by adding MethodologyBox to your imports in +page.svelte.
<script>
import DatabaseHeader from '$lib/components/DatabaseHeader.svelte';
import RankingList from '$lib/components/RankingList.svelte';
import RankingCard from '$lib/components/RankingCard.svelte';
import SearchInput from '$lib/components/SearchInput.svelte';
import MethodologyBox from '$lib/components/MethodologyBox.svelte';
// ... rest of your script
</script>Add a MethodologyBox with your write-up about the data in the same container as the RankingList. You can use any mix of paragraphs, lists, and links inside the box.
<DatabaseHeader
kicker="Data Explorer"
headline="Tainted Home Tracker"
description="The Bronx buildings with the most lead paint violations"
byline="NYCity News Service"
date="March 2026"
>
<div class="search-wrapper">
<SearchInput bind:value={search} placeholder="Search by address..." />
</div>
</DatabaseHeader>
<div class="container">
<RankingList title={search ? `Showing top ${filtered.length} results` : 'Top 20 buildings by open violations'}>
{#each filtered as building (building.id)}
<RankingCard
rank={building.rank}
title={building.address}
value={building.violationCount}
/>
{/each}
</RankingList>
<MethodologyBox>
<p>
The data on this page comes from the Department of Housing Preservation and Development
<a href="https://data.cityofnewyork.us/Housing-Development/Housing-Maintenance-Code-Violations/wvxf-dwi5" target="_blank">via New York City's open data portal</a>.
</p>
<p>
The citations published by the city were filtered to include only lead paint violations that city inspectors listed as unresolved. The data was filtered to only citations linked to addresses in the Bronx and then aggregated by address. Only addresses with five or more open violations were included in the ranking. The data is current as of March 2026.
</p>
<p>The code that executed the analysis is available as open source on GitHub at <a href="https://github.com/palewire/nyc-hpd-bronx-lead-paint-violations" target="_blank">github.com/palewire/nyc-hpd-bronx-lead-paint-violations</a>.</p>
</MethodologyBox>
</div>Save and check the bottom of your page.
Part 7: Creating detail pages
So far, everything lives on a single page. But a good database explorer lets you click into individual records and see their full story.
In SvelteKit, every page on your site is a file inside the src/routes/ folder. Up until now, we've only used src/routes/+page.svelte, which is the homepage. But SvelteKit can create pages at any URL by adding folders and files inside src/routes/.
We want a separate page for every building, at a URL like /building/34291/, where the number is the building's ID. To do this, we'll use a SvelteKit feature called dynamic routes, where the id for each record is called a "parameter."
Creating the dynamic route
Create a new folder in src/routes/ with the name of the parameter in square brackets, like src/routes/building/[id]. If you're on your terminal you could type:
mkdir -p 'src/routes/building/[id]/'The square brackets around [id] are the key. They tell SvelteKit that this part of the URL is a variable, or parameter. When someone visits /building/34291, SvelteKit will load this route and set id property to "34291".
Inside that folder, create two files. The first is +page.js. As with the homepage, this file will contain a load function that tells SvelteKit how to get the data for this page. The second is +page.svelte, which will be the template for rendering the page. It's the same structure as our root page, just in a subfolder.
Again, if you're on your terminal, you could create the files with the following.
touch 'src/routes/building/[id]/+page.js'
touch 'src/routes/building/[id]/+page.svelte'Inside src/routes/building/[id]/+page.js, paste the following code.
import buildings from '$lib/data/bronx_buildings.json';
export function load({ params }) {
const building = buildings.find(b => b.id === params.id);
return { building };
}
export function entries() {
return buildings.map(b => ({ id: b.id }));
}There are two functions here. Let's take them one at a time.
The load function receives a params object containing the dynamic parts of the URL. Since our folder is called [id], params.id will contain whatever value appears in the URL. We use JavaScript's .find() method to locate the matching building in our dataset, and return it as an object. That's the data properties that will be passed to the page component when it renders.
The entries function serves a different purpose. SvelteKit needs to know every possible page that could exist. The entries function provides that list. It maps over all the buildings and returns an array of objects, each containing an id value. When you build the site, SvelteKit will loop through these and generate a separate HTML page for each one.
Creating the detail page
Now create the page that will display the building's details. Open src/routes/building/[id]/+page.svelte and toss this in.
<script>
import { base } from '$app/paths';
import DatabaseHeader from '$lib/components/DatabaseHeader.svelte';
import Dashboard from '$lib/components/Dashboard.svelte';
import BigNumber from '$lib/components/BigNumber.svelte';
import Card from '$lib/components/Card.svelte';
import MethodologyBox from '$lib/components/MethodologyBox.svelte';
let { data } = $props();
let building = data.building;
function formatDate(dateStr) {
const [year, month, day] = dateStr.split('-').map(Number);
return new Date(year, month - 1, day).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
});
}
</script>
<DatabaseHeader
kicker="Tainted Home Tracker"
headline={building.address}
>
<Dashboard>
<BigNumber number={`#${building.rank}`} label="Rank" />
<BigNumber number={building.violationCount} label="Open violations" />
</Dashboard>
<a class="back" href="{base}/">
← Back to ranking
</a>
</DatabaseHeader>
<div class="container">
<div class="card-grid">
{#each building.violations as violation (violation.violationId)}
<Card>
<p class="card-date">{formatDate(violation.date)}</p>
<p>{violation.specificDescription || 'No description available'}</p>
</Card>
{/each}
</div>
<MethodologyBox>
<p>
The data on this page comes from the Department of Housing Preservation and Development
<a href="https://data.cityofnewyork.us/Housing-Development/Housing-Maintenance-Code-Violations/wvxf-dwi5" target="_blank">via New York City's open data portal</a>.
</p>
<p>
The citations published by the city were filtered to include only lead paint violations that city inspectors listed as unresolved. The data was filtered to only citations linked to addresses in the Bronx and then aggregated by address. Only addresses with five or more open violations were included in the ranking. The data is current as of March 2026.
</p>
<p>The code that executed the analysis is available as open source on GitHub at <a href="https://github.com/palewire/nyc-hpd-bronx-lead-paint-violations" target="_blank">github.com/palewire/nyc-hpd-bronx-lead-paint-violations</a>.</p>
<p>The map tiles are provided by OpenFreeMap and OpenStreetMap contributors.</p>
</MethodologyBox>
</div>
<style type="scss">
.container {
max-width: var(--max-width-wide);
margin: 0 auto;
padding: 0 var(--spacing-md);
}
:global(.row) {
margin-top: var(--spacing-sm) !important;
margin-bottom: var(--spacing-sm) !important;
justify-content: flex-start !important;
}
@media (max-width: 767px) {
:global(.row) {
flex-direction: column;
}
}
:global(.row .big-number .number) {
font-size: 2.25rem;
}
:global(.row .big-number .label) {
font-size: var(--font-size-sm);
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--spacing-md);
margin: var(--spacing-lg) 0;
}
:global(p.card-date) {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
color: var(--color-accent) !important;
text-transform: uppercase;
letter-spacing: 0.03em;
margin-bottom: var(--spacing-xs) !important;
}
a.back {
display: inline-block;
font-size: var(--font-size-xs);
color: var(--color-text);
text-decoration: none;
text-transform: uppercase;
&:hover {
text-decoration: underline;
}
}
</style>The key line is let { data } = $props(). When a page has a +page.js load function, SvelteKit passes whatever it returns as a data prop. So data.building is the building object we found in the load function.
The DatabaseHeader at the top reuses the same component from the list page, this time showing the building's address and violation summary.
The violations themselves are in a <table>, cobbled together outside of our core components library.
Linking to detail pages
Before we can see the detail pages, we need to wire up the list to link down to them. Go back to src/routes/+page.svelte and add an href prop to each RankingCard. First, import base:
<script>
import { base } from '$app/paths';
import DatabaseHeader from '$lib/components/DatabaseHeader.svelte';
import RankingList from '$lib/components/RankingList.svelte';
// ... rest of your imports
</script>The base variable is a SvelteKit helper that holds the URL prefix for your site on GitHub Pages. Your project will live at a URL like yourusername.github.io/bronx-lead-paint/ — not at the root of a domain — so any links between your pages need to include that prefix to work correctly.
Then add the href attribute to each RankingCard:
{#each filtered as building (building.id)}
<RankingCard
rank={building.rank}
title={building.address}
href="{base}/building/{building.id}"
value={building.violationCount}
/>
{/each}Save and click on any card to test it.
Part 8: Adding a locator map
Each building in your dataset has latitude and longitude coordinates. Let's use them to add a small map to the detail page that shows where the building is located.
The template includes a LocatorMap component that centers on any set of coordinates.
We're going to place this map inside the DatabaseHeader on the detail page, so it appears as a graphic in the right column alongside the building's name and violation count.
Open src/routes/building/[id]/+page.svelte and add the import:
<script>
import { base } from '$app/paths';
import DatabaseHeader from '$lib/components/DatabaseHeader.svelte';
import Dashboard from '$lib/components/Dashboard.svelte';
import BigNumber from '$lib/components/BigNumber.svelte';
import Card from '$lib/components/Card.svelte';
import MethodologyBox from '$lib/components/MethodologyBox.svelte';
import LocatorMap from '$lib/components/LocatorMap.svelte';
let { data } = $props();
let building = data.building;
</script>Now we need to pass the LocatorMap into DatabaseHeader. Earlier, when we nested SearchInput inside DatabaseHeader, we placed it between the opening and closing tags. That worked because DatabaseHeader has a children slot. Anything placed between the tags goes there automatically.
But DatabaseHeader also has a second slot called graphic, which renders content in a right-hand column. To target a specific named slot like this, Svelte uses a {#snippet} tag.
<DatabaseHeader
kicker="Tainted Home Tracker"
headline={building.address}
>
{#snippet graphic()}
<LocatorMap
longitude={building.lng}
latitude={building.lat}
zoom={13}
dot={true}
width={200}
credit=" "
/>
{/snippet}
<Dashboard>
<BigNumber number={`#${building.rank}`} label="Rank" />
<BigNumber number={building.violationCount} label="Open violations" />
</Dashboard>
<a class="back" href="{base}/">
← Back to ranking
</a>
</DatabaseHeader>The {#snippet graphic()} block tells Svelte: "Don't put this in the default children slot. Pass it to the slot named graphic instead." The DatabaseHeader then renders it in a right column on desktop screens and stacks it below the headline on mobile.
Save and visit a building's detail page. You should see the map appear next to the building name, with a red dot marking the location.
In a couple of weeks, we'll learn how to build full interactive maps from scratch using a library called MapLibre. For now, this locator map is enough.
Save and commit. Before you push to GitHub, remember to visit your repository's settings to activate GitHub Pages. Then push to GitHub. Watch the "Actions" tab to make sure the deploy succeeds and check your live site.
Congratulations. As simple as it is, you've built a real database explorer. You've learned almost all of the fundamentals that data journalists use to build these projects.
Homework
Task 1: Build your own database explorer
Pick a dataset. It can be from another class, your previous work or downloaded from the NYC Open Data portal. Build a database explorer for it.
Your project must have:
- A searchable or filterable list page
- Detail pages for individual records or groups of records
- A
DatabaseHeaderwith a headline, description, byline, and date - A
MethodologyBoxat the bottom explaining the data source and how it was processed - Add at least one other component or feature not found in this tutorial
Deploy it to GitHub Pages and send me the link.
Task 2: Prepare to present
Be ready to share your database explorer with the class and explain how you built it. You should be prepared to:
- Show the dataset you chose and explain why it's interesting
- Walk through your search and filter logic
- Show a detail page
- Discuss what editorial choices you made — what did you include, what did you leave out, and why






















