/dashboard/users/123 Home Dashboard About Contact <Outlet /> → User Profile Component REACT ROUTER v6 • NAVIGATION • SPA ROUTING
Frontend

React Router: Complete Navigation Guide

Mayur Dabhi
Mayur Dabhi
April 1, 2026
22 min read

You build a React app with a sidebar, a couple of pages and a login screen. Then someone bookmarks /dashboard/users/42, hits refresh, and gets a blank page or a server 404. Or the back button skips the page they expected, or a logged-out user can see the dashboard for half a second before the redirect kicks in. Almost all of these come down to how routing is set up.

This post walks through React Router v6 the way I'd use it in a real project: basic setup, links, nested layouts, URL params, protected routes, lazy loading, and the loader/action data APIs, plus the gotchas that tend to show up once the app is deployed.

What You'll Learn
  • React Router v6 fundamentals and key differences from v5
  • Setting up routes with BrowserRouter and createBrowserRouter
  • Nested routes and the Outlet component
  • Dynamic routes with URL parameters
  • Programmatic navigation with useNavigate
  • Protected routes and authentication patterns
  • Code splitting with lazy loading
  • Data loading with loaders and actions
  • Navigation state and location handling
  • Patterns that hold up as the app grows

Understanding Client-Side Routing

In a traditional multi-page app, every click on a link asks the server for a whole new HTML page. In a single-page app, the browser loads one HTML file and JavaScript takes over: the router changes the URL with the History API, matches it against your route config, and swaps components in place. No reload, and your React state survives the navigation.

The catch is that the server still sees those URLs. If a user refreshes on /dashboard/users, the request goes to your web server, and unless it's configured to fall back to index.html for unknown paths, you get a 404. On Nginx that's try_files $uri $uri/ /index.html;; on Apache it's a rewrite rule; on most static hosts it's a "SPA fallback" or redirect setting. I'd check this before anything else when a routed app works locally but breaks after deploy.

Traditional vs Client-Side Routing Traditional (MPA) Browser Request Server renders entire new page ❌ Full page reload Client-Side (SPA) URL Change Detected React Router matches route & renders component ✓ Instant, no reload

With client-side routing, the router swaps components instead of fetching a new page

Installation and Setup

v6 changed a lot compared to v5 (Switch became Routes, component became element, useHistory became useNavigate), so if you're copying snippets from older answers online, check which version they target. Everything here is v6.

Terminal
# Install React Router
npm install react-router-dom

# Or with yarn
yarn add react-router-dom

# Or with pnpm
pnpm add react-router-dom

Basic Router Setup

You can set up v6 in two ways: the component-based approach with BrowserRouter, or the newer data API approach with createBrowserRouter. Both work for basic routing:

src/App.jsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Home from './pages/Home';
import About from './pages/About';
import Contact from './pages/Contact';
import NotFound from './pages/NotFound';

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="/contact" element={<Contact />} />
        <Route path="*" element={<NotFound />} />
      </Routes>
    </BrowserRouter>
  );
}

export default App;
src/App.jsx
import { 
  createBrowserRouter, 
  RouterProvider 
} from 'react-router-dom';
import Home from './pages/Home';
import About from './pages/About';
import Contact from './pages/Contact';
import NotFound from './pages/NotFound';

const router = createBrowserRouter([
  {
    path: "/",
    element: <Home />,
  },
  {
    path: "/about",
    element: <About />,
  },
  {
    path: "/contact",
    element: <Contact />,
  },
  {
    path: "*",
    element: <NotFound />,
  },
]);

function App() {
  return <RouterProvider router={router} />;
}

export default App;
Which one to pick

For a new project I'd start with createBrowserRouter. Loaders, actions and route-level errorElement only work with the data routers, and moving an existing BrowserRouter app over later means touching every route.

Navigation Components

There are four ways to move the user around, and each fits a different spot in your code:

Link

Basic navigation component that renders an anchor tag. Use for standard navigation links.

NavLink

Like Link, but knows whether it's active. Use it in menus where the current page should be highlighted.

Navigate

Declarative component for redirects. Use in render logic to redirect users based on conditions.

useNavigate

