theWhiteFoxDev
From 500 Errors to UX Wins: API Debugging in a Real-World Next.js 14 App

From 500 Errors to UX Wins: API Debugging in a Real-World Next.js 14 App

Authors

🎢 Beyond the Basics: Overcoming Real-World API Challenges in a Next.js Survey App

Building a new feature or application can often feel like following a clear path, but the reality of development is rarely that straightforward. Recently, I embarked on a coding challenge to create a lightweight survey tool using Next.js 14 (App Router), TypeScript, and Tailwind CSS. The goal was simple: fetch surveys from an API, let users take them, and submit responses. What seemed like a straightforward task quickly became a masterclass in real-world API integration and persistent debugging. 👉 View the source code on GitHub

📋 Aligning with the Project Brief

The goal was to implement a lightweight survey tool that allows users to browse, take, and view surveys with thoughtful UX and best practices.

Important note: This wasn't a pass/fail test that will be scored out of correctness but rather an opportunity to demonstrate my ability to transform API data into a responsive and user-friendly UI.

🚀 Survey Application Development

This project involved the end to end development of a dynamic survey application, emphasizing a robust user experience and a modular, API-driven Architecture. Key accomplishments include:

  • Dynamic Survey Management: Designed and implemented a responsive "Survey List Page" that dynamically fetches and displays available surveys from the API, including titles and basic information.

  • API-Driven Form Rendering: Developed a flexible system to dynamically render survey questions based on API data, supporting various input types such as single_choice and multiple_choice.

  • Comprehensive Submission & Validation: Engineering a robust submission process that collects user responses, transforms them to API's expected format, and sends them via POST requests. Integrating client-side validation using Zod schemas to ensure data integrity and provide immediate, clear error feedback.

  • Modular & Maintainable Architecture: Designed with reusable components and a clear separation of concerns (e.g., API service, validation schemas, UI components) for enhanced maintainability and scalability.

  • Responsive UI: Styled with Tailwind CSS to ensure a seamless across devices.

  • API Resilience : Includes a fallback to mock data when the live API is unreachable, ensuring continuous development and testing workflows.


🛠 Tech Stack

  • Next.js 14+ (App Router): Utilized for server-side rendering (SSR), intelligent routing, and efficient data fetching.
  • TypeScript: Ensures strong typing throughout the codebase, improving code quality and reducing bugs.
  • Tailwind CSS: A utility-first CSS framework for rapid and consistent styling.
  • Zod: A powerful, TypeScript-first schema declaration and validation library.
  • Workspace API: Provides survey data and accepts responses. Authentication and error handling are centralized in lib/data.ts.
  • Environment Variables: For secure API keys and base URLs.
  • Tooling: Bun (optional) for faster installs and dev server.

🧠 Design Notes

  • Next.js App Router for Performance & Security: Leveraging App Router to differentiate between Server Components (for fetching a list of surveys with links and details of each survey, preventing API tokens exposure) and Client Components (like the SurveyForm component, for interactive elements and form handling). This optimizes performance and security.
  • Modular API Layer (app/lib/data.ts): All interactions with the Workspace API are centralized here. This modularity ensures consistent error handling, authentication, and provides a clear separation of concerns, making the codebase easier to maintain and extend. Mock data fallbacks are included for development resilience.
  • Strongly Typed Forms with Zod: Client-side form submissions are rigorously validated using Zod schemas. This ensures that the data sent to the API conforms to expected types and formats, reducing invalid requests and providing immediate, clear feedback to the user, enhancing the overall UX.
  • User-Centric Feedback: the UI provides distinct messages and visual cues for various submission outcomes: success, specific validation errors, API call failures, and general "Not Submitted" messages for unhandled issues.

🗂 Project Structure

