This article details the implementation of a complete user flow in Next.js 15, from selecting a service on the homepage to requesting a specific package on a details page. It covers the technique of passing state between pages using useSearchParams
, conditional rendering based on the URL, and managing user interaction through a modal that communicates with a Server Action to process the request. The goal is to present a practical case study on architectural decisions, code organization, and the lessons learned along the way.
Tech Stack & Key Dependencies
The tool selection was focused on performance, type safety, and developer experience.
- Framework: Next.js 15 (
@next/15.4.6
) with App Router and Turbopack. - Language: TypeScript (
@typescript/5
). - Styling: Tailwind CSS v4 (
@tailwindcss/4
). - UI Components:
- Headless Primitives: Radix UI (
@radix-ui/react-select
,@radix-ui/react-dialog
) to ensure accessibility. - Carousels: Embla Carousel (
embla-carousel-react
) for its lightweight and performant nature.
- Headless Primitives: Radix UI (
- Backend & Forms:
- Server Logic: Server Actions.
- Validation: Zod (
zod/4
). - Email Delivery: Nodemailer (
nodemailer/7
) & Mailchimp Marketing (@mailchimp/mailchimp_marketing
).
- Testing:
- Framework: Jest (
jest/30
). - Environment: JSDOM (
jest-environment-jsdom
). - Libraries: Testing Library (
@testing-library/react
,@testing-library/user-event
).
- Framework: Jest (
Project Architecture
Code organization was a priority to ensure scalability and maintainability. A modular structure was adopted to clearly separate concerns:
src/
├── actions/ # Server-side logic (Server Actions)
├── app/ # Routes and layouts (App Router)
├── components/ # React Components
│ ├── sections/ # Page sections (composition)
│ └── ui/ # Base UI components (atomic)
├── data/ # Static data and content (single source of truth)
├── lib/ # Utility functions and config
├── schemas/ # Validation schemas with Zod
└── types/ # Global type definitions
(Note: Tests (__tests__
) are co-located with the modules they test, facilitating discovery and maintenance.)
The Functional Requirement: A Context-Aware Navigation Flow
The goal was to design a user flow that wasn't generic. A user's selection on the main page had to pre-configure the state of a different details page (/cursos
), displaying the relevant information immediately. In turn, selecting a specific package on this page needed to transfer its context to a request modal, minimizing friction for the user.
Step-by-Step Technical Implementation
How to pass the initial selection context between pages?
The useRouter
hook from next/navigation
was used in the source component (ServicesSection.tsx
) to build a URL with a search parameter (?tab=...
) that encodes the user's choice.
// src/components/sections/home/ServicesSection.tsx
import { useRouter } from 'next/navigation';
// ...
const router = useRouter();
const handleLearnMore = (serviceType: string) => {
// Build and navigate to the URL with the selected service as the search parameter
router.push(`/cursos?tab=${serviceType}`);
}
How does the destination page react to this context?
The destination component (CoursesSection.tsx
) was implemented as a Client Component that uses the useSearchParams
hook to read the tab
parameter from the URL. This value is used to initialize the activeTab
state, ensuring the correct content is displayed on the first render, even if the page is reloaded or the link is shared.
// src/components/sections/cursos/CoursesSection.tsx
import { useSearchParams } from 'next/navigation';
import { useState, useEffect } from 'react';
// ...
// Reads URL parameters from the client side
const searchParams = useSearchParams();
const tabFromUrl = searchParams.get('tab');
// A helper function to validate the parameter and return a safe value
const getValidatedTab = (tab: string | null): TabKey => {
const validTabs = Object.keys(coursesData);
if (tab && validTabs.includes(tab)) {
return tab as TabKey;
}
return 'personalizado'; // Returns a default value if the parameter is invalid or null
};
// Initialize the component state directly from the URL
const [activeTab, setActiveTab] = useState<TabKey>(() => getValidatedTab(tabFromUrl));
// (Optional) Sync state if user navigates back/forward
useEffect(() => {
setActiveTab(getValidatedTab(searchParams.get('tab')));
}, [searchParams]);
How to handle the final request securely and efficiently?
Selecting a package triggers a ServiceRequestModal
. This component encapsulates the final conversion logic:
- It receives the
serviceName
as a prop to personalize its content. - It presents a minimal form (only email) to reduce friction.
- It uses
useActionState
to call thesubmitServiceRequest
Server Action. - The loading state (
pending
) is automatically managed via aSubmitButton
sub-component that usesuseFormStatus
.
// src/components/sections/cursos/ServiceRequestModal.tsx
'use client';
import React, { useEffect } from 'react';
import { useActionState, useFormStatus } from 'react';
import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { submitServiceRequest } from '@/actions/serviceRequest';
// Subcomponent to access the 'pending' state
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button type="submit" loading={pending} disabled={pending} fullWidth>
{pending ? 'Enviando...' : 'Confirmar Solucitud'}
</Button>
);
}
export const ServiceRequestModal = ({ isOpen, onClose, serviceName }) => {
// Link the form to the Server Action
const [state, formAction] = useActionState(submitServiceRequest, { message: '', success: false });
// Closes the modal automatically if the action was successful
useEffect(() => {
if (state?.success) {
const timer = setTimeout(() => onClose(), 2000);
return () => clearTimeout(timer);
}
}, [state, onClose]);
return (
<Modal isOpen={isOpen} onClose={onClose}>
{state?.success ? (
<p className="text-green-600">{state.message}</p>
) : (
<>
<p>
Estás solicitando el paquete: <strong>{serviceName}</strong>
</p>
<form action={formAction} className="space-y-4">
<Input
name="email"
type="email"
placeholder="tu@correo.com"
required
/>
{/* Pass the service name to the Server Action in a hidden way */}
<input type="hidden" name="serviceName" value={serviceName} />
<SubmitButton />
{state?.message && <p className="text-sm text-red-600">{state.message}</p>}
</form>
</>
)}
</Modal>
);
};
Alternative Approaches
The implemented solution using useSearchParams
is effective for this use case, as it treats the URL as the source of truth, which is ideal for state that needs to be shareable and persistent. However, it's not the only way to solve this problem. I'm opening the discussion to the community:
Context API vs. URL State
In what scenarios would you have preferred using React's
Context API
to manage this state? An advantage could be avoiding URL "pollution," but with the downside of losing state on a page refresh.
Global State Libraries (Redux, Zustand)
For a flow like this, would you consider a global state library a viable option? Or would it be overkill for state that isn't truly "global" to the entire application?
Other Techniques?
Are there other patterns or tools within the Next.js ecosystem you would have considered for passing state between pages this way? I'd love to read about other implementations.
Resources & Contact
The source code is available for review. Technical feedback, suggestions for improvement via Issues, or Pull Requests are welcome.
For technical discussions or professional inquiries, you can find me on Linkedin.
Top comments (0)