Hook for programmatic navigation. Use in event handlers or after async operations.

Link and NavLink Examples

components/Navigation.jsx
import { Link, NavLink } from 'react-router-dom';

function Navigation() {
  return (
    <nav className="main-nav">
      {/* Basic Link - no active styling */}
      <Link to="/">Home</Link>
      
      {/* NavLink with active class */}
      <NavLink 
        to="/dashboard"
        className={({ isActive }) => 
          isActive ? 'nav-link active' : 'nav-link'
        }
      >
        Dashboard
      </NavLink>
      
      {/* NavLink with inline active styles */}
      <NavLink 
        to="/profile"
        style={({ isActive }) => ({
          fontWeight: isActive ? 'bold' : 'normal',
          color: isActive ? '#61dafb' : 'inherit'
        })}
      >
        Profile
      </NavLink>
      
      {/* Link with state */}
      <Link 
        to="/products" 
        state={{ from: 'navigation' }}
      >
        Products
      </Link>
      
      {/* Link with replace (no history entry) */}
      <Link to="/login" replace>
        Login
      </Link>
    </nav>
  );
}

Nested Routes and Layouts

Most apps have a shell (header, sidebar) that stays put while the main area changes. Nested routes model that directly: the parent route renders the layout, and an <Outlet /> marks where the matched child goes. Dashboards and admin panels are the obvious fit.

Nested Routes with Outlet Root Layout Component <Header /> - Shared across all routes <Sidebar /> NavLinks • Dashboard • Users • Settings <Outlet /> Child route renders here /dashboard DashboardPage /users UsersPage /settings SettingsPage

The Outlet component renders child routes within parent layouts

src/layouts/DashboardLayout.jsx
import { Outlet, NavLink } from 'react-router-dom';

function DashboardLayout() {
  return (
    <div className="dashboard-layout">
      <header className="dashboard-header">
        <h1>Admin Dashboard</h1>
        <UserMenu />
      </header>
      
      <div className="dashboard-body">
        <aside className="sidebar">
          <nav>
            <NavLink to="/dashboard" end>
              <i className="icon-home" /> Overview
            </NavLink>
            <NavLink to="/dashboard/users">
              <i className="icon-users" /> Users
            </NavLink>
            <NavLink to="/dashboard/products">
              <i className="icon-box" /> Products
            </NavLink>
            <NavLink to="/dashboard/settings">
              <i className="icon-cog" /> Settings
            </NavLink>
          </nav>
        </aside>
        
        <main className="dashboard-content">
          {/* Child routes render here */}
          <Outlet />
        </main>
      </div>
    </div>
  );
}
src/App.jsx - Nested Route Configuration
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import DashboardLayout from './layouts/DashboardLayout';
import Overview from './pages/dashboard/Overview';
import Users from './pages/dashboard/Users';
import Products from './pages/dashboard/Products';
import Settings from './pages/dashboard/Settings';

const router = createBrowserRouter([
  {
    path: "/dashboard",
    element: <DashboardLayout />,
    children: [
      {
        index: true, // Matches /dashboard exactly
        element: <Overview />,
      },
      {
        path: "users", // Matches /dashboard/users
        element: <Users />,
      },
      {
        path: "products", // Matches /dashboard/products
        element: <Products />,
      },
      {
        path: "settings", // Matches /dashboard/settings
        element: <Settings />,
      },
    ],
  },
]);

function App() {
  return <RouterProvider router={router} />;
}
Understanding Index Routes