survey-tool-app/
├── public/
├── src/
│   ├── app/
│   │   ├── components/              # Reusable UI components for the app directory
│   │   │   ├── QuestionField.tsx    # Renders individual input fields based on question type
│   │   │   ├── ResponsesTable.tsx   # Displays a table of survey responses
│   │   │   ├── SurveyForm.tsx       # Client-side form component handling submission and validation
│   │   │   └── SurveysTable.tsx     # Displays a table of available surveys
│   │   ├── lib/
│   │   │   ├── mock-data/           # Folder for mock API responses
│   │   │   │   ├── surveys.ts       # Mock data for survey list
│   │   │   │   └── response-q1.ts   # Mock data for individual question responses (example)
│   │   │   ├── data.ts              # Centralized API calls, authentication headers
│   │   │   └── schemas.ts           # Zod schemas for API response structures AND client-side form validation
│   │   ├── globals.css              # Global styles for the application
│   │   ├── layout.tsx               # Root layout for the application
│   │   ├── page.tsx                 # Home page (redirects to /surveys)
│   │   └── surveys/
│   │       ├── [surveyId]/          # Dynamic page for individual surveys (Server Component)
│   │       │   └── page.tsx
│   │       ├── responses/           # Folder for displaying survey responses/results
│   │       │   ├── [questionId]/    # Dynamic page for individual question responses
│   │       │   │   └── page.tsx
│   │       │   └── page.tsx         # Survey list page (Server Component)
├── .env.local                       # Environment variables (API keys, base URLs)
├── next.config.js                   # Next.js configuration (no rewrites needed as CORS is handled by API)
├── package.json                     # Project dependencies and scripts
├── tsconfig.json                    # TypeScript configuration
└── README.md                        # Project documentation

🎨 UX Considerations:

  • Added loaders and error messages to guide the user.
  • Used radio buttons for single-choice and checkboxes for multiple-choice questions for clear, guaranteed option values.
  • Set up baseURL logic for consistent API calls in dev and during SSR.

✅ What's Working Well

  • Response UI: md:hiddenand md:table conditionally render mobile vs desktop views on the surveys page.
  • Dynamic Routing: Links to individual surveys and question results.
  • Data Display: Clear listing of title, description, and question content.
  • Accessibility: aria-labelattributes and semantic clarity for screen readers.

🔧 Navigating Real-World Integration: A Journey Beyond Estimates

This project initially scoped for an "8-hour" completion, evolved into a comprehensive exercise in confronting real-world API challenges. This experience reinforced my framework-agnostic approach to application development, where the optimal tool is chosen for the specific task. I selected Next.js for its robust server side rendering (SSR) capabilities, which are crucial for secure data fetching and safeguarding sensitive credentials for dynamic user interactions.

  1. Proactive CORS Management:

Challenge & Approach: Anticipating potential browser-enforced Cross-Origin Resource (CORS) restrictions, I proactively implemented rewrites in next.config.ts. This startegy proxies API requests, allowing the client-side application to seamlessly communcatire with the backend API without trigger CORS errors.

Outcome: While subsequent debugging with Swagger UI and curl POST requests, confirming that the API had no CORS restriction set up. Nevertheless, implementing rewrites demonstrates a robust, proactive approach to API integration for scenarios where CORS is actively enforced.

  1. API Payload Transformation: The core problem was a clash between how the survey form gathered data and how the API expected to receive it. The API needed survey responses in a very specific JSON format: a list of objects, each containing a question_id and its selected_option.

For example, a single_choice question would look like:

{
    "responses": [{ "question_id": "q1_1", "selected_option": "Excellent" }]
}

And a multiple_choice question:

{
    "responses": [
        {
            "question_id": "q4_2",
            "selected_option": "Dashboard, Notifications, Reports, Integrations"
        }
    ]
}

However, the HTML form, specially the QuestionField component was generating a flat FormData object. This was due to incorrect name attributes on the input elements. For instance:

  • Radio buttons used name={question.question} instead of name={question.id}.
  • Checkboxes used name={${question.id}[]} which FormData interprets differently for multiple selections.

This mismatch meant the data sent to the backend was structurally wrong, leading to 500 Internal Server Errors (which should have been a 400 series error for invalid input, but the data was so malformed it caused a server-side crash). Essentially, the backend couldn't understand the submitted survey answers.

The fix: Transforming the Data:

To solve this, a crucial data transformation step was added within the handleSubmit function. This step acted as a translator, converting the form's FormData into the API's required question_id and selected_option structure.

