Back to All Articles
reacttailwinduxfrontend

Curating Developer Learning Paths: Building a Resource Hub in React & Tailwind

Learn how to build a performant, searchable resource hub in React and Tailwind to organize developer learning materials effectively.

2026-11-02
Curating Developer Learning Paths: Building a Resource Hub in React & Tailwind

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.

TSX
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.

TSX
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.

TSX
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-only label on the search input and the role="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.

TSX
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 &rarr;
          </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.

TSX
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.