Skip to main content

@ovineko/react-router

Type-safe wrapper for React Router v7 with valibot schema validation, automatic error handling, and typed params.

Install

pnpm add @ovineko/react-router react react-router valibot

Peer dependencies: react@^19, react-router@^7, valibot@^1.

Features

  • Valibot schema validation — Runtime validation for URL params and search params
  • Two hook variants — Normal hooks (auto-redirect on error) and Raw hooks (manual error handling)
  • Smart validation — Ignores extra query params and invalid optional params
  • Global error handling — Configure fallback redirect URLs globally or per-route
  • Conditional redirects — useRedirect hook for declarative navigation with infinite loop prevention
  • Optional search params helper — optionalSearchParams utility to avoid repetitive v.optional() calls
  • Type-safe — Full TypeScript support with inferred types
  • Hash support — Generate paths with hash fragments

Quick Start

import { createRouteWithParams, optionalSearchParams } from "@ovineko/react-router";
import * as v from "valibot";

const userRoute = createRouteWithParams("/users/:id", {
params: v.object({ id: v.pipe(v.string(), v.uuid()) }),
searchParams: optionalSearchParams({
tab: v.string(),
page: v.pipe(v.string(), v.transform(Number), v.number()),
}),
errorRedirect: "/404",
});

// Normal hooks - auto-redirect on validation error
function UserPage() {
const params = userRoute.useParams(); // Readonly<{ id: string }>
const [searchParams, setSearchParams] = userRoute.useSearchParams();

return (
<div>
User {params.id}, Page {searchParams.page ?? 1}
</div>
);
}

// Raw hooks - manual error handling
function AdvancedUserPage() {
const [params, error] = userRoute.useParamsRaw();
const { data, error: searchError } = userRoute.useSearchParamsRaw();

if (error) return <div>Invalid user ID</div>;
if (searchError) console.warn("Invalid search params", searchError);

return <div>User {params?.id}</div>;
}

Global Error Redirect

Configure a global fallback URL for validation errors:

import { setGlobalErrorRedirect } from "@ovineko/react-router";

// In your app entry point
setGlobalErrorRedirect("/error");

// Priority: route-level > global > default "/"
const route = createRouteWithParams("/users/:id", {
params: v.object({ id: v.string() }),
errorRedirect: "/404", // Overrides global
});

Validation Behavior

Path Params

  • Always strict validation
  • Redirect on any validation error

Search Params

  • Extra params (not in schema) — ignored
  • Invalid optional params — ignored (treated as undefined)
  • Invalid required params — error (triggers redirect)
const route = createRouteWithParams("/search", {
params: v.object({}),
searchParams: v.object({
q: v.string(), // required
page: v.optional(v.pipe(v.string(), v.transform(Number), v.number())),
}),
});

// URL: /search?q=react&page=invalid&debug=true
// Result: { q: "react", page: undefined }
// - page=invalid ignored (optional + invalid)
// - debug=true ignored (not in schema)

API Reference

Route Creation

createRouteWithParams

const route = createRouteWithParams<TParams, TSearchParams>(pattern, config);

Config:

  • params — Valibot schema for URL params (required)
  • searchParams — Valibot schema for search params (optional)
  • errorRedirect — Redirect URL on validation error (optional)

Returns:

  • path(params, searchParams?, hash?) — Generate URL path
  • parseURLParams(url) — Parse and validate URL params
  • Link — Type-safe Link component
  • useParams() — Get validated params (auto-redirect on error)
  • useParamsRaw() — Get params with error info [data, error]
  • useSearchParams() — Get validated search params (auto-redirect on error)
  • useSearchParamsRaw() — Get search params with error info {data, error, setter}
  • pattern — Original route pattern

createRouteWithoutParams

const route = createRouteWithoutParams<TSearchParams>(pattern, config?);

Config:

  • searchParams — Valibot schema for search params
  • errorRedirect — Redirect URL on validation error

Returns:

  • path(searchParams?, hash?) — Generate URL path
  • Link — Type-safe Link component
  • useSearchParams() — Get validated search params
  • useSearchParamsRaw() — Get search params with error info
  • pattern — Original route pattern

Examples

Path Generation

const userRoute = createRouteWithParams("/users/:id", {
params: v.object({ id: v.string() }),
searchParams: v.object({
tab: v.optional(v.string()),
}),
});

userRoute.path({ id: "42" });
// "/users/42"

userRoute.path({ id: "42" }, { tab: "settings" });
// "/users/42?tab=settings"

userRoute.path({ id: "42" }, { tab: "settings" }, "profile");
// "/users/42?tab=settings#profile"
<userRoute.Link params={{ id: "42" }} searchParams={{ tab: "profile" }}>
View Profile
</userRoute.Link>

// Automatically includes prevPath in navigation state

Parse URL Params

const params = userRoute.parseURLParams("https://example.com/users/42");
// { id: "42" }

// Throws URLParseError on invalid URL or validation error

Update Search Params

const [searchParams, setSearchParams] = route.useSearchParams();

// Set new params
setSearchParams({ q: "react", page: 1 });

// Update based on previous
setSearchParams((prev) => ({ ...prev, page: prev.page + 1 }));

// With navigation options
setSearchParams({ q: "vue" }, { replace: true });

Utilities

useRedirect(path, condition, replace?)

Declarative hook for conditional redirects with built-in infinite loop prevention.

