Next.js App Router example
A calendar whose view and date live in the URL, loaded by a Server Component and saved through Server Actions.
Next.js and server rendering covers the basics: Client Components, strings
instead of Temporal values, and now and the time zone from the server. This page walks through a
complete App Router route built on them. Open the example, then read its source
here. To load events in the browser from an API instead, see
Load events with TanStack Query.
- The URL holds the view and the date, as in
/examples/nextjs?view=week&date=2026-10-12. Previous, next, Today and the view switcher change it, and Back and Forward work. - A Server Component fetches the meetings for the range the URL shows.
- Server Actions save created, moved and resized meetings. The calendar proposes each change, the server decides, and the page renders again with the result.
- The time zone comes from a cookie, and the first render's clock from the server.
The route has eight files:
| File | Kind | What it does |
|---|---|---|
layout.tsx | Server Component | The heading and the frame, which stay put as pages change |
page.tsx | Server Component | Reads the URL and the cookies, and fetches the meetings |
loading.tsx | Server Component | The first load's skeleton |
location.ts | Shared | The URL's view and date, and the dates they show |
meetings.ts | Server only | An in-memory store that stands in for your database |
actions.ts | Server Actions | Save meetings and the time zone |
calendar.ts | Client | The calendar, its views and its policies |
team-calendar.tsx | Client Component | The calendar's state, wired to the URL and the actions |
The URL
The view and the date are search params. A missing or broken view falls back to the week, and a missing or broken date to today, so every link opens something:
export const meetingViews = ["week", "day", "month"] as const;
export type MeetingView = (typeof meetingViews)[number];
/** Where the calendar is, as the URL says: `?view=week&date=2026-10-12`. */
export interface CalendarLocation {
readonly date: string;
readonly view: MeetingView;
}
type SearchParams = Readonly<Record<string, string | string[] | undefined>>;
/** Reads the view and date from the URL. Missing or broken ones fall back to this week. */
export function parseLocation(params: SearchParams, today: Temporal.PlainDate): CalendarLocation {
const view = meetingViews.find((name) => name === params.view) ?? "week";
return { date: (parseDate(params.date) ?? today).toString(), view };
}
function parseDate(value: string | string[] | undefined): Temporal.PlainDate | undefined {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined;
try {
return Temporal.PlainDate.from(value, { overflow: "reject" });
} catch {
return undefined;
}
}
export function locationSearch({ date, view }: CalendarLocation): string {
return new URLSearchParams({ view, date }).toString();
}The page
The page is a Server Component. It reads the URL, the visitor and the clock, and fetches the meetings for the range the calendar will show:
import { TeamCalendar } from "./team-calendar";
import { parseLocation, visibleRange } from "./location";
import { findMeetings, getVisitor } from "./meetings";
interface MeetingsPageProps {
readonly searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function MeetingsPage({ searchParams }: MeetingsPageProps) {
const visitor = await getVisitor();
// Until the browser has said which zone it's in, render in UTC.
const timeZone = visitor.timeZone ?? "UTC";
// Read the clock once, and hand the instant to the browser, so both render the same today.
const now = Temporal.Now.instant();
const location = parseLocation(
await searchParams,
now.toZonedDateTimeISO(timeZone).toPlainDate(),
);
const meetings = await findMeetings(visitor, visibleRange(location, timeZone));
return (
<TeamCalendar
location={location}
meetings={meetings}
now={now.toString()}
timeZone={timeZone}
timeZoneFromCookie={visitor.timeZone !== undefined}
/>
);
}- It renders on every request. Reading
searchParamsand cookies makes the page dynamic. nowis read once, here, and passed down as a string, so the server and the browser render the same today. Once the page has hydrated, the calendar follows the browser's clock instead, so the now line keeps moving between server renders.- Meetings arrive as strings.
meetings.tssends them asMeetingJson, with ISO 8601 times:
/** A meeting as the server sends it: Client Components get strings, not Temporal values. */
export interface MeetingJson {
readonly color: CalendarEventColor;
readonly end: string;
readonly id: string;
readonly start: string;
readonly title: string;
}The server works out the dates with the functions the views use, so it fetches exactly what they show. The example's tests check that the two agree for every view:
export const locale = "en-US";
export const weekRules = { firstDay: 1, minimalDays: 4, weekend: [6, 7] } as const;
/** The dates a location shows, by the same rules as its view. */
export function visibleDates({ date, view }: CalendarLocation): PlainDateInterval {
const anchor = Temporal.PlainDate.from(date);
switch (view) {
case "day":
return timeGridRange(anchor, { count: 1, kind: "days" }, weekRules);
case "week":
return timeGridRange(anchor, { kind: "week" }, weekRules);
case "month":
return getMonthRange(anchor, environment("UTC"));
}
}
/** The instants between those dates' starts of day in `timeZone`: the events to fetch. */
export function visibleRange(location: CalendarLocation, timeZone: string): InstantInterval {
return resolveViewInstantRange(visibleDates(location), timeZone);
}
export function environment(timeZone: string): CalendarEnvironmentBase {
return { direction: "ltr", disambiguation: "compatible", locale, timeZone, weekRules };
}visibleRange turns the dates into instants in the visitor's time zone, as Root does for
onVisibleRangeChange. A week in Tokyo starts nine hours before a week in UTC.
The calendar
The calendar is defined in a client module. Meetings have a time, so a policy refuses drops on the all-day row before they reach the server:
import {
createCalendar,
defineEventAdapter,
minimumDurationPolicy,
snapPolicy,
type CalendarEventChange,
type CalendarPolicy,
} from "@moltenship/calendar";
import type { CalendarEventColor } from "@/components/calendar/event-colors";
import { createMonthView } from "@/components/calendar/month-view/month-view";
import { createDayView, createWeekView } from "@/components/calendar/time-grid-view/time-grid-view";
export interface Meeting {
readonly color: CalendarEventColor;
readonly end: Temporal.Instant;
readonly id: string;
/** Created in this tab and not saved yet, so it has no ID from the server. */
readonly pending?: true;
readonly start: Temporal.Instant;
readonly title: string;
}
export type MeetingChange = CalendarEventChange<Meeting, string, never>;
const getEventColor = (meeting: Meeting) => meeting.color;
export const Calendar = createCalendar<Meeting>()({
eventAdapter: defineEventAdapter<Meeting>()({
getId: (meeting) => meeting.id,
getInterval: ({ end, start }) => ({ end, kind: "timed", start }),
getResourceIds: () => [],
getTitle: (meeting) => meeting.title,
// The server knows a new meeting by the ID it gives it, so it can't move until then.
isReadOnly: (meeting) => meeting.pending === true,
}),
views: [
createWeekView<Meeting>({ dayView: "day", getEventColor }),
createDayView<Meeting>({ getEventColor }),
createMonthView<Meeting>({ getEventColor }),
],
});
// Meetings always have a time, so drops on the all-day row stop here, before the server.
const timedOnly: CalendarPolicy<Meeting, string, never> = ({ proposed }) =>
proposed === null || proposed.kind === "timed"
? { type: "accept" }
: { reason: "meetings-need-a-time", type: "reject" };
export const policies = [
timedOnly,
snapPolicy<Meeting, string, never>({ minutes: 15 }),
minimumDurationPolicy<Meeting, string, never>({ minutes: 15 }),
];The Client Component wires it to the URL, the Server Actions and the time zone cookie:
const viewOptions = [
{ label: "Week", value: "week" },
{ label: "Day", value: "day" },
{ label: "Month", value: "month" },
] as const;
export interface TeamCalendarProps {
readonly location: CalendarLocation;
readonly meetings: readonly MeetingJson[];
/** When the server rendered the page, so both sides agree on today. */
readonly now: string;
readonly timeZone: string;
/** `false` on a first visit, when the time zone is a guess. */
readonly timeZoneFromCookie: boolean;
}
const subscribeToNothing = () => () => undefined;
/** `false` while the page hydrates, `true` from then on. */
function useHydrated() {
return useSyncExternalStore(
subscribeToNothing,
() => true,
() => false,
);
}
export function TeamCalendar(props: TeamCalendarProps) {
const { loading, location, navigate } = useCalendarLocation(props.location);
const { meetings, message, save } = useMeetings(props.meetings);
const [timeZone, setTimeZone] = useTimeZone(props.timeZone, props.timeZoneFromCookie);
// The server's clock while hydrating, so both sides render the same today. The browser's clock
// after that, so the now line keeps moving between server renders.
const serverNow = useHydrated() ? {} : { now: Temporal.Instant.from(props.now) };
return (
<Calendar.Root
{...serverNow}
anchorDate={Temporal.PlainDate.from(location.date)}
events={meetings}
locale={locale}
onAnchorDateChange={(date) => navigate({ date: date.toString() })}
onEventChange={save}
onViewChange={(view) => navigate({ view })}
policies={policies}
timeZone={timeZone}
view={location.view}
weekRules={weekRules}
>
<Calendar.Frame className="flex h-full flex-col">
<CalendarToolbar calendar={Calendar} className="border-b">
<ViewSwitcher
onValueChange={(view) => navigate({ view })}
options={viewOptions}
value={location.view}
/>
</CalendarToolbar>
{/* Stays on screen while the next range loads; only this bar says so. */}
<div
aria-hidden="true"
className={`h-0.5 shrink-0 bg-brand transition-opacity ${loading ? "opacity-100 motion-safe:animate-pulse" : "opacity-0"}`}
/>
<Calendar.Viewport aria-busy={loading} className="min-h-0 flex-1">
<Calendar.ActiveView />
</Calendar.Viewport>
<footer className="flex min-h-12 shrink-0 flex-wrap items-center gap-x-4 gap-y-2 border-t px-4 py-2 text-sm">
<TimeZonePicker onChange={setTimeZone} value={timeZone} />
<p className="text-fd-muted-foreground" role="status">
{message}
</p>
</footer>
<Calendar.Announcer messages={calendarAnnouncements} />
</Calendar.Frame>
</Calendar.Root>
);
}view and anchorDate are controlled, so the URL decides what the calendar shows. timeZone is
controlled too, and comes from the cookie. useHydrated is false only while the page hydrates, so
now is the server's for that first render and the calendar's own clock after it.
Navigation
When someone moves to another week or view, the calendar calls onAnchorDateChange or
onViewChange. The component pushes a new URL instead of keeping the change in state:
/** The view and date from the URL. Changing them pushes a new URL; the calendar moves at once. */
function useCalendarLocation(location: CalendarLocation) {
const router = useRouter();
const pathname = usePathname();
const [loading, startNavigation] = useTransition();
// Shown until the new page has rendered on the server, so the grid moves on the click.
const [shown, show] = useOptimistic(location);
const queued = useRef<CalendarLocation | null>(null);
function navigate(change: Partial<CalendarLocation>) {
const first = queued.current === null;
queued.current = { ...(queued.current ?? shown), ...change };
if (!first) return;
// Opening a day from the month view changes the date and the view in one click. Wait for
// both, so they land in one URL and one history entry.
queueMicrotask(() => {
const next = queued.current ?? shown;
queued.current = null;
if (next.date === shown.date && next.view === shown.view) return;
startNavigation(() => {
show(next);
router.push(`${pathname}?${locationSearch(next)}`, { scroll: false });
});
});
}
return { loading, location: shown, navigate };
}- The grid moves on the click.
router.pushruns inside a transition, anduseOptimisticshows the new view and date at once. Their meetings arrive with the server's render. Until then the grid shows the meetings it already has that fall in the new range: all of them when zooming from a month into a week, none when stepping to the next week. - The page says it's loading. The transition's
loadingflag setsaria-busyon the viewport and shows a thin bar under the toolbar. The grid itself never blanks. - One click, one URL. Opening a day from the month view changes the view and the date. The changes queue for one microtask, so they land in a single URL and a single history entry.
- Back and Forward restore that URL's render from the Next.js router's cache, or fetch it again when it isn't cached, as after a save or a zone change. The controlled props follow.
The calendar stays mounted through all of this. Next.js keeps the page on screen while it fetches
the next render, and the props change in place, so the scroll position and the focus survive.
loading.tsx only shows when the route first loads, before there's a page to keep:
// The first visit's fallback. Later navigations keep the calendar on screen instead.
export default function Loading() {
return (
<div className="flex h-full flex-col" role="status">
<span className="sr-only">Loading calendar</span>
<div className="flex h-14 shrink-0 items-center gap-3 border-b px-4">
<span className="h-8 w-20 rounded-full bg-fd-muted" />
<span className="h-5 w-40 rounded-md bg-fd-muted" />
</div>
<div className="flex-1 bg-[linear-gradient(to_bottom,var(--color-fd-border)_1px,transparent_1px)] bg-size-[100%_3rem]" />
</div>
);
}Saving
The calendar proposes; the app commits. Every move, resize and create runs through the calendar's
policies first, then reaches onEventChange, which calls a Server Action:
export type SaveResult =
| { readonly ok: true }
| { readonly ok: false; readonly reason: "invalid" | "in-the-past" | "not-found" | "full" };
interface Times {
readonly end: string;
readonly start: string;
}
// Server Actions are public endpoints: check every argument, whatever the calendar sent.
function parseTimes(input: unknown) {
const { end, start } = (input ?? {}) as Partial<Record<keyof Times, unknown>>;
if (typeof start !== "string" || typeof end !== "string") return undefined;
try {
const times = { end: Temporal.Instant.from(end), start: Temporal.Instant.from(start) };
const minutes = times.start.until(times.end).total("minutes");
return minutes >= 15 && minutes <= 24 * 60 ? times : undefined;
} catch {
return undefined;
}
}
/** The rule this server adds on top of the calendar's policies. */
function startsInThePast(times: { readonly start: Temporal.Instant }) {
return Temporal.Instant.compare(times.start, Temporal.Now.instant()) < 0;
}
export async function createMeeting(input: Times): Promise<SaveResult> {
const times = parseTimes(input);
if (times === undefined) return { ok: false, reason: "invalid" };
if (startsInThePast(times)) return { ok: false, reason: "in-the-past" };
const session = await startSession(await getVisitor());
if (insertMeeting(session, times) === undefined) return { ok: false, reason: "full" };
// Re-render the page in this response, so the new meeting arrives with its ID.
refresh();
return { ok: true };
}
/** Moves and both resizes send the meeting's new start and end. */
export async function moveMeeting(input: Times & { readonly id: string }): Promise<SaveResult> {
const times = parseTimes(input);
const id: unknown = input?.id;
if (times === undefined || typeof id !== "string") return { ok: false, reason: "invalid" };
if (startsInThePast(times)) return { ok: false, reason: "in-the-past" };
const session = await startSession(await getVisitor());
if (!updateMeeting(session, id, times)) return { ok: false, reason: "not-found" };
refresh();
return { ok: true };
}- Check every argument. A Server Action is a public endpoint, and anyone can call it with anything. The calendar's snap and minimum duration don't protect the server.
- The server has the last word. This one refuses meetings that start in the past, a rule the calendar doesn't know.
refresh()re-renders the page in the same response as the action's result, so the saved meeting arrives without another request. If your reads are cached with"use cache", callupdateTaginstead.- Saves run one at a time. Next.js sends a page's Server Actions in order, so two quick moves of one meeting are saved in the order they were made.
On the client, save turns the action's result into the promise the calendar waits for:
function fromJson(meeting: MeetingJson): Meeting {
return {
...meeting,
end: Temporal.Instant.from(meeting.end),
start: Temporal.Instant.from(meeting.start),
};
}
function addMeeting(meetings: readonly Meeting[], meeting: Meeting): readonly Meeting[] {
return [...meetings, meeting];
}
function send(change: MeetingChange): Promise<SaveResult> {
if (change.proposed?.kind !== "timed") return Promise.resolve({ ok: false, reason: "invalid" });
const times = { end: change.proposed.end.toString(), start: change.proposed.start.toString() };
return change.cause === "create"
? createMeeting(times)
: moveMeeting({ ...times, id: change.eventId });
}
const refusals: Record<Exclude<SaveResult, { ok: true }>["reason"], string> = {
full: "Not saved: this example keeps 200 meetings at most.",
"in-the-past": "Not saved: the server refuses meetings that start in the past.",
invalid: "Not saved: the server couldn't read the change.",
"not-found": "Not saved: the meeting is gone. Reload the page.",
};
/** The server's meetings, plus new ones still saving. */
function useMeetings(json: readonly MeetingJson[]) {
const saved = useMemo(() => json.map(fromJson), [json]);
// The calendar shows a moved or resized meeting in its new place while it saves. A new one has
// no ID until the server gives it one, so it's shown here until the page re-renders with it.
const [meetings, addPending] = useOptimistic(saved, addMeeting);
const [message, setMessage] = useState("");
// The calendar keeps a change pending until this promise settles, and undoes it on a rejection.
function save(change: MeetingChange): Promise<void> {
const { promise, reject, resolve } = Promise.withResolvers<void>();
// Cleared first, so the same refusal twice is announced twice.
setMessage("");
startTransition(async () => {
if (change.cause === "create" && change.proposed.kind === "timed") {
const { end, start } = change.proposed;
addPending({
color: "blue",
end,
id: change.id,
pending: true,
start,
title: "New meeting",
});
}
const result = await send(change).catch(() => null);
if (result?.ok === true) {
resolve();
} else {
setMessage(
result === null ? "Not saved: the server didn't answer." : refusals[result.reason],
);
reject(new Error(result?.reason ?? "network"));
}
});
return promise;
}
return { meetings, message, save };
}- Moves and resizes stay where they were dropped while the promise is pending. The calendar shows them as saving, then keeps them in place until the page's next render has the saved times.
- A refusal rejects the promise, and the calendar moves the meeting back. The reason shows under the calendar.
- New meetings have no ID until the server gives them one, so the calendar can't show them as
pending.
useOptimisticadds them to the meetings on screen until the page renders again with the saved one. They're markedpending, and the adapter'sisReadOnlykeeps them still until then.
The time zone
The page renders in the zone from the time-zone cookie. On a first visit there's no cookie, so the
server renders in UTC, and the browser sends its own zone once it has hydrated:
const timeZones = [
"America/Los_Angeles",
"America/New_York",
"Europe/London",
"Europe/Berlin",
"Asia/Kolkata",
"Asia/Tokyo",
"Australia/Sydney",
"UTC",
];
/** The cookie's time zone. A first visit has none: store the browser's, and render in it. */
function useTimeZone(timeZone: string, fromCookie: boolean) {
const [shown, show] = useOptimistic(timeZone);
// The calendar switches at once, and the page renders again in the new zone when the cookie is
// set. If saving fails, it goes back to the zone the server rendered in.
const change = useCallback(
(next: string) => {
startTransition(async () => {
show(next);
await saveTimeZone(next).catch(() => undefined);
});
},
[show],
);
useEffect(() => {
if (!fromCookie) change(Temporal.Now.timeZoneId());
}, [change, fromCookie]);
return [shown, change] as const;
}
function TimeZonePicker({
onChange,
value,
}: {
readonly onChange: (timeZone: string) => void;
readonly value: string;
}) {
const options = (timeZones.includes(value) ? timeZones : [value, ...timeZones]).map((zone) => ({
label: zone.replaceAll("_", " "),
value: zone,
}));
return (
<Select items={options} onValueChange={(zone) => zone !== null && onChange(zone)} value={value}>
<SelectTrigger aria-label="Time zone" className="w-48 rounded-full" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);
}useOptimistic switches the calendar to the new zone at once. Setting a cookie in a Server Action
re-renders the page in the same response, so the meetings for the new zone's range arrive with the
action's result. Every later request renders in the right zone from the start, and hydration
matches. The picker under the calendar calls the same action.
Switching zones keeps each meeting at its instant, so a 09:30 standup in Tokyo shows in the small hours in Berlin. A first visit has no zone to keep, so its sample meetings simply follow the browser's.
export async function saveTimeZone(input: string): Promise<void> {
const timeZone = timeZoneOf(input);
if (timeZone === undefined) return;
const visitor = await getVisitor();
// Switching zones keeps the meetings at the instants the visitor saw, so they move on the clock.
// A first visit has no zone to keep, and its sample meetings simply follow the new one.
if (visitor.timeZone !== undefined && visitor.timeZone !== timeZone) await startSession(visitor);
// Setting a cookie re-renders the page in this response, in the new zone.
await setTimeZoneCookie(timeZone);
}Temporal in the browser
The server runs on Node.js 26, which has Temporal. The calendar also renders while the page
hydrates, so Temporal has to exist in the browser before then. team-calendar.tsx imports
temporal-polyfill/global before anything else. The polyfill installs itself only in browsers
without Temporal, but as a static import every browser downloads it. The dynamic import that
Installation shows saves that, but only suits a calendar that renders
after hydration.
Make it yours
- Replace
meetings.tswith your database. Its functions take the visitor and a range, as queries would. - Use your auth in place of the session cookie, and check in every action that the visitor owns the meeting they're changing.
- Refuse stale writes. Give meetings a revision, send it with each change, and refuse a change
made to an older one. The adapter's
getRevisiontells the calendar about them. Sync with Google Calendar does the same with etags. - Cache the reads with
"use cache"andcacheTag, and callupdateTagfrom the actions.
Last updated on