Here's how the solution worked:

  1. Gathering Raw Data: The handleSubmit function first took the FormData from the HTML form. It then processed these entries into a more usable rewData object, correctly handling cases where multiple values where multiple values were associated with a single key (like for multiple_choice questions).

  2. Mapping to API format: It then iterated through the survey.questions definition (which holds the expected question.ids). For each question, it looked up the corresponding answer in the rawData and transforming it into the question.id and selected_option format.

    • For multiple_choice checkboxes, setting name={question.id}, was key. This allowed the FormData to group all selected options for a single question into an array, which could be then joined into a comma-separated string (e.g, "Dashboard, Notifications, Reports, Integrations") as expected by the API.

    • Number inputs were also correctly converted to numbers.

The provided handleSubmit TS code snippet is the core of the transformation, showing the logic for processing the raw form data and mapping it to the API's expected structure.

...
const handleSubmit = async (e: FormEvent) => {
    e.preventDefault()

    const form = e.currentTarget as HTMLFormElement
    const formDataEntries = new FormData(form).entries()
    const rawData: { [key: string]: string | string[] } = {}
    const transformedResponses: PostResponseItem[] = []

    for (const [key, value] of formDataEntries) {
      if (rawData[key]) {
        if (Array.isArray(rawData[key])) {
          (rawData[key] as string[]).push(value as string)
        } else {
          rawData[key] = [rawData[key] as string, value as string]
        }
      } else {
        rawData[key] = value as string
      }
    }

    survey.questions.forEach(question => {
      const rawAnswer = rawData[question.id]

      if (rawAnswer !== undefined && rawAnswer !== '') {
        let selectedOptionForApi: string | number | (string | number)[]

        if (question.type === 'multiple_choice' && Array.isArray(rawAnswer)) {
          selectedOptionForApi = rawAnswer.join(',')
        } else if (question.type === 'number' && typeof rawAnswer === 'string' && !isNaN(Number(rawAnswer))) {
          selectedOptionForApi = Number(rawAnswer)
        } else {
          selectedOptionForApi = rawAnswer as string
        }

        transformedResponses.push({
          question_id: question.id,
          selected_option: selectedOptionForApi
        })
      }
    })

    if (transformedResponses.length === 0) {
      toast.error('Please select at least one question')
      return
    }

    const loadingToastId = toast.loading('Submitting your survey...')

    try {
      const result = await postSurveyResponse(survey.id, transformedResponses)

      if (result && (result.status === 'saved' || result.status === 'success_local_fallback')) {
        toast.success('Survey Submitted Successfully!', { id: loadingToastId })
        form.reset()
      } else {
        toast.error('Failed to submit survey. Please try again.', { id: loadingToastId })
      }
    } catch (error: unknown) {
      console.error("Error during survey submission:", error)
    }
  }
...

View SurveyForm.tsx component code on GitHub.

  • Client-Side Validation with Zod: To enforce data integrity and provide immediate user feedback, Zod was implemented for schema-driven validation, simplifying form state management and ensuring valid state management.

This validation acts as an early warning system, catching errors before they even reach the transformation step or the backend API, contributing to the more robust and user-friendly application.

  1. Server-Side Fetching and Absolute URLs:

Relative paths like /api/surveys don't automatically resolve to http://localhost:3000/api/surveys, leading to TypeError: Failed to parse URL. To ensure consistency whether the fetch happened during SSR or on the client, I configured process.env.NEXT_PUBLIC_BASE_URL dynamically provides the full absolute URL (e.g, http://localhost:3000) for API calls originating from the server side.

  1. The Elusive 500 Internal Server Error on Submission: Despite correctly implementing CORS, payload structure, and absolute URLs, POST requests consistently resulted in a 500 Internal Server Error from the API, even though the exact request payload and headers worked via Swagger UI or curl. This suggests an unhandled exception or environmental quirk on the API's backend when processing a proxied request. In a team setting this would be escalated to the backend team with all debugging information. (Initial timeouts with Swagger also prompted robust mock data fallbacks.)

Task Breakdown:

  1. Survey List Page: Fetch and display a list of surveys from the API.
  2. View & Take a Survey: Click into a survey to view questions, providing a form for answering and submitting, dynamically rendering questions based on type (single_choice, multiple_choice).
  3. Submit Survey Response: Send the user responses to the API, handling successful requests gracefully.
  4. View Response Summary: After submission, show a simple summary of the submitted answers.

