DEV Community

Amool-kk
Amool-kk

Posted on

Building a Centralized, Type-Safe Modal System in Next.js

Modals are one of the most common UI patterns in web applications.

They’re used for:

  • Confirmations
  • Forms
  • Previews
  • Warnings
  • Details panels

In small applications, managing modals is simple.

But as a Next.js app grows, modal handling often becomes messy and hard to maintain.

In this article, we’ll build a centralized, type-safe modal system in Next.js using:

  • React Context
  • TypeScript generics
  • A modal registry pattern
  • shadcn/ui Dialog (UI layer only)

This approach scales well for large production apps and keeps modal logic clean, predictable, and reusable.


The Problem

In small applications, handling modals locally works fine.
But as a Next.js app grows, modal management quickly becomes messy.

Imagine a page that needs multiple modals:

  • A confirmation modal
  • A details modal
  • A form modal

A typical implementation looks like this:

const [isConfirmOpen, setConfirmOpen] = useState(false);
const [isDetailsOpen, setDetailsOpen] = useState(false);
const [isFormOpen, setFormOpen] = useState(false);

return (
  <>
    <ConfirmModal
      open={isConfirmOpen}
      onClose={() => setConfirmOpen(false)}
      onConfirm={handleConfirm}
    />

    <DetailsModal
      open={isDetailsOpen}
      onClose={() => setDetailsOpen(false)}
      data={selectedItem}
    />

    <FormModal
      open={isFormOpen}
      onClose={() => setFormOpen(false)}
      onSubmit={handleSubmit}
    />
  </>
);
Enter fullscreen mode Exit fullscreen mode

Now imagine this same set of modals is needed on five different pages.

Each page ends up:

  • Duplicating modal state
  • Passing slightly different props
  • Re-implementing the same open/close logic

Over time, this leads to:

  • ❌ Repeated and fragile state logic
  • ❌ Inconsistent modal behavior across pages
  • ❌ Harder refactors when modal props change
  • ❌ Poor developer experience as the app scales

This approach works initially, but it does not scale well in medium to large applications.

To solve this, we can move modal state and rendering into a single, centralized system.


The Solution: A Centralized Modal System

Instead of managing modal state locally, we can design a global modal architecture.

Core ideas

  1. One global Modal Context
  2. A modal registry that maps modal names to components
  3. Type-safe modal props enforced by TypeScript
  4. Open any modal from anywhere in the app
  5. UI libraries stay at the edges

Step 1: Define Generic Shared Types

Create app/(modals)/types.ts.

This file defines the contract for your entire modal system.

import type { ReactNode } from "react";

// Base props passed to all modals
export interface BaseModalProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  close: () => void;
}

// Example shared data type
export interface Entity {
  id: string;
  title: string;
  description?: string;
}

// Modal-specific props
interface EntityModalProps {
  entity: Entity;
}

// Map modal names to their required props
export interface ModalPropsMap {
  confirm: EntityModalProps & {
    onConfirm?: () => void;
    onCancel?: () => void;
  };

  details: EntityModalProps;

  form: {
    initialValues?: Record<string, unknown>;
    onSubmit: (values: Record<string, unknown>) => void;
  };
}

export type ModalType = keyof ModalPropsMap;

export type ModalState<K extends ModalType = ModalType> = {
  type: K;
  props: ModalPropsMap[K];
  open: boolean;
};

export type ModalComponent<K extends ModalType> =
  React.ComponentType<ModalPropsMap[K] & BaseModalProps>;
Enter fullscreen mode Exit fullscreen mode

Why this design works

  • Every modal has strictly typed props
  • You cannot open a modal without required data
  • TypeScript autocomplete works everywhere
  • Refactors are safe and predictable

Step 2: Create Generic Modal Components

Example 1: Details Modal

"use client";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import type { ModalPropsMap, BaseModalProps } from "./types";

type Props = ModalPropsMap["details"] & BaseModalProps;

export default function DetailsModal({
  open,
  onOpenChange,
  close,
  entity,
}: Props) {
  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>{entity.title}</DialogTitle>
        </DialogHeader>

        <p>{entity.description}</p>

        <button onClick={close}>Close</button>
      </DialogContent>
    </Dialog>
  );
}
Enter fullscreen mode Exit fullscreen mode

Example 2: Confirmation Modal

type Props = ModalPropsMap["confirm"] & BaseModalProps;

