Huge congrats for making it this far. Seriously. 🙌 You’ve already built the core product loop of a real task manager: users can create tasks, edit them, and update their completion status with fast, responsive UI.
In this final lesson, you’ll add the kind of polish that makes an app feel production-ready instead of just functional. That means safe deletes with a confirmation modal so users do not lose data by mistake, global loading and error UI for the whole dashboard segment so the app feels stable and predictable, and a more cohesive data strategy so lists, filters, and detail pages stay in sync after every action.
As always, one rule stays in place: do not modify anything under src/app/api/**. The backend is read-only. Your job here is to perfect the frontend experience on top of the existing API.
You’ve already seen the general product loop for actions like Create and Update. You call the API client through api.*, show toast feedback, revalidate SWR keys with mutate(...), and navigate the user to the most sensible next screen.
Deletes follow that same pattern, but they need one extra step first: confirmation. That is because delete actions are destructive and usually irreversible. A mistaken create or edit can often be fixed later, but a mistaken delete is much more costly. That is why professional apps slow the user down just enough before destructive actions by asking for confirmation.
That extra confirmation step is exactly why we add a modal in this lesson.
This code block is the heart of the safe delete experience. It introduces a reusable <Modal /> component and wires the /tasks/[id] page so users must confirm before deleting. On success, the app shows a toast, revalidates task data, and navigates back to /tasks.
The reusable Modal component gives you a consistent confirmation dialog anywhere in the app. It handles Escape-to-close, renders an overlay and centered panel, and provides clear “Cancel” and “Delete” actions.
-
isOpencontrols whether the modal is mounted at all. When it isfalse, the modal returnsnull, which means it cannot intercept clicks, appear visually, or interfere with the rest of the page. That is what makes it behave like a true modal rather than just a hidden block of markup.
This page integrates the modal and implements the real delete flow: api.del → toast → revalidate → redirect.
You should approach this in two tightly scoped steps: first build the modal, then wire it into the detail page. That is a better prompting strategy because it separates reusable UI creation from page-level behavior.
For the modal itself, keep the prompt focused on the component file only and describe the modal behavior clearly. You want isOpen, onClose, title, overlay and centered panel rendering, and support for closing on Escape or overlay interaction.
Modal implementation prompt:
Codex, modify ONLY
src/components/ui/Modal.tsx. Create a reusable modal withisOpen,onClose,title, optional description, overlay + centered panel, close on overlay click or Escape. Do NOT modify anything undersrc/app/api/**.
Then use a second prompt for the detail page wiring. That prompt should explicitly describe the delete button, modal opening behavior, confirmation flow, API call, toast feedback, cache revalidation, and redirect.
Wiring prompt:
Codex, modify ONLY
src/app/(dashboard)/tasks/[id]/page.tsx. Add a Delete button that opens the modal, and on confirm callapi.del("/api/tasks/" + id), show toast success/error,mutate("/api/tasks"), and navigate to/tasks. Do NOT touch .
These two special files are segment-wide polish. Next.js will automatically use them whenever something inside the (dashboard) segment is loading or throws an error.
This gives the app a smoother experience during transitions and pending work. Instead of showing nothing or flashing between layouts, the user sees a skeleton that hints at the page structure.
-
This loading UI hints at the real layout instead of showing a generic spinner. Users can tell that headline and card content are on the way, which makes the wait feel more connected to the page they were expecting.
-
It works automatically at the segment level. You do not need to manually render this component inside every dashboard page. Next.js uses it whenever something in that route segment is pending, which keeps the experience consistent.
-
Skeleton loading often reduces perceived latency. Even if the actual wait time is the same, a layout-shaped placeholder feels more intentional and more polished than a blank screen or abrupt flicker.
When something goes wrong, users should see a consistent recovery screen instead of a broken or confusing UI.
-
This is a real Next.js error boundary, which is why it must be a client component and accept the
(error, reset)signature. That shape is not optional — it is how the framework knows how to hand control back to your UI when something fails. -
reset()is what turns the screen into a recovery path instead of a dead end. Without it, the user would only see an error message. With it, they have a clear action to retry rendering the segment. -
The error messaging stays user-friendly. Showing
error.messagecan be helpful, but the screen avoids overwhelming users with a stack trace or developer-oriented details. That is the right balance for product-facing error UI.
You should scope this prompt to exactly two files: the loading UI and the error boundary. The goal is not to redesign the dashboard, but to add segment-wide fallback behavior.
A good prompt should explicitly mention skeleton loading UI, the (error, reset) signature, and the “Try again” button that calls reset(). Those are the functional requirements that matter most here.
Codex, modify ONLY
src/app/(dashboard)/loading.tsxandsrc/app/(dashboard)/error.tsx. Build a dashboard skeleton loading UI and an error boundary component with signature(error, reset)and a “Try again” button that callsreset(). Do NOT edit anything undersrc/app/api/**.
This final polish is about consistency. Every list view should fetch tasks the same way and use keys that match the keys you mutate elsewhere in the app.
The most important part of this practice is the SWR key strategy:
/api/tasks/api/tasks?completed=true/api/tasks?completed=false
When those keys are used consistently, all your mutate(...) calls become more predictable. That means creates, edits, toggles, and deletes can refresh the right views without special case logic all over the app.
-
The dashboard uses
/api/tasksjust like the main tasks list. That means anymutate("/api/tasks")call refreshes the dashboard automatically too. This is one of the main benefits of shared SWR keys: separate pages stay synchronized without needing custom refresh code. -
The stats are derived from the same fetched data instead of being fetched separately. That avoids extra endpoints and ensures the summary numbers always match the actual list the user sees. Derived data is often the cleanest solution when the underlying dataset is already available.
-
TaskRowremains a shared UI building block here as well. The dashboard is just another list view, so it should render tasks the same way instead of inventing a second task-row design.
-
Using the same SWR key means refresh behavior stays predictable. Any action that mutates
/api/tasksupdates this page automatically because it shares the same cache identity as the dashboard overview. -
The empty state is also part of polish. Instead of only saying “No tasks,” it gently suggests the next action the user can take, which helps the page feel more guided and intentional.
-
Local loading and error states still matter even though the dashboard segment also has global loading and error handling. Segment-level boundaries are great for broad fallback behavior, but page-level messaging can still provide more specific context.
-
The SWR key encodes the filter itself.
/api/tasks?completed=trueand/api/tasks?completed=falseare distinct caches, which is exactly what you want because they represent two different views of the data. -
Filtered views stay correct only if you revalidate the same key shapes elsewhere in the app. That is why consistent key naming matters so much. If a toggle or delete revalidates the right filtered keys, those pages stay in sync naturally.
-
The UI messaging matches the context of the page. Loading and empty states mention filtered tasks specifically, which helps the page feel tailored instead of generic.
You should scope this prompt to exactly three files and explicitly mention the SWR keys you want used. This is important because the real lesson here is not just “switch to SWR,” but “make the whole app consistent about the same cache identities.”
A good prompt should require shared api.get, consistent keys, and clear loading, error, and empty states across all three views.
Codex, modify ONLY
src/app/(dashboard)/page.tsx,src/app/(dashboard)/tasks/page.tsx, andsrc/app/(dashboard)/tasks/filter/page.tsx. UseuseSWRwith the sharedapi.getto fetch tasks consistently, and ensure the SWR keys match what the app mutates:/api/tasks,/api/tasks?completed=true,/api/tasks?completed=false. Add loading/error/empty states. Do NOT edit anything undersrc/app/api/**.
You now have a frontend that behaves like a real product.
Users can delete safely with confirmation instead of risking accidental data loss. The dashboard segment has stable, reusable loading and error handling, so the app feels more resilient during transitions and failures. And your data fetching strategy is now consistent across dashboard views, lists, and filters, which keeps everything synchronized when actions happen anywhere in the app.
That is the kind of finish-line polish many people skip, but it is exactly what makes an app feel professional.
