Let's build a clean, searchable developer resource directory to keep design systems, docs, and tutorials organized in one place.
What you'll learn:
- Setting up a typed resource data structure in TypeScript.
- Implementing fast client-side filtering and search with React hooks.
- Designing a responsive grid layout using Tailwind CSS.
- Handling empty states and keyboard accessibility.
Structuring the Resource Data Model
Before touching UI, let's type out our resource records. Good TypeScript interfaces prevent messy rendering bugs down the line when data fields are missing or optional.
export type Category = 'design' | 'code' | 'architecture' | 'tools';
export interface Resource {
id: string;
title: string;
description: string;
category: Category;
url: string;
author: string;
}
export const RESOURCES: Resource[] = [
{
id: '1',
title: 'Awwwards Creative Pass',
description: 'Curated design courses and deep dives into digital craftsmanship.',
category: 'design',
url: 'https://www.awwwards.com',
author: 'Awwwards',
},
{
id: '2',
title: 'Advanced Prototyping Patterns',
description: 'Moving beyond basic hotspots to complex interactive mockups.',
category: 'architecture',
url: 'https://example.com/prototyping',
author: 'UX Lab',
},
];
Speed note: Keep your mock or initial dataset lean. If you plan to load hundreds of items, structure your objects with flat primitives so filtering runs instantly without deep object traversal.
Building the Search and Filter State
We need a way to slice through resources by keyword search and category tags. Combining useState with useMemo keeps UI rendering snappy even as the list grows.
import { useState, useMemo } from 'react';
import { RESOURCES, Category, Resource } from './types';
export function useResourceFilter() {
const [query, setQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<Category | 'all'>('all');
const filteredResources = useMemo(() => {
return RESOURCES.filter((item) => {
const matchesQuery =
item.title.toLowerCase().includes(query.toLowerCase()) ||
item.description.toLowerCase().includes(query.toLowerCase());
const matchesCategory =
selectedCategory === 'all' || item.category === selectedCategory;
return matchesQuery && matchesCategory;
});
}, [query, selectedCategory]);
return {
query,
setQuery,
selectedCategory,
setSelectedCategory,
filteredResources,
};
}
Crafting the Search Bar and Filter Pills
A great search component is responsive and accessible. Let's wire up our search hook into a clean, keyboard-navigable Tailwind interface.
import React from 'react';
import { Category } from './types';
interface FilterControlsProps {
query: string;
setQuery: (q: string) => void;
selectedCategory: Category | 'all';
setSelectedCategory: (c: Category | 'all') => void;
}
const CATEGORIES: (Category | 'all')[] = ['all', 'design', 'code', 'architecture', 'tools'];
export function FilterControls({ query, setQuery, selectedCategory, setSelectedCategory }: FilterControlsProps) {
return (
<div className="flex flex-col gap-4 mb-8">
<label htmlFor="search" className="sr-only">Search resources</label>
<input
id="search"
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search books, guides, and courses..."
className="w-full px-4 py-3 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<div className="flex flex-wrap gap-2" role="group" aria-label="Category filters">
{CATEGORIES.map((cat) => (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
className={`px-3.5 py-1.5 rounded-full text-sm font-medium capitalize transition-colors ${
selectedCategory === cat
? 'bg-blue-600 text-white'
: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-700'
}`}
>
{cat}
</button>
))}
</div>
</div>
);
}
Accessibility tip: Notice the
sr-onlylabel on the search input and therole="group"on the category pills. Screen readers need context when buttons toggle state outside of traditional form submission.
Rendering the Resource Grid with Empty States
When filters return zero results, don't leave the user staring at a blank screen. Always provide a clear fallback state so they know to adjust their query.
import React from 'react';
import { Resource } from './types';
export function ResourceGrid({ resources }: { resources: Resource[] }) {
if (resources.length === 0) {
return (
<div className="text-center py-12 border border-dashed border-zinc-300 dark:border-zinc-800 rounded-xl">
<p className="text-zinc-500 dark:text-zinc-400">No resources found matching your criteria.</p>
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{resources.map((item) => (
<a
key={item.id}
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="p-5 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 hover:border-blue-500 dark:hover:border-blue-500 transition-all flex flex-col justify-between group"
>
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold uppercase tracking-wider text-blue-600 dark:text-blue-400">
{item.category}
</span>
<span className="text-xs text-zinc-400">By {item.author}</span>
</div>
<h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
{item.title}
</h3>
<p className="text-sm text-zinc-600 dark:text-zinc-400 mt-1 line-clamp-2">
{item.description}
</p>
</div>
<div className="mt-4 flex items-center text-sm font-medium text-blue-600 dark:text-blue-400">
Explore resource →
</div>
</a>
))}
</div>
);
}
Putting It All Together
Here is how the parent container hooks up the state management, filter controls, and the resource grid into a complete page layout.
import React from 'react';
import { useResourceFilter } from './useResourceFilter';
import { FilterControls } from './FilterControls';
import { ResourceGrid } from './ResourceGrid';
export default function ResourceDirectoryPage() {
const {
query,
setQuery,
selectedCategory,
setSelectedCategory,
filteredResources,
} = useResourceFilter();
return (
<main className="max-w-4xl mx-auto px-4 py-12">
<header className="mb-8">
<h1 className="text-3xl font-bold text-zinc-900 dark:text-zinc-100">Engineering & Design Hub</h1>
<p className="text-zinc-600 dark:text-zinc-400 mt-2">
Hand-picked books, articles, and interactive courses for modern web builders.
</p>
</header>
<FilterControls
query={query}
setQuery={setQuery}
selectedCategory={selectedCategory}
setSelectedCategory={setSelectedCategory}
/>
<ResourceGrid resources={filteredResources} />
</main>
);
}
Summary & Next Steps
Organizing educational content doesn't require a heavy CMS. With a clean TypeScript schema and useMemo for filtering, you can build a fast, searchable directory in minutes.
- Next Steps:
- Connect your data layer to a headless CMS or local Markdown files using
contentlayer. - Add URL search params persistence (
useSearchParams) so users can share filtered views.
- Connect your data layer to a headless CMS or local Markdown files using