export function ConfirmModal({
  open,
  onOpenChange,
  close,
  entity,
  onConfirm,
  onCancel,
}: Props) {
  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>
        <p>Are you sure you want to proceed with "{entity.title}"?</p>

        <div className="flex gap-2 mt-4">
          <button onClick={() => { onConfirm?.(); close(); }}>
            Confirm
          </button>
          <button onClick={() => { onCancel?.(); close(); }}>
            Cancel
          </button>
        </div>
      </DialogContent>
    </Dialog>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Modal Registry

All modals are registered in one place.

import ConfirmModal from "./ConfirmModal";
import DetailsModal from "./DetailsModal";
import FormModal from "./FormModal";
import type { ModalComponent, ModalType } from "./types";

export const MODALS: Record<ModalType, ModalComponent<any>> = {
  confirm: ConfirmModal,
  details: DetailsModal,
  form: FormModal,
};
Enter fullscreen mode Exit fullscreen mode

Why a registry?

  • Centralized control
  • No accidental imports across pages
  • Easy to add or remove modals
  • Clear overview of available modals

Step 4: Modal Context

"use client";

import { createContext, useContext, useState } from "react";
import { MODALS } from "../(modals)/registry";
import type { ModalPropsMap, ModalType, ModalState } from "../(modals)/types";

interface ModalContextValue {
  openModal: <K extends ModalType>(
    type: K,
    props: ModalPropsMap[K]
  ) => void;
  closeModal: () => void;
}

const ModalContext = createContext<ModalContextValue | null>(null);

export function ModalProvider({ children }: { children: React.ReactNode }) {
  const [modal, setModal] = useState<ModalState | null>(null);

  const openModal = <K extends ModalType>(
    type: K,
    props: ModalPropsMap[K]
  ) => {
    setModal({ type, props, open: true });
  };

  const closeModal = () => {
    setModal(prev => (prev ? { ...prev, open: false } : null));
  };

  const ModalComponent = modal ? MODALS[modal.type] : null;

  return (
    <ModalContext.Provider value={{ openModal, closeModal }}>
      {children}

      {ModalComponent && (
        <ModalComponent
          {...modal.props}
          open={modal.open}
          onOpenChange={(open) => {
            if (!open) closeModal();
          }}
          close={closeModal}
        />
      )}
    </ModalContext.Provider>
  );
}

export function useModal() {
  const ctx = useContext(ModalContext);
  if (!ctx) throw new Error("useModal must be used inside ModalProvider");
  return ctx;
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Where to Use ModalProvider

After creating the ModalProvider, it needs to wrap your application so that any page or component can open modals.

In Next.js (App Router), the best place to do this is your root layout.

Example: Wrapping the App

// app/layout.tsx
import { ModalProvider } from "@/context/ModalContext";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <ModalProvider>
          {children}
        </ModalProvider>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now the modal system is available globally across the app.


Step 6: Using the Modal Anywhere

"use client";
import { useModal } from "../(context)/ModalContext";

export default function ExamplePage() {
  const { openModal } = useModal();

  const entity = {
    id: "1",
    title: "Example Item",
    description: "This is a generic entity used for demonstration.",
  };

  return (
    <div className="flex gap-2">
      <button
        onClick={() =>
          openModal("details", { entity })
        }
      >
        Open Details
      </button>

      <button
        onClick={() =>
          openModal("confirm", {
            entity,
            onConfirm: () => console.log("Confirmed"),
          })
        }
      >
        Open Confirm
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

TypeScript guarantees:

  • ❌ You can’t open a modal with wrong props
  • ❌ You can’t open an unregistered modal
  • ✅ Autocomplete works everywhere

Centralised modal architecture flowchart

Bonus: Generic Promise-Based Modals

This architecture can be easily extended to support promise-based modals:

const confirmed = await openConfirmModal({ entity });
Enter fullscreen mode Exit fullscreen mode

This is especially useful for:

  • Delete confirmations
  • Destructive actions
  • Async or step-based workflows

Promise-based modals fit naturally into the same centralized system and help keep async logic clean and readable.


Benefits of This Approach

  • ✅ No duplicated modal state
  • ✅ Fully type-safe modal usage
  • ✅ Works across the entire application
  • ✅ Scales cleanly as the app grows
  • ✅ UI implementation stays flexible

Possible Enhancements

  • Promise-based modals – Let modals return values (true/false, form data) so they can be used with async/await instead of callbacks.
  • Modal stacking – Support opening multiple modals on top of each other for complex or nested workflows.
  • URL-driven modals – Sync modal state with the URL to enable deep links, browser navigation, and refresh persistence.
  • Animations & transitions – Add custom enter/exit animations using libraries like Framer Motion while keeping logic centralized.
  • Auto-unmount & cleanup – Delay unmounting to finish animations and ensure forms, timers, or requests are properly cleaned up.
  • Access control & guards – Prevent certain modals from opening based on permissions, feature flags, or application state.

Conclusion

A centralized modal system built with React Context + TypeScript generics provides:

  • Predictable modal behavior
  • Strong compile-time guarantees
  • Cleaner page components
  • Better developer experience

This pattern is ideal for medium to large Next.js applications and is worth setting up early as your app grows in complexity.

#NextJS #TypeScript #React #Frontend #Architecture #DeveloperExperience #WebDevelopment

Top comments (1)

Collapse
 
gajananbodhankar profile image
Gajanan Bodhankar

Thoughtful!!