02examples
View and date in the URL
Controlled view and date mirrored into the query string, so a link opens the same week and the browser's back button steps the calendar.
Step a week forward and look at the address bar: ?view=week&date=… follows.
Switch to month view; it follows again. Press the browser's back button and
the calendar goes back with it. Copy the link, open it in another tab, and it
lands on the same view of the same week — a calendar you can share.
liveView and date in the URLviewdateonViewChangeonDateChange
loading
"use client";
import { useEffect, useMemo, useState } from "react";
import { usePathname } from "next/navigation";
import {
buildSeedEvents,
Kloq,
MAX_N_DAY_VIEW,
MIN_N_DAY_VIEW,
parseInstant,
toDateOnlyString,
type KloqView,
} from "kloq";
import { Button } from "@/components/ui/button";
const NAMED: KloqView[] = ["day", "week", "month", "agenda", "year"];
function parseView(s: string | null): KloqView | null {
if (NAMED.includes(s as KloqView)) return s as KloqView;
const n = Number(s);
return Number.isInteger(n) && n >= MIN_N_DAY_VIEW && n <= MAX_N_DAY_VIEW ? n : null;
}
/** `YYYY-MM-DD` at local midnight; anything else — including a rolled-over 2026-02-31 — is rejected. */
function parseDate(s: string | null): Date | null {
if (!s) return null;
const d = new Date(parseInstant(s));
return Number.isNaN(d.getTime()) || toDateOnlyString(d) !== s ? null : d;
}
function fromQuery(search: string) {
const params = new URLSearchParams(search);
return {
view: parseView(params.get("view")) ?? "week",
date: parseDate(params.get("date")) ?? new Date(),
};
}
const toQuery = (view: KloqView, date: Date) => `?view=${view}&date=${toDateOnlyString(date)}`;
export default function UrlStateExample() {
const pathname = usePathname();
// the query string is the state; null until the location has been read
const [search, setSearch] = useState<string | null>(null);
const { view, date } = useMemo(() => fromQuery(search ?? ""), [search]);
const [seeds] = useState(() => buildSeedEvents());
const [copied, setCopied] = useState(false);
useEffect(() => {
const read = () => setSearch(window.location.search);
// normalise the incoming link: missing or invalid params become today's week
const initial = fromQuery(window.location.search);
window.history.replaceState(null, "", toQuery(initial.view, initial.date));
read();
window.addEventListener("popstate", read);
return () => window.removeEventListener("popstate", read);
}, []);
const navigate = (next: { view?: KloqView; date?: Date }) => {
const query = toQuery(next.view ?? view, next.date ?? date);
if (query === search) return;
window.history.pushState(null, "", query);
setSearch(query);
};
const copyLink = async () => {
try {
await navigator.clipboard.writeText(window.location.href);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// clipboard unavailable — the URL is on screen anyway
}
};
return (
<div className="flex flex-col">
<div className="h-[26rem]">
<Kloq
view={view}
onViewChange={(v) => navigate({ view: v })}
date={date}
onDateChange={(d) => navigate({ date: d })}
defaultEvents={seeds}
persistence={false}
storage={false}
designMode={false}
responsive={false}
>
<Kloq.Toolbar />
</Kloq>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 border-t px-4 py-2 font-mono text-xs text-muted-foreground">
<span className="min-w-0 flex-1 truncate">
url <span className="text-foreground">{pathname}{search ?? ""}</span>
</span>
<Button size="xs" variant="outline" className="font-mono text-xs" onClick={copyLink}>
{copied ? "copied" : "copy link"}
</Button>
</div>
</div>
);
}
How it works
viewanddateare controlled. With both props set, ‹ ›, Today, the view switcher, the month picker and the arrow keys all ask throughonViewChange/onDateChange, and nothing moves until the props change. That is what makes the URL authoritative: the calendar cannot drift from it. See Controlled state.- The query string is the state.
searchholds it verbatim;viewanddateare derived from it withuseMemo.navigatebuilds the next string, pushes it withwindow.history.pushState, and stores it — no router navigation, no server round-trip. Next.js patchespushState, souseSearchParamselsewhere on the page stays in sync for free. - Back and forward are a
popstatelistener that readslocation.searchback into state. The initial read runs in an effect, never during render, so the server-rendered markup and the first client paint agree; it alsoreplaceStates a normalised query, so a bare or broken link becomes?view=week&date=<today>without adding a history entry. - Parsing is strict.
parseViewaccepts the five named views and an n-day integer insideMIN_N_DAY_VIEW … MAX_N_DAY_VIEW;parseDateacceptsYYYY-MM-DDonly, and rejects a date that would roll over by checking that the parsedDateformats back — throughtoDateOnlyString— to the same string. Anything else falls back to today's week. See Views & navigation. - The chrome is the stock
<Kloq.Toolbar />. Its parts read the same controlled state throughuseKloq(), which is why they need no wiring. See Toolbar & chrome. responsive={false}keeps the URL honest in this narrow column. With the collapse on, a box under 900px squeezes a week to three days and reports3throughonViewChange— the effective view, not the intent — and that is what would land in the link. See Views & navigation.
Take it further
- Prefer one history entry per visit? Swap
pushStateforreplaceStateinnavigate— the link still shares, the back button leaves the page. - Put it in the path instead:
/calendar/week/2026-08-31. The parsing does not change; readusePathname()instead oflocation.search. - Turn the collapse back on for a real app, and keep the intent in the
URL: a child of
<Kloq>can readuseKloq().collapsed, and a view change reported while it is true is the width talking, not the user.