index: true makes an "index route" that renders when the parent path matches exactly. Here, Overview shows at /dashboard and the other children show at their own paths. Also note the end prop on the Overview NavLink: without it, that link stays highlighted on every /dashboard/* page, because NavLink matches by prefix.

Dynamic Routes and URL Parameters

Anything with an ID in the URL (user profiles, product pages, blog posts) needs a dynamic segment. Prefix a segment with : and the router hands you its value.

Route Configuration
const router = createBrowserRouter([
  {
    path: "/users",
    element: <UsersLayout />,
    children: [
      {
        index: true,
        element: <UsersList />,
      },
      {
        path: ":userId", // Dynamic segment
        element: <UserProfile />,
      },
      {
        path: ":userId/edit",
        element: <EditUser />,
      },
    ],
  },
  {
    // Multiple dynamic segments
    path: "/products/:category/:productId",
    element: <ProductDetail />,
  },
  {
    // Optional segments with ?
    path: "/search/:query?",
    element: <SearchResults />,
  },
  {
    // Catch-all with *
    path: "/docs/*",
    element: <Documentation />,
  },
]);

Accessing URL Parameters with useParams

Params always come back as strings (or undefined). If you compare userId to a numeric ID from your API with ===, it will never match, so convert it first.

pages/UserProfile.jsx
import { useParams, Link } from 'react-router-dom';
import { useState, useEffect } from 'react';

function UserProfile() {
  const { userId } = useParams();
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    async function fetchUser() {
      try {
        const response = await fetch(`/api/users/${userId}`);
        const data = await response.json();
        setUser(data);
      } catch (error) {
        console.error('Failed to fetch user:', error);
      } finally {
        setLoading(false);
      }
    }
    
    fetchUser();
  }, [userId]); // Re-fetch when userId changes
  
  if (loading) return <LoadingSpinner />;
  if (!user) return <NotFound message="User not found" />;
  
  return (
    <div className="user-profile">
      <img src={user.avatar} alt={user.name} />
      <h1>{user.name}</h1>
      <p>{user.email}</p>
      <Link to={`/users/${userId}/edit`}>Edit Profile</Link>
    </div>
  );
}

Essential Router Hooks

These are the hooks I end up using in almost every project:

Hook Purpose Returns
useNavigate Programmatic navigation Navigate function
useParams Access URL parameters Object with params
useSearchParams Read/write query strings [searchParams, setSearchParams]
useLocation Current location object { pathname, search, hash, state }
useMatch Check if route matches Match object or null
useOutletContext Access parent context Context value from Outlet

useNavigate - Programmatic Navigation

Using useNavigate
import { useNavigate } from 'react-router-dom';

function LoginForm() {
  const navigate = useNavigate();
  
  async function handleSubmit(event) {
    event.preventDefault();
    const formData = new FormData(event.target);
    
    try {
      await loginUser(formData);
      
      // Navigate to dashboard after successful login
      navigate('/dashboard');
      
      // Or with options:
      navigate('/dashboard', { 
        replace: true, // Replace current history entry
        state: { from: 'login' } // Pass state to destination
      });
      
    } catch (error) {
      setError(error.message);
    }
  }
  
  return (
    <form onSubmit={handleSubmit}>
      {/* form fields */}
      <button type="submit">Login</button>
      
      {/* Go back */}
      <button type="button" onClick={() => navigate(-1)}>
        Go Back
      </button>
      
      {/* Go forward */}
      <button type="button" onClick={() => navigate(1)}>
        Go Forward
      </button>
    </form>
  );
}

useSearchParams - Query String Management

Search and Filter with Query Params
import { useSearchParams } from 'react-router-dom';

function ProductList() {
  const [searchParams, setSearchParams] = useSearchParams();
  
  // Read query parameters
  const category = searchParams.get('category') || 'all';
  const sort = searchParams.get('sort') || 'newest';
  const page = parseInt(searchParams.get('page') || '1');
  
  // Update query parameters
  function handleCategoryChange(newCategory) {
    setSearchParams(prev => {
      prev.set('category', newCategory);
      prev.set('page', '1'); // Reset to page 1
      return prev;
    });
  }
  
  function handleSortChange(newSort) {
    setSearchParams(prev => {
      prev.set('sort', newSort);
      return prev;
    });
  }
  
  function handlePageChange(newPage) {
    setSearchParams(prev => {
      prev.set('page', newPage.toString());
      return prev;
    });
  }
  
  // URL becomes: /products?category=electronics&sort=price&page=2
  
  return (
    <div>
      <Filters 
        category={category}
        sort={sort}
        onCategoryChange={handleCategoryChange}
        onSortChange={handleSortChange}
      />
      <ProductGrid category={category} sort={sort} page={page} />
      <Pagination page={page} onPageChange={handlePageChange} />
    </div>
  );
}