⚡ Setup and Running Locally

  1. Clone the repository:

    git clone https://https://github.com/theWhiteFox/survey-tool-app.git
    cd survey-tool-app
    
  2. Install dependencies:

      bun install
      # or
      npm install
      # or
      yarn install
    
  3. Configure environment variables: Create a .env.local file in the root of the project

    NEXT_PUBLIC_BASE_URL=https://interview.staging.company.com
    API_TOKEN=token
    
    
  4. Run the development server:

      bun dev
      # or
      npm run dev
      # or
      yarn dev
    

Running locally this is what you will see 🤞

Open http://localhost:3000 in your browser. Click the link to the surveys to see a list of available surveys.

localhost-running-app.jpg

Design Decisions & Best Practices

  • Next.js App Router: Utilized for server-side rendering (SSR) of survey lists and detail pages, enhancing initial load performance and SEO (though not critical for this specific app, it demonstrates modern Next.js capabilities).
  • Server Components & Client Components: API data fetching (app/lib/data.ts) primarily occurs in server-side functions (e.g., in page.tsx files) for better performance and security (API token is not exposed client-side). Interactive elements like the SurveyForm are Client Components.
  • Modular API Service (app/lib/data.ts): Centralized API calls for reusability, maintainability, and consistent error handling/authentication.
  • Sensible State Management:
    • useState for local component state (form data, submission status).
    • loading and error states are managed to provide clear user feedback.
  • Responsive UI: Designed with Tailwind CSS to ensure a good experience across various screen sizes.
  • User Experience (UX) Considerations:
    • Clear titles and descriptions.
    • Dynamic form elements (e.g., dropdown for single_choice questions to ensure valid input).
    • Immediate feedback after submission (success/error messages).

Future Improvements

  • Multiple Choice Handling: Implement dedicated UI (e.g., checkboxes) and data collection logic for multiple_choice question types.
  • Response Summary Details: Enhance the "View Response Summary" page to provide more meaningful aggregated insights (e.g., breakdown of chosen options for each question).
  • Form Validation: Implement more robust client-side validation (e.g., react-hook-form or similar) beyond basic required attributes.
  • Accessibility: Improve ARIA attributes and keyboard navigation.
  • UI Polish: Further refine styling, transitions, and animations for a more polished user experience.
  • Loading Skeletons: Implement skeleton loaders for better perceived performance during data fetching.
  • Testing (Jest): Implementing unit and integrating testing using Jest.
  • Managing state (Zustand): Explore Zustand for more complex global state management, if required, offering a simpler alternative to Redux.
  • Storybook: Integrate Storybook for isolated UI component development and documentation.

⚠️ Lessons Learned & Reflection

Building this survey app was a miniature version of real-world frontend engineering. The requirement to interact with a strict API forced me to:

  • Think deeply about data modeling and transformations.
  • Troubleshoot ambiguous server errors methodically.
  • Build resilient UX patterns that inform and guide users even when things go wrong.
  • Leverage tools like Zod for validation and Next.js rewrites for elegant proxying.

Key takeaways

  • Effective Next.js App Router Utilization: Strategically leveraged Server Components for efficient data fetching and improved security, and Client Components for interactivity and state management.
  • Dynamic Component Rendering: Gained hands-on experience dynamically rendering UI based on API data, allowing for flexible and adaptable forms.
  • Comprehensive Error Handling Strategies: Developed robust error handling for network issues, API failures, and validation errors, communicating clearly to the user.
  • Complex API Data Transformation: Practical experience collecting raw form data from diverse inputs and accurately transforming it into the specific payload structure required by the backend API, particularly for complex question types like multiple-choice selections.
  • API CORS Handling in Practice: Understood Next.js rewrites are truly necessary for CORS versus when the API itself is properly configured.
  • Building a Responsive UI with Tailwind CSS: Continuous learning in designing for different screen sizes and ensuring a consistent, accessible user experience.

This project was a valuable journey into building a robust and user-friendly frontend application integrated with a real API.

I wrote this blog post to reflect on what I learned during this project — including dynamic component rendering, Zod validation patterns, and managing typed form state and hosted on Vercel. Check out the Live Demo