Parameters:

  • path: string — Target redirect URL
  • condition: boolean — Whether to trigger the redirect
  • replace?: boolean — Use replace instead of push (default: true)

Features:

  • Prevents infinite redirect loops using useRef tracking
  • Only redirects once when condition becomes true
  • Resets automatically when condition becomes false
  • Uses replace: true by default to prevent back-button issues
import { useRedirect } from "@ovineko/react-router";

function ProtectedPage() {
const { isAuthenticated, isLoading } = useAuth();

// Redirect to login if not authenticated
useRedirect("/login", !isAuthenticated && !isLoading);

if (isLoading) return <Spinner />;
return <div>Protected Content</div>;
}

// Advanced usage with custom options
function UserProfile() {
const { user, error } = useUser();

// Redirect without replacing history
useRedirect("/users", !user && !error, false);

return <div>{user?.name}</div>;
}

GuardedRoute

Declarative route guard wrapper for React Router v7 route configs. Makes route protection visible at the routing config level instead of hidden inside page components.

Props:

  • useGuard: () => GuardResult — A React hook that returns guard state
  • loadingFallback?: React.ReactNode — Optional loading UI (blocks child rendering)

GuardResult interface:

interface GuardResult {
allowed: boolean; // Whether the user is allowed to access this route
isLoading: boolean; // Whether the guard data is still loading
redirectTo: string; // Where to redirect when !allowed && !isLoading
}

Behavior:

  • Default (no loadingFallback): Renders <Outlet /> immediately while isLoading is true. This allows the child page's lazy import to start in parallel with the guard's data fetching.
  • With loadingFallback: Renders the fallback instead of <Outlet /> while isLoading is true. Use this when you need to block child rendering until the guard resolves.
  • Redirect: When isLoading is false and allowed is false, redirects to redirectTo using replace (guards are access checks, not navigation).

Example — Extracting Guard from Data Hook:

import { GuardedRoute } from "@ovineko/react-router";
import type { GuardResult } from "@ovineko/react-router";

// Guard hook — extracted, visible in route config
function useOrderGuard(): GuardResult {
const { orderId } = useParams<{ orderId: string }>();
const { data, isLoading } = useSWR(`/api/orders/${orderId}`);

return {
allowed: Boolean(data?.order),
isLoading,
redirectTo: "/orders",
};
}

// Data hook — pure, no redirects
function useOrderData() {
const { orderId } = useParams<{ orderId: string }>();
const { data, isLoading } = useSWR(`/api/orders/${orderId}`);
return { order: data?.order, isLoading };
}

function Checkout() {
const { order, isLoading } = useOrderData();
if (isLoading) return <Spinner />;
return <div>Checkout for order {order.id}</div>;
}

// Route config — protection is visible and declarative
const routes: RouteObject[] = [
{
element: <GuardedRoute useGuard={useOrderGuard} />,
children: [{ path: "orders/:orderId/checkout", element: <Checkout /> }],
},
];

Example — With loadingFallback:

const protectedRoutes: RouteObject[] = [
{
element: <GuardedRoute useGuard={useAuthGuard} loadingFallback={<Spinner />} />,
children: [{ path: "dashboard", element: <Dashboard /> }],
},
];

Comparison with useRedirect:

  • useRedirect — Hook-level conditional redirect (used inside components)
  • GuardedRoute — Config-level declarative guard (used in route definitions)

Both complement each other: GuardedRoute makes route protection explicit in the config, while useRedirect handles component-level conditional navigation.

optionalSearchParams(entries)

Utility to make all search param fields optional automatically, avoiding repetitive v.optional() calls.

import { optionalSearchParams } from "@ovineko/react-router";
import * as v from "valibot";

// Before: Manual v.optional() for each field
const route = createRouteWithParams("/search", {
params: v.object({ id: v.string() }),
searchParams: v.object({
q: v.optional(v.string()),
page: v.optional(v.pipe(v.string(), v.transform(Number), v.number())),
sort: v.optional(v.string()),
filter: v.optional(v.string()),
}),
});

// After: Clean and concise
const route = createRouteWithParams("/search", {
params: v.object({ id: v.string() }),
searchParams: optionalSearchParams({
q: v.string(),
page: v.pipe(v.string(), v.transform(Number), v.number()),
sort: v.string(),
filter: v.string(),
}),
});

// All fields are automatically optional!
const [searchParams] = route.useSearchParams();
// Type: Readonly<{ q?: string; page?: number; sort?: string; filter?: string }>

setGlobalErrorRedirect(url)

Set global fallback redirect URL for validation errors.

setGlobalErrorRedirect("/error");

replaceState(state)

Update browser history state without navigation.

import { replaceState } from "@ovineko/react-router";

replaceState({ scrollTop: window.scrollY });

URLParseError

Error class thrown when URL parsing fails.

try {
route.parseURLParams("invalid-url");
} catch (error) {
if (error instanceof URLParseError) {
console.log(error.context); // { pattern, url, ... }
}
}

TypeScript

All types are automatically inferred from valibot schemas:

const route = createRouteWithParams("/users/:id", {
params: v.object({ id: v.string() }),
searchParams: v.object({
page: v.optional(v.pipe(v.string(), v.transform(Number), v.number())),
}),
});

// Inferred types:
const params = route.useParams(); // Readonly<{ id: string }>
const [searchParams] = route.useSearchParams(); // Readonly<{ page?: number }>