# @uxf/router

## Installation

```
yarn add @uxf/router
```

There are two factories:

- `createRouter` from `@uxf/router` is the default. It is server-safe (no React hooks, no client-only imports) and can be used anywhere, including React Server Components. It returns `routeToUrl`, `route`, `routes`, `getRouteInfo`, `createRouteMatcher` and `createSitemapGenerator`.
- `createClientRouter` from `@uxf/router/client` is a superset that adds the React hooks (`useQueryParams`, `useQueryParamsStatic`, `usePageParams`, `useRouteInfo`). It imports `next/navigation` and `next/router`, so it must only be used in client components.

Define the routes once as plain data, then build each router from it:

```ts
// routes/routes.ts – plain route definitions, importable anywhere

import { number, object, optional, string } from "superstruct";

export const routes = {
    index: {
        path: "/",
    },
    "admin/index": {
        path: "/admin",
        schema: object({
            param1: optional(number()),
        }),
    },
    "blog/detail": {
        path: "/blog/[id]",
        schema: object({
            id: number(),
        }),
    },
    "localized-route": {
        path: {
            en: "/en/home",
            cs: "/cs/domu",
        },
        schema: object({
            term: optional(string()),
        }),
    },
} as const;

export const routerOptions = {
    locales: ["cs", "en"],
    baseUrl: "https://www.uxf.cz",
} as const;

export type RouteList = typeof routes;
```

```ts
// routes/index.ts – server-safe entry (`@app-routes`), usable in RSC

import { createRouter, ExtractSchema, UxfGetServerSideProps, UxfGetStaticProps } from "@uxf/router";
import { PreviewData as NextPreviewData } from "next/types";
import { RouteList, routerOptions, routes } from "./routes";

export const { route, routeToUrl, getRouteInfo, createRouteMatcher, createSitemapGenerator } = createRouter(
    routes,
    routerOptions,
);

export type GetRouteSchema<K extends keyof RouteList> = ExtractSchema<RouteList[K]>;

export type GetStaticProps<
    Route extends keyof RouteList,
    Props extends { [key: string]: any } = { [key: string]: any },
    PreviewData extends NextPreviewData = NextPreviewData,
> = UxfGetStaticProps<RouteList, Route, Props, PreviewData>;

export type GetServerSideProps<
    Route extends keyof RouteList,
    Props extends { [key: string]: any } = { [key: string]: any },
    PreviewData extends NextPreviewData = NextPreviewData,
> = UxfGetServerSideProps<RouteList, Route, Props, PreviewData>;
```

```ts
// routes/client.ts – client entry (`@app-routes/client`), hooks for client components

"use client";

import { createClientRouter } from "@uxf/router/client";
import { routerOptions, routes } from "./routes";

export const { useQueryParams, useQueryParamsStatic, usePageParams, useRouteInfo } = createClientRouter(
    routes,
    routerOptions,
);
```

Add configuration to `tsconfig.json`

```json
{
    "compilerOptions": {
        "baseUrl": "./src",
        "paths": {
            "@app-routes": ["routes"],
            "@app-routes/client": ["routes/client"]
        }
    }
}
```

## routeToUrl

Builds a URL from a route name and its params. Path params are substituted into the bracketed segments; anything left over becomes the query string.

```ts
routeToUrl("blog/detail", { id: 12 }); /* "/blog/[id]" -> "/blog/12" */
routeToUrl("admin/index", { param1: 3 }); /* "/admin?param1=3" */
routeToUrl("localized-route", { term: "x" }, { locale: "cs" }); /* "/cs/domu?term=x" */
routeToUrl("index", {}, { shouldBeAbsolute: true }); /* "https://www.uxf.cz/" */
```

**A missing required param throws.** Any bracketed segment still present after substitution means the URL would be broken, so it fails loudly rather than shipping a href containing `[id]`:

```ts
routeToUrl("blog/detail", {});
// Error: Missing parameter '[id]' for route 'blog/detail'.
```

**Optional catch-all segments may be omitted entirely.** For a path like `/catch-all-optional/[[...pathParams]]`, the segment is stripped whether the key is absent, `null`, `undefined`, `""` or `[]`:

```ts
routeToUrl("optionalCatchAll", {}); /* "/catch-all-optional" */
routeToUrl("optionalCatchAll", { pathParams: null }); /* "/catch-all-optional" */
routeToUrl("optionalCatchAll", { pathParams: ["a", "b"] }); /* "/catch-all-optional/a/b" */
```

A **required** catch-all (`[...pathParams]`) is not optional: an empty array throws `Parameter 'pathParams' can not be empty array for route '…'`, and omitting the key throws the missing-parameter error above.

## getRouteInfo

Resolves a pathname back to the route that declared it, or `null` when nothing matches. The result is `RouteInfo` = `{ pathname: string; routeName: string; routeDefinition: RouteDefinition }` — note it carries the pathname, not parsed params.

```ts
getRouteInfo(
    "/blog/12",
); /* { pathname: "/blog/12", routeName: "blog/detail", routeDefinition: { path: "/blog/[id]", … } } */
getRouteInfo("/not-a-route"); /* null */
```

