Yesterday, the authentication foundation was built, Axios with JWT interceptors, auth context, login, and register pages. Today was the first day real data from the Django API appeared in the browser. The browse projects page and the project detail page: the two most important public-facing pages in DevCollab.
The Projects Service
Before building any page, I wrote services/projects.js: a dedicated file for all project-related API calls. Every component that needs project data calls a function from this file rather than making Axios calls directly. This keeps API logic in one place and components focused on rendering.
The service has functions for fetching all projects with optional search and filter parameters, fetching a single project by ID, and fetching the current user's own projects. Search and filter parameters are passed as query params to the Axios instance, which appends them to the URL automatically. The Django API handles the filtering on the server side and returns the right results.
The Browse Projects Page
The browse projects page is the homepage of DevCollab for anyone who isn't logged in yet. It's the first thing a visitor sees and the main entry point into the platform.
The page has two sections: the search and filter bar at the top, and the project card grid below it.
The search and filter bar has three inputs: a keyword search field, a tech stack filter, and a role filter. When the user types and submits, the values are pushed into the URL as query parameters. The page reads these parameters from the URL, passes them to the project's service function, and displays the filtered results. Using the URL for filter state means the filtered view is shareable and bookmarkable; copying the URL gives you the same filtered results. It also means the browser back button works correctly.
The project card grid renders a ProjectCard component for each project returned from the API. If the API returns an empty array, an empty state message is shown with a prompt to clear the filters. While the data is loading, a loading state is shown, skeleton cards, or a spinner, so the page doesn't flash blank content.
The page is fully public; no authentication is required to browse. The Django API's project list endpoint is open, so no Authorization header is needed for this page.
The ProjectCard Component
ProjectCard is the most reused component in the app. It appears on the browse page, the dashboard, and the public profile page. Getting it right today means all three pages benefit.
The card shows five pieces of information: the project title as a link to the detail page, the owner's username, a truncated description, tech stack tags, and roles needed tags. The tech stack and roles are rendered as individual colored badges using the TechStackTag and RoleTag components, respectively.
The design decision on description truncation was deliberate, showing the first 120 characters with an ellipsis. Full descriptions can be long, and showing them in full on the browse page would make cards have inconsistent heights and hard to scan. The detail page shows the full description.
The card's click target is the title, a Next.js Link component pointing to /projects/[id]. The whole card isn't clickable because the owner's username also needs to be a link to the user's profile. Two separate link targets on the same card means that making the whole card clickable would create nested anchors.
TechStackTag and RoleTag
Two tiny components that do one thing each render a styled badge for a tech stack item or a role. They're separate components rather than inline styles because they're used in multiple places, and having them as components means styling them in one place updates them everywhere.
TechStackTag renders with a blue color scheme. RoleTag renders with a green color scheme. The visual distinction helps users quickly scan what a project uses versus what roles it needs.
The Project Detail Page
The project detail page is the most complex page built so far because it renders differently for four different types of users.
Unauthenticated users see the full project information and a "Login to apply" link where the collaboration button would be. They can read everything but can't take any action.
The project owner sees edit and delete buttons instead of a collaboration button. They can't apply it to their own project. Clicking delete shows a confirmation before making the delete API call.
Authenticated users who have already sent a request see a status badge, pending, accepted, or rejected, instead of an apply button. The status comes from the request_status field in the project detail API response, which the Django serializer computed from the current user's request on that project.
Authenticated users who haven't applied yet see the "Request to Collaborate" button. Clicking it navigates to /projects/[id]/apply, the application form page being built tomorrow.
The page layout is a two-column grid on desktop, the main project information on the left taking up two-thirds of the width, and a sidebar on the right showing the owner's profile card and the action button. On mobile, the layout collapses to a single column with the action section moving below the project info.
The owner profile card in the sidebar shows the owner's avatar, username as a link to their profile, bio, skills tags, and GitHub link if they've set one. All of this data comes from the owner_data field nested in the project detail API response.
URL-Based Filter State
The decision to store search and filter state in the URL rather than in React state deserves explanation because it's a pattern worth understanding.
The naive approach is to store filter values in useState. This works but has two problems: refreshing the page loses the filter state, and the filtered URL can't be shared with someone else.
The better approach is useSearchParams and useRouter from Next.js. When a filter is applied, router.push updates the URL with the new query parameters. The page reads the current filter values from useSearchParams on every render. When the URL changes, whether from user input or browser navigation, the component re-renders and refetches with the new parameters.
This means /projects?search=django&tech_stack=react always shows the same results regardless of how you arrived at that URL. It's a small architectural decision that makes the app feel significantly more polished.
Loading and Empty States
Both pages have proper loading and empty states. This is one of those things that's easy to skip during development and embarrassing to ship without.
The browse page shows three skeleton cards during the initial load, gray placeholder shapes in the same layout as real cards. Once the data arrives, the skeletons are replaced with real cards. If the data fetch fails, an error message with a retry option is shown.
The empty state on the browse page, when search or filters return no results, shows a message explaining there are no matching projects and offers a "Clear filters" link that resets all filter parameters and returns to the full list.
The project detail page shows a centered loading spinner while the project data is being fetched. If a project ID in the URL doesn't exist, the API returns 404; the page shows a "Project not found" message with a link back to the browse page. Next.js's notFound() function handles this cleanly.
Where Things Stand After Day 92
Two complete public pages are working with real data. Users can browse projects, search and filter the results, and view full project details. The correct action, login prompt, owner controls, status badge, or apply button renders based on who is viewing.
Thanks for reading. Feel free to share your thoughts!
Top comments (0)