Building a Modern Insurance Management Dashboard
Building a Modern Insurance Management Dashboard
Insurance agencies face a constant challenge: managing complex workflows across customer registration, policy tracking, claims processing, and automation monitoring. Traditional systems are often fragmented, requiring multiple platforms and manual processes that slow down operations and increase the risk of errors.
This case study explores how we built a comprehensive insurance management dashboard using Next.js 15, TypeScript, and a carefully curated stack. The result is a unified platform that streamlines operations while providing role-based access for administrators, agencies, and planners.
The Challenge: Fragmented Insurance Workflows
Agencies typically juggle multiple systems---CRM, policy administration, claims processing, and automation tracking. Each often operates in isolation, creating data silos and requiring manual data entry.
Our goal: build a single, cohesive dashboard that handles:
- Customer registration with signature capture and address validation
- Real-time automation tracking and status monitoring
- Claims processing with dynamic PDF generation
- Role-based access control for different user types
- Notice & inquiry management with workflow automation
Architecture: Modern Stack for Complex Requirements
Technology Foundation
We chose Next.js 15 (App Router) with TypeScript for developer experience and runtime reliability.
Core Stack:
- Frontend: Next.js 15 with App Router
- Language: TypeScript
- UI Framework: shadcn/ui (Radix UI)
- Styling: Tailwind CSS
- State Management: Zustand (app state) + TanStack Query (server state)
- Forms: React Hook Form + Yup
- HTTP Client: Axios with automatic token refresh
Component Architecture Strategy
1 src/components/2 ├── ui/ # Base shadcn/ui components3 ├── molecules/ # Composite components (DataTable, Pagination)4 └── organisms/ # Feature components (CustomerForm, Dashboard)
Authentication: Security-First Approach
- Insuary’s dashboard needed to expose dozens of insurance operations while keeping partner APIs and credentials off the client. We adopted Next.js App Router server actions so every network hop stays on the server, then hydrated the UI through React Query hooks that call those actions, giving us a fast, data-rich SPA without shipping secrets.
- Authentication starts in the loginAction, which performs the credential exchange with the upstream API and stores the resulting tokens and user profile in httpOnly cookies.
1export async function loginAction(credentials: LoginRequest) {2 let shouldRedirectToTempPassword = false;34 try {5 const response: AxiosResponse<Response> = await axios.post(6 `${process.env.NEXT_PUBLIC_URL}/api/v1/login`,7 credentials,8 {9 headers: {10 "Content-Type": "application/json",11 },12 }13 );1415 const { data } = response.data;1617 // Set auth cookies first18 await setAuthCookies(data.accessToken, data.refreshToken, data.maxAge);1920 // Set user cookie21 await setUserCookie({22 id: data.id,23 name: data.name,24 role: data.role,25 agencyId: data.agencyId,26 agency: data.agency,27 userId: data.userId,28 isTempPassword: data.isTempPassword,29 });3031 // Check if temp password redirect is needed32 shouldRedirectToTempPassword = data.isTempPassword || false;3334 return { success: true };35 } catch (error) {36 console.error("Login error:", error);37 return {38 success: false,39 error: "아이디 또는 비밀번호를 확인해 주세요.",40 };41 } finally {42 // Handle redirect outside try-catch43 if (shouldRedirectToTempPassword) {44 redirect("/temp-password");45 } else {46 revalidatePath("/");47 }48 }49}
Cookie helpers enforce secure flags and run only on the server, ensuring identity never reaches the browser.
1import "server-only";23import { cookies } from "next/headers";4import { redirect } from "next/navigation";5import type { User } from "@/types/dashboard";67const COOKIE_OPTIONS = {8 httpOnly: true,9 secure: process.env.NODE_ENV === "production",10 sameSite: "lax" as const,11 path: "/",12};
- Follow-up actions reuse the same pattern: they fetch the bearer token on the server, call the relevant endpoint, and return structured domain data—e.g. dashboard summaries. Because actions sit in the server runtime, the access token is injected into Axios headers without exposing it to client-side code.
1/**2 * Server action to fetch dashboard summary data3 * @returns Dashboard summary data or error object4 */5export async function getDashboardSummaryAction() {6 try {7 const { accessToken } = await getAuthTokens();89 if (!accessToken) {10 return {11 success: false,12 error: "인증이 필요합니다. 다시 로그인해 주세요.",13 };14 }1516 const response: AxiosResponse<{17 data: DashboardSummaryData;18 message: string;19 }> = await axios.get(20 `${process.env.NEXT_PUBLIC_URL}/api/v1/dashboard/summary`,21 {22 headers: {23 Authorization: `Bearer ${accessToken}`,24 "Content-Type": "application/json",25 },26 }27 );2829 return {30 success: true,31 data: response.data.data,32 };33 } catch (error) {34 console.error("Dashboard summary error:", error);35 return {36 success: false,37 error: "대시보드 데이터를 불러올 수 없습니다.",38 };39 }40}
- Client components stay “clean”: React Query hooks simply invoke server actions and manage UI state.
1/**2 * Hook to fetch GA accounts list with filtering and pagination3 * @param filters - GA account filter parameters4 * @returns Query result with GA accounts list5 */6export const useGAAccounts = (filters: GAAccountFilter = {}) => {7 return useQuery<GAAccountListResponse>({8 queryKey: ["agencies", "list", filters],9 queryFn: async () => {10 const result = await getGAAccountsAction(filters);1112 if (!result.success) {13 throw new Error(result.error || "Failed to fetch GA accounts list");14 }1516 return result.data!;17 },18 staleTime: 5 * 60 * 1000, // 5 minutes19 });20};
The AuthProvider fetches the session through an internal API route that reads the secure cookies so the store initializes without ever touching tokens.
1export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {2 const { setUser, setLoading, logout } = useAuthStore();34 useEffect(() => {5 const initializeAuth = async () => {6 setLoading(true);78 try {9 const response = await fetch('/api/user');10 const result = await response.json();1112 if (result.success && result.data) {13 setUser(result.data);14 } else {15 logout();16 }17 } catch (error) {18 console.error('Failed to initialize auth:', error);19 logout();20 } finally {21 setLoading(false);22 }23 };2425 initializeAuth();26 }, [setUser, setLoading, logout]);2728 return <>{children}</>;29};
- Defense-in-depth comes from layered refresh and guarding. Axios interceptor logic refreshes tokens when a 401 occurs
12// Request interceptor to add access token3httpClient.interceptors.request.use(4 async (config) => {5 const { accessToken } = await getAuthTokens();67 if (accessToken) {8 config.headers.Authorization = `Bearer ${accessToken}`;9 }1011 return config;12 },13 (error) => {14 return Promise.reject(error);15 }16);
, middleware protects routes and enforces temporary-password flows before the request reaches React
1export async function middleware(request: NextRequest) {2 const accessToken = request.cookies.get("accessToken")?.value;3 const refreshToken = request.cookies.get("refreshToken")?.value;4 const userCookie = request.cookies.get("user")?.value;5 const { pathname } = request.nextUrl;67 // Parse user data from cookie8 let user: User | null = null;9 if (userCookie) {10 try {11 user = JSON.parse(userCookie);12 } catch (error) {13 console.error("Error parsing user cookie:", error);14 }15 }1617 // Public routes that don't need authentication18 const publicRoutes = ["/login", "/create-inquiry"];19 const isPublicRoute = publicRoutes.includes(pathname);20 const isTempPasswordPage = pathname === "/temp-password";2122 if (pathname === "/") {23 return NextResponse.redirect(new URL("/dashboard", request.url));24 }25 // If no access token but we have refresh token, try to refresh26 if (!accessToken && refreshToken && !isPublicRoute) {27 const refreshResult = await attemptTokenRefresh(refreshToken);2829 if (refreshResult.success && refreshResult.response) {30 // Token refresh successful, continue with the new tokens31 return refreshResult.response;32 } else {33 // Token refresh failed, clear cookies and redirect to login34 const response = NextResponse.redirect(new URL("/login", request.url));35 response.cookies.delete("accessToken");36 response.cookies.delete("refreshToken");37 response.cookies.delete("user");38 return response;39 }40 }4142 // If no tokens at all and trying to access protected route43 if (!accessToken && !refreshToken && !isPublicRoute) {44 return NextResponse.redirect(new URL("/login", request.url));45 }4647 // Temp password logic - highest priority48 if (user?.isTempPassword) {49 // If user has temp password but not on temp-password page, redirect to temp-password50 if (!isTempPasswordPage) {51 return NextResponse.redirect(new URL("/temp-password", request.url));52 }53 } else {54 // If user doesn't have temp password but is on temp-password page, redirect to home55 if (isTempPasswordPage && accessToken) {56 return NextResponse.redirect(new URL("/", request.url));57 }58 }5960 // If has access token and trying to access auth pages (except temp-password handled above)61 if (accessToken && isPublicRoute) {62 return NextResponse.redirect(new URL("/", request.url));63 }6465 return NextResponse.next();66}
, and server helpers can redirect unauthenticated requests with requireAuth.
1export async function requireAuth(): Promise<User> {2 const user = await validateSession();34 if (!user) {5 redirect("/login");6 }78 return user;9}
Outcome: all API calls originate from controlled server surfaces, authentication state lives in hardened cookies, and the client keeps the snappy React UX. We can add new features by writing another server action + hook pair without revisiting security scaffolding, and operations are confident that tokens never leak to the browser.
State Management: Three-Tier Strategy
1) Server State --- TanStack Query
1export const useCustomers = (params: CustomerSearchParams) => {2 return useQuery({3 queryKey: ['customers', params],4 queryFn: () => customerApi.getCustomers(params),5 staleTime: 5 * 60 * 1000,6 retry: (failureCount, error) => !isAuthError(error) && failureCount < 3,7 });8};
2) Global App State --- Zustand
1interface AppStore {2 isNavigationGuardEnabled: boolean;3 setNavigationGuard: (enabled: boolean) => void;4 modals: {5 customerDetail: { open: boolean; customerId?: string };6 };7 openCustomerDetail: (customerId: string) => void;8 closeCustomerDetail: () => void;9}
3) Form State --- React Hook Form + Yup
1const customerSchema = yup.object({2 name: yup.string().required('이름을 입력해주세요'),3 phone: yup.string().required('전화번호를 입력해주세요'),4 email: yup.string().email('올바른 이메일을 입력해주세요'),5 birthDate: yup.date().required('생년월일을 입력해주세요'),6});7
-----------
Challenges & Solutions
1) Complex Form Validation
1const insuranceSchema = yup.object().shape({2 insuranceType: yup.string().required(),3 coverage: yup.number().when('insuranceType', {4 is: 'life',5 then: yup.number().min(10_000_000, '최소 1천만원'),6 otherwise: yup.number().min(1_000_000, '최소 100만원'),7 }),8});
2) Role-Based UI Rendering
1export const useAuthStore = create<AuthStore>()(2 devtools(3 persist(4 (set, get) => ({5 // State6 user: null,7 isAuthenticated: false,8 isLoading: false,910 // Actions11 setUser: (user: User) =>12 set(13 {14 user,15 isAuthenticated: true,16 isLoading: false,17 },18 false,19 "setUser"20 ),2122 logout: () =>23 set(24 {25 user: null,26 isAuthenticated: false,27 isLoading: false,28 },29 false,30 "logout"31 ),3233 setLoading: (loading: boolean) =>34 set({ isLoading: loading }, false, "setLoading"),3536 // Role checking utilities37 hasRole: (role: UserRole) => {38 const { user } = get();39 return user?.role === role;40 },4142 hasAnyRole: (roles: UserRole[]) => {43 const { user } = get();44 return user ? roles.includes(user.role) : false;45 },4647 isAdmin: () => {48 const { user } = get();49 return user?.role === "ADMIN";50 },5152 isAgency: () => {53 const { user } = get();54 return user?.role === "AGENCY";55 },5657 isPlanner: () => {58 const { user } = get();59 return user?.role === "PLANNER";60 },61 }),62 {63 name: "auth-storage",64 partialize: (state) => ({65 user: state.user,66 isAuthenticated: state.isAuthenticated,67 }),68 }69 ),70 {71 name: "auth-store",72 }73 )74);7576// Selectors for better performance77export const useUser = () => useAuthStore((state) => state.user);78export const useIsAuthenticated = () =>79 useAuthStore((state) => state.isAuthenticated);80export const useUserRole = () => useAuthStore((state) => state.user?.role);81export const useIsAdmin = () => useAuthStore((state) => state.isAdmin());82export const useIsAgency = () => useAuthStore((state) => state.isAgency());83export const useIsPlanner = () => useAuthStore((state) => state.isPlanner());84
Performance Optimizations
1. Code Splitting: Lazy-load major features with dynamic imports
2. Query Tuning: Strategic `staleTime` and selective field fetching
3. Bundle Diet: Tree-shaking + dynamic imports → ~40% smaller bundle
4. Caching: Browser + HTTP caching for static assets and API responses
Development Best Practices
- Type Safety: 100% TypeScript coverage
- Code Quality: ESLint + Prettier with domain-specific rules
- Consistency: Standardized API hooks, composition patterns, and error handling
- Docs: JSDoc and ADRs for architectural decisions
Lessons Learned
1. Architecture Matters: three-tier state scaled cleanly
2. Type Safety Pays Off: `strict` mode prevented runtime errors
3. UX is Critical: smooth auth and loading states improved adoption
4. Performance Early: virtualization + caching avoided debt
Looking Forward
- Advanced analytics
- External insurance API integrations
- Microservices for scalability
Conclusion
Modern web tech can transform traditional insurance operations. By aligning architecture, developer ergonomics, and user experience, we built a platform that meets current needs and lays a foundation for future growth. The key is **balancing technical sophistication with practical usability**---every architectural decision should make users more productive in their daily work.
Live Demo (desktop only)
Username: a d m i n _ l o m e n (no space)
Password: a d m i n 1 2 3 4 (no space)