Protected Routes and Authentication

Some pages should only render for logged-in users, or for certain roles. The usual approach is a wrapper component that checks auth and either renders its children or redirects. Keep in mind this is a UX feature, not security: the JavaScript for the "protected" page still ships to the browser, so your API has to enforce permissions on its own.

Protected Route Flow User Request /dashboard Authenticated? Check Auth Yes ✓ Render Route <Dashboard /> No ↩ Redirect /login?from=/dashboard

Protected routes check authentication before rendering or redirecting

components/ProtectedRoute.jsx
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';

function ProtectedRoute({ children, requiredRole }) {
  const { user, isLoading } = useAuth();
  const location = useLocation();
  
  // Show loading while checking auth
  if (isLoading) {
    return <LoadingSpinner />;
  }
  
  // Not logged in - redirect to login
  if (!user) {
    return (
      <Navigate 
        to="/login" 
        state={{ from: location.pathname }}
        replace 
      />
    );
  }
  
  // Check role if required
  if (requiredRole && user.role !== requiredRole) {
    return <Navigate to="/unauthorized" replace />;
  }
  
  // Authorized - render children
  return children;
}

// Usage in routes:
const router = createBrowserRouter([
  {
    path: "/dashboard",
    element: (
      <ProtectedRoute>
        <DashboardLayout />
      </ProtectedRoute>
    ),
    children: [/* ... */]
  },
  {
    path: "/admin",
    element: (
      <ProtectedRoute requiredRole="admin">
        <AdminPanel />
      </ProtectedRoute>
    ),
  },
]);

Redirect After Login

pages/Login.jsx
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '../hooks/useAuth';

function Login() {
  const navigate = useNavigate();
  const location = useLocation();
  const { login } = useAuth();
  
  // Get the page they were trying to visit
  const from = location.state?.from || '/dashboard';
  
  async function handleSubmit(event) {
    event.preventDefault();
    const formData = new FormData(event.target);
    
    try {
      await login(formData.get('email'), formData.get('password'));
      
      // Redirect to the page they originally wanted
      navigate(from, { replace: true });
    } catch (error) {
      setError('Invalid credentials');
    }
  }
  
  return (
    <form onSubmit={handleSubmit}>
      <h1>Login</h1>
      {from !== '/dashboard' && (
        <p className="info">
          Please log in to access {from}
        </p>
      )}
      {/* form fields */}
    </form>
  );
}

Code Splitting with Lazy Loading

By default your bundler puts every page into one bundle, so someone opening the login screen also downloads the analytics dashboard. Splitting by route means each page's code loads when the user first visits it. How much that helps depends on how heavy your pages are, but routes are usually the easiest place to split.

Lazy Loading Routes
import { lazy, Suspense } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';

// Lazy load route components
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
const Analytics = lazy(() => import('./pages/Analytics'));

// Loading component
function PageLoader() {
  return (
    <div className="page-loader">
      <div className="spinner" />
      <p>Loading...</p>
    </div>
  );
}

const router = createBrowserRouter([
  {
    path: "/",
    element: (
      <Suspense fallback={<PageLoader />}>
        <Home />
      </Suspense>
    ),
  },
  {
    path: "/dashboard",
    element: (
      <Suspense fallback={<PageLoader />}>
        <Dashboard />
      </Suspense>
    ),
  },
  // Or wrap multiple routes with a single Suspense
]);

// Better approach: Create a lazy wrapper
function LazyRoute({ component: Component }) {
  return (
    <Suspense fallback={<PageLoader />}>
      <Component />
    </Suspense>
  );
}
Named Exports with Lazy

If your component uses named exports, you can use: const Page = lazy(() => import('./Page').then(module => ({ default: module.Page })))

After a deploy

Lazy chunks have hashed filenames. If you deploy while a user has the old version open, their next navigation may request a chunk that no longer exists, and you'll see errors like Failed to fetch dynamically imported module or ChunkLoadError. Either keep old assets around for a while after deploying, or catch that error in your errorElement and reload the page.

Data Loading with Loaders

