Introduction: Let the URL Drive the UI

Welcome back! 🙌 At this point, your Task Manager UI already feels like a real app. It has a dashboard shell, consistent task rows, reusable buttons, and pages that fetch live task data from the backend.

In this final unit, you’ll make the UI feel smarter by letting the URL control what the user sees. This is an important idea in modern web apps because the URL is not just an address bar detail — it can also act as part of your application state. When the URL changes, the UI can react to it, which makes views shareable, bookmarkable, and easier to understand.

There are three main improvements in this unit. First, you’ll build a proper Filtered Tasks view that reads a query param (completed=true|false), validates it, fetches the matching data, and renders results using your existing TaskRow component. Next, you’ll polish the stub routes so they feel more intentional, even though full CRUD is not implemented yet. Finally, you’ll add a lightweight toast notification system so even a temporary page can give the user friendly, app-like feedback.

Together, these changes make the UI feel more dynamic and more product-like without requiring any backend changes.

Previously…

In the previous unit, you extracted repeated markup into reusable components like TaskRow and Button, and you wired up navigation so /tasks/new and /tasks/[id] routes already exist. That gave the app a stronger internal structure and made the UI more consistent across pages.

Now you’ll build on top of that structure with URL-driven filtering, stronger product-style stub pages, and a toast system for small but meaningful feedback. These are the kinds of additions that make an app feel more polished, even before all of its features are complete.