**Candidates are ordered by specificity, not by declaration order** — fewest catch-all segments first, then fewest dynamic segments, then most static segments. A localized route is ranked by its most specific variant. So a static path is never shadowed by a dynamic one declared before it:

```ts
const routes = {
    hotelDetail: { path: "/hotel/[hotel-id]" },
    hotelCreate: { path: "/hotel/create-hotel" },
    hotelCatchAll: { path: "/hotel/[...rest]" },
} as const;

getRouteInfo("/hotel/create-hotel")?.routeName; /* "hotelCreate", not "hotelDetail" */
getRouteInfo("/hotel/12")?.routeName; /* "hotelDetail" */
getRouteInfo("/hotel/12/a/b")?.routeName; /* "hotelCatchAll" - only when nothing more specific matches */
```

`createRouteMatcher` delegates to `getRouteInfo`, so it follows the same ordering — which is what makes active-route detection in navigation and layouts agree with the resolved route.

## useQueryParams

Hooks live in the client entry (`@app-routes/client`):

```tsx
import { useQueryParams } from "@app-routes/client";
import { queryParamToNumber } from "@uxf/router";

// can be used on SSR pages
const [query, { push, replace }] = useQueryParams("route-name");

// must be used on static pages, because router is not ready on first render
// query is null if router is not ready
const [query, { push, replace }] = useQueryParamsStatic("route-name");
```

## Next Link

```tsx
// pages/index.js

import Link from "next/link";
import { routeToUrl } from "@app-routes";

export default () => <Link href={routeToUrl("blog/detail", { id: 12 })}>Hello world</Link>;
```

## RouteMatcher

```tsx
import { createRouteMatcher } from "@app-routes";

// create active resolver
const routeMatcher = createRouteMatcher("admin/index", { param1: 123 });
// or
const routeMatcher = createRouteMatcher("admin/index");

// how to use in component

function MyComponent() {
    const router = useRouter();
    const isRouteActive = routeMatcher(router);

    return <div>{isRouteActive ? "active" : "not active"}</div>;
}
```

### Custom route matchers

```tsx
function createPathnameRouteMatcher(path: string): RouteMatcher {
    return (router) => {
        return router.pathname.startsWith(path);
    };
}
```

```tsx
import { getCurrentRoute } from "@app-routes";

function createCustomRouteMatcher(): RouteMatcher {
    return (router) => {
        const { route, params } = getCurrentRoute(router);
        if (route === "admin/index") {
            // do something
        } else if (route === "admin/form") {
            // do something
        }
    };
}
```

### Merge multiple route matchers

```tsx
import { mergeRouteMatchers } from "@uxf/router";

const routeMatcher = mergeRouteMatchers([createRouteMatcher("admin/index"), createRouteMatcher("admin/form")]);
```

## Type-safe route params

```tsx
import { GetRouteSchema } from "@app-routes";

const blogProps: GetRouteSchema<"blog/detail"> = {
    id: 1,
};
```

## GetStaticProps

```tsx
import { GetStaticProps } from "@app-routes";
import { queryParamToNumber } from "@uxf/router";

export const getStaticProps: GetStaticProps<"blog/detail"> = (context) => {
    const id = queryParamToNumber(context.params?.id); // context.params is of type { id: number } | undefined
};
```

## GetServerSideProps

```tsx
import { GetServerSideProps } from "@app-routes";
import { queryParamToNumber } from "@uxf/router";

export const getServerSideProps: GetServerSideProps<"blog/detail"> = (context) => {
    const id = queryParamToNumber(context.params?.id); // context.params is of type { id: number } | undefined
};
```

## Sitemap

Create sitemap items

```tsx
// sitemap-items.ts in @app-routes

import { createSitemapGenerator, routeToUrl } from "@app-routes";

export const sitemapItems = createSitemapGenerator({ baseUrl: "http://localhost:3000", defaultPriority: 1 })
    .add("index", async (route) => ({ loc: routeToUrl(route) }))
    .add("blog/detail", async (route) => [
        { loc: routeToUrl(route, { id: 1 }), priority: 2 },
        { loc: routeToUrl(route, { id: 2 }), priority: 2 },
    ])
    .skip("admin/index")
    .exhaustive();
```

#### sitemap.xml

```tsx
// pages/sitemap.xml.tsx

import React from "react";
import { NextPage } from "next";
import { sitemapItems } from "@app-routes";

const Page: NextPage = () => null;

Page.getInitialProps = async (ctx) => {
    if (ctx.res) {
        ctx.res.setHeader("Content-Type", "text/xml");
        ctx.res.write(await sitemapItems.toXml());
        ctx.res.end();
    }

    return {};
};

export default Page;
```

#### sitemap.json

```tsx
// pages/sitemap.json.tsx

import React from "react";
import { NextPage } from "next";
import { sitemapItems } from "@app-routes";

const Page: NextPage = () => null;

Page.getInitialProps = async (ctx) => {
    if (ctx.res) {
        ctx.res.setHeader("Content-Type", "text/json");
        ctx.res.write(await sitemapItems.toJson());
        ctx.res.end();
    }

    return {};
};

export default Page;
```