The useEffect fetch in UserProfile above works, but the component renders first, then fetches, and every page needs its own loading and error state. Since v6.4, a route can declare a loader that runs before the route renders, so the component gets its data straight away and errors go to the route's errorElement. The page still waits on the network; you've just moved where that waiting happens.

Route with Loader
import { 
  createBrowserRouter, 
  useLoaderData,
  defer,
  Await 
} from 'react-router-dom';
import { Suspense } from 'react';

// Loader function runs before component renders
async function userLoader({ params }) {
  const response = await fetch(`/api/users/${params.userId}`);
  if (!response.ok) {
    throw new Response('User not found', { status: 404 });
  }
  return response.json();
}

// Deferred loader for non-critical data
function dashboardLoader() {
  return defer({
    user: fetchUser(), // Critical - wait for this
    stats: fetchStats(), // Non-critical - stream later
    notifications: fetchNotifications(), // Non-critical
  });
}

const router = createBrowserRouter([
  {
    path: "/users/:userId",
    element: <UserProfile />,
    loader: userLoader,
    errorElement: <ErrorPage />,
  },
  {
    path: "/dashboard",
    element: <Dashboard />,
    loader: dashboardLoader,
  },
]);

// Component using loader data
function UserProfile() {
  const user = useLoaderData();
  
  // No loading state needed - data is already available!
  return (
    <div className="profile">
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

// Component with deferred data
function Dashboard() {
  const { user, stats, notifications } = useLoaderData();
  
  return (
    <div>
      {/* User loads immediately */}
      <WelcomeMessage user={user} />
      
      {/* Stats stream in when ready */}
      <Suspense fallback={<StatsSkeletons />}>
        <Await resolve={stats}>
          {(stats) => <StatsCards stats={stats} />}
        </Await>
      </Suspense>
      
      {/* Notifications stream in when ready */}
      <Suspense fallback={<NotificationsSkeleton />}>
        <Await resolve={notifications}>
          {(notifications) => <NotificationList data={notifications} />}
        </Await>
      </Suspense>
    </div>
  );
}

Form Handling with Actions

Actions are the write side of loaders. A <Form method="post"> submits to the route's action, and after the action finishes, the router re-runs the loaders on the page so the UI picks up the new data without you refetching by hand.

Route with Action
import { 
  Form, 
  useActionData, 
  useNavigation,
  redirect 
} from 'react-router-dom';

// Action handles form submission
async function createUserAction({ request }) {
  const formData = await request.formData();
  
  const errors = validateUser(formData);
  if (Object.keys(errors).length) {
    return { errors };
  }
  
  try {
    const user = await createUser({
      name: formData.get('name'),
      email: formData.get('email'),
      role: formData.get('role'),
    });
    
    // Redirect after successful creation
    return redirect(`/users/${user.id}`);
  } catch (error) {
    return { errors: { form: error.message } };
  }
}

const router = createBrowserRouter([
  {
    path: "/users/new",
    element: <CreateUserForm />,
    action: createUserAction,
  },
]);

function CreateUserForm() {
  const actionData = useActionData();
  const navigation = useNavigation();
  
  const isSubmitting = navigation.state === 'submitting';
  
  return (
    <Form method="post" className="user-form">
      <h1>Create New User</h1>
      
      {actionData?.errors?.form && (
        <div className="error">{actionData.errors.form}</div>
      )}
      
      <div className="field">
        <label htmlFor="name">Name</label>
        <input 
          type="text" 
          name="name" 
          id="name"
          required 
        />
        {actionData?.errors?.name && (
          <span className="field-error">
            {actionData.errors.name}
          </span>
        )}
      </div>
      
      <div className="field">
        <label htmlFor="email">Email</label>
        <input 
          type="email" 
          name="email" 
          id="email"
          required 
        />
      </div>
      
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Creating...' : 'Create User'}
      </button>
    </Form>
  );
}

Error Handling

With a data router, any error thrown in a loader, action or while rendering bubbles up to the nearest errorElement. If there isn't one, you get React Router's default error screen, which is fine for development and not something you want users to see.

Error Handling
import { 
  useRouteError, 
  isRouteErrorResponse,
  Link 
} from 'react-router-dom';

function ErrorPage() {
  const error = useRouteError();
  
  // Handle different error types
  if (isRouteErrorResponse(error)) {
    if (error.status === 404) {
      return (
        <div className="error-page">
          <h1>404 - Page Not Found</h1>
          <p>The page you're looking for doesn't exist.</p>
          <Link to="/">Go Home</Link>
        </div>
      );
    }
    
    if (error.status === 401) {
      return (
        <div className="error-page">
          <h1>Unauthorized</h1>
          <p>You need to log in to access this page.</p>
          <Link to="/login">Log In</Link>
        </div>
      );
    }
    
    return (
      <div className="error-page">
        <h1>{error.status} Error</h1>
        <p>{error.statusText}</p>
      </div>
    );
  }
  
  // Handle unexpected errors
  return (
    <div className="error-page">
      <h1>Oops! Something went wrong</h1>
      <p>{error.message || 'An unexpected error occurred'}</p>
      <button onClick={() => window.location.reload()}>
        Try Again
      </button>
    </div>
  );
}

// Route configuration with error boundary
const router = createBrowserRouter([
  {
    path: "/",
    element: <RootLayout />,
    errorElement: <ErrorPage />, // Catches errors in this route and children
    children: [
      {
        path: "users/:userId",
        element: <UserProfile />,
        loader: userLoader,
        // Can have its own error element
        errorElement: <UserNotFound />,
      },
    ],
  },
]);

Habits That Keep Routing Manageable

Organize Routes by Feature

Group routes by feature or domain rather than by route type:

src/
├── features/
│   ├── auth/
│   │   ├── routes.jsx      # Auth routes config
│   │   ├── Login.jsx
│   │   └── Register.jsx
│   ├── dashboard/
│   │   ├── routes.jsx      # Dashboard routes
│   │   ├── Overview.jsx
│   │   └── Settings.jsx
│   └── users/
│       ├── routes.jsx      # User routes
│       ├── UserList.jsx
│       └── UserProfile.jsx
└── App.jsx                  # Combines all routes

Use Relative Links in Nested Routes

Inside nested routes, relative paths mean you can rename a parent path without hunting down every link:

// Inside /dashboard route
<Link to="settings">Settings</Link>    // Goes to /dashboard/settings
<Link to="../profile">Profile</Link>  // Goes to /profile
<Link to=".">Current</Link>           // Stays on current route

Quick Reference Checklist

  • ✅ Use createBrowserRouter for new projects (enables data APIs)
  • ✅ Use NavLink for navigation menus with active states
  • ✅ Implement errorElement for each route level
  • ✅ Use loader for data fetching, action for mutations
  • ✅ Lazy load routes with React.lazy for code splitting
  • ✅ Use useSearchParams for filters and pagination
  • ✅ Wrap protected routes in an authentication component
  • ✅ Use the end prop on NavLink for exact matching
  • ✅ Pass state via Link for context between pages
  • ✅ Use index routes for default child content

When I'd Use the Data APIs (and When I Wouldn't)

Plain routing with Routes, Link and useParams is uncontroversial. The bigger decision is whether to lean on loaders and actions.

I'd use them when the app is mostly "load a page of data, show it, submit a form": admin panels, CRUD dashboards, internal tools. You get fewer loading spinners scattered through components, errors handled per route, and automatic revalidation after a form submit.

I'd skip them if you already use TanStack Query, SWR or RTK Query for server state. Those libraries handle caching, background refetching and optimistic updates better than loaders do, and running both usually means two sources of truth for the same data. In that case keep React Router for URLs and layouts, and let the query library own the data. (You can call the query client from a loader to prefetch, but that's an optimization, not a starting point.)

Also check which version you're on. In v7, react-router-dom was folded into react-router and defer is no longer needed, since loaders can return promises directly. The concepts here carry over, but some imports won't.

And if what you actually need is server rendering, SEO for public pages, or data loading on the server, a client-side router won't give you that by itself. That's when I'd look at React Router's framework mode, Remix or Next.js instead of bolting it on.

React Router Navigation SPA Frontend
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and modern technologies.