And as always, one rule remains unchanged: the backend lives under src/app/api/** and is read-only for this course unit. You should not modify it. Your job is to consume the existing backend cleanly and build the frontend around the API contract it already provides.

Keeping Frontend and Backend Separate

Your UI consumes endpoints that already exist. In this unit, you’ll specifically rely on: GET /api/tasks/filter?completed=true|false → { data: Task[] }

This matters for two reasons. First, the backend expects exact string values: "true" or "false". That means your frontend should not invent alternate values like "yes", "0", or booleans in the URL. Second, the backend wraps its returned task array inside an object, so you must read tasks from json.data instead of assuming the response itself is the array.

If your UI validates correctly and fetches correctly, the same filter page will continue to work no matter how many tasks exist or how the list changes over time. That is one of the benefits of keeping frontend and backend responsibilities cleanly separated: each side stays predictable.

Building Filter Controls That Update the URL

A filter UI should do two things well. It should change the URL so the view can be shared or bookmarked, and it should reflect the current state so the user can immediately tell which filter is active.

We’ll implement this with a TaskFilters component. It will read query params from the current URL and use router.push(...) to switch between filter states. This is a simple pattern, but it is powerful because it avoids the need for extra local state or a global state library. Instead, the URL becomes the source of truth.

TaskFilters component

This code lives in src/components/tasks/TaskFilters.tsx. It renders two reusable Buttons, highlights the selected filter based on the URL, and updates the URL when the user clicks a filter.

  • This is a client component because it uses hooks like useRouter and useSearchParams. In Next.js App Router, those hooks are only available in client components, which is why the file needs the 'use client' directive. This is a common pattern whenever a component reacts directly to navigation state.

  • useSearchParams() makes the URL the source of truth for which filter is currently active. That means the buttons do not need their own separate state, because the selected value is already encoded in the query string. This is one of the cleanest examples of URL-driven UI: the interface reflects what the URL says, rather than maintaining an independent copy of the same information.

  • router.push(...) updates the URL without a full page reload, which keeps the app feeling fast and smooth. It also means changing the filter behaves like real app navigation rather than like a traditional full-page refresh. Once you get used to this pattern, it becomes a very natural way to build filter views, tabs, and other navigation-driven UI.

Rendering Filtered Tasks with Query Validation

Now we’ll turn /tasks/filter into a real page. This page needs to read completed from the URL, validate it, fetch from /api/tasks/filter?completed=..., render a list with TaskRow, and show loading, error, and empty states just like the rest of your task views.

This code lives in src/app/(dashboard)/tasks/filter/page.tsx. It ties together the filter controls, the validation logic, and the filtered fetch request.

Making Sure the Sidebar Filters Link Works

Once the filter page exists, your layout should send users to a sensible default filter when they click “Filters.” In this unit, that default is /tasks/filter?completed=false.

This code lives in src/app/(dashboard)/layout.tsx. The important pieces are the Filters href and the active-state rule that recognizes filter routes.

  • The sidebar uses Link from next/link, which gives client-side navigation and helps preserve the smooth dashboard experience. Since the Filters destination is a normal navigation item in the layout, is the natural fit here.

Polishing Stub Routes to Feel Intentional

Even before full CRUD is implemented, good apps try not to make routes feel broken. Stub pages are still part of the overall product experience. They should explain what the route is for, clarify what will come later, and give the user an easy way back.

This code lives in src/app/(dashboard)/tasks/[id]/page.tsx. It shows the task id directly in the heading and explains what functionality will arrive later.

  • [id] creates a dynamic route, and Next.js passes params.id automatically. Showing the id in the heading is a simple but effective way to prove that navigation from TaskRow is wired correctly and that the dynamic segment is working as expected.

  • This remains a server component because it does not use any client-side hooks. That is a useful reminder that not every page needs 'use client'. In App Router, it is often best to stay with the default server behavior unless a page truly needs client-side interactivity.

  • The back link is important for user experience, especially on routes that are not fully implemented yet. A page that explains itself and offers an easy way back feels intentional. A page with no guidance and no navigation path tends to feel broken, even if the route technically works.

Adding Toast Feedback Across the Dashboard

Toasts are a small but very useful UX primitive. They let the app communicate feedback without requiring a full page refresh or a large modal. Even when a page is still a stub, a toast can acknowledge an action and make the interface feel responsive.

We’ll implement a ToasterProvider that stores toasts in state, a useToast() hook with success, error, and info helpers, automatic dismissal after about 2.5 seconds, and a fixed top-right stack for rendering notifications.

This code lives in src/components/ui/Toast.tsx. It provides toast functionality through React context and renders the visible toast stack.

Wrapping the Dashboard Layout with ToasterProvider

Toasts should work in every dashboard route, so the best place to mount the provider is the dashboard layout itself. That way, every page inside (dashboard) can use useToast() without setting up its own provider.

This code lives in src/app/(dashboard)/layout.tsx. The layout keeps its sidebar shell, but now wraps everything in <ToasterProvider>.

  • Wrapping at the layout level means the toast system is mounted once for the whole dashboard, not separately for each page. That is important because it keeps the toast system stable during navigation and avoids unnecessary reinitialization.

Triggering a Toast from the New Task Stub Page

Now we’ll prove the toast system works by adding a button that triggers an info toast. This gives the stub page a small but meaningful interaction and helps it feel intentional instead of unfinished.

This code lives in src/app/(dashboard)/tasks/new/page.tsx. It becomes a client component so it can call useToast(), and the button displays a friendly message.

  • This must be a client component because useToast() is a hook and depends on React context. In App Router, hooks require 'use client', so the page has to opt into client-side behavior in order to use the toast API.

  • The toast message is a small UX micro-interaction, but it has real value. Instead of leaving the page feeling passive, it acknowledges the user’s action and explains why the feature is not available yet. Small interactions like this help the app feel deliberately staged rather than incomplete.

  • The message also points directly toward future behavior by mentioning the eventual POST /api/tasks flow. That helps learners connect the current stub to the next unit’s implementation. In a project course, this kind of continuity makes the learning path feel much more coherent.

Codex Prompts for This Unit

Because this unit spans routing, query parsing, and global UI infrastructure, your Codex prompt needs to stay especially disciplined. This is the kind of unit where vague prompts can easily cause the model to overreach.

A strong prompt should name only the files to modify, describe the exact navigation targets like /tasks/filter?completed=true|false, explicitly require query validation for "true" and "false", remind Codex to parse tasks from json.data, and include the rule “do not edit src/app/api/**.” Those instructions help keep the work focused on the frontend behavior you actually want.

This is how you prevent Codex from inventing new endpoints, changing backend behavior, or solving the problem in a way that does not match the lesson objective. In multi-file frontend work, clear scope is often the difference between a useful result and a messy one.

  • Tight file scope is especially important here because the unit touches layout, shared components, and route pages. If you do not define the boundaries clearly, Codex may make broad “cleanup” changes that were never part of the task. Strong prompts keep the output aligned with the lesson rather than with a generic idea of improvement.

  • Clear behavior requirements matter too. Saying “add filters” is not enough. Saying “update the URL, validate the query param, fetch filtered data from /api/tasks/filter, parse from json.data, and keep the backend untouched” gives Codex a concrete target. The more exact the success criteria, the more reliable the generated edits become.

Recap

By finishing this unit, you’ve turned a basic list UI into something that feels much closer to a real dashboard app. Filter controls now update the URL and visually reflect the active filter, and /tasks/filter validates the query param before fetching filtered results from the backend.

Your sidebar now links to a sensible default filter, your stub routes feel more intentional and navigable, and a minimal toast system provides app-like feedback across the dashboard. These are all relatively small additions on their own, but together they make the product feel significantly more polished.

Most importantly, you now have a clean and scalable frontend foundation. The routing structure is in place, the shared components are doing real work, and the UI already behaves like an application rather than a collection of disconnected pages. That puts you in a strong position for the next step: implementing real CRUD behavior on top of the structure you’ve already built.

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal