DEV Community

Cover image for Case Study: A Complete User Flow in Next.js 15, from useSearchParams to Server Actions
Edgardo Mota
Edgardo Mota

Posted on

Case Study: A Complete User Flow in Next.js 15, from useSearchParams to Server Actions

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.
  • 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).

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
Enter fullscreen mode Exit fullscreen mode

(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}`);
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

How to handle the final request securely and efficiently?

Selecting a package triggers a ServiceRequestModal. This component encapsulates the final conversion logic:

  1. It receives the serviceName as a prop to personalize its content.
  2. It presents a minimal form (only email) to reduce friction.
  3. It uses useActionState to call the submitServiceRequest Server Action.
  4. The loading state (pending) is automatically managed via a SubmitButton sub-component that uses useFormStatus.
// 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>
    );
};
Enter fullscreen mode Exit fullscreen mode

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)