How to Add a Calendar to a Remix App (Without SSR Breaking Everything)
By SimpleCalendarJS Team
You need to add a calendar to your Remix app. You search for options and immediately hit a wall: every calendar library that handles events, week views, and day grids crashes on the server with window is not defined. The date pickers work fine — but they can't show scheduled events on a time grid. Here's how to add a real event calendar to Remix without fighting its server-rendering model.
Why calendars break in Remix
Remix renders every route on the server before sending HTML to the browser. This is what makes Remix fast — users see content before JavaScript loads. But calendar libraries need the DOM: they measure container widths, attach scroll listeners, and reference window and document to position events on a time grid. None of that exists in Node.js.
Drop a calendar import into a Remix route and you get one of three outcomes:
ReferenceError: window is not defined— the library accesseswindowat import timeReferenceError: document is not defined— the library tries to create DOM elements during module initialisation- Hydration mismatch — the server HTML doesn't match what React produces on the client
This is a well-documented pain point in the Remix community. The solution isn't to disable SSR globally — it's to isolate the calendar to the client using one of Remix's built-in escape hatches.
Client-only patterns in Remix
Remix gives you three ways to keep code off the server. Each has trade-offs:
1. The useEffect guard (simplest)
import { useEffect, useRef, useState } from "react"; export default function CalendarPage() { const [mounted, setMounted] = useState(false); const calendarRef = useRef(null); useEffect(() => { setMounted(true); }, []); if (!mounted) return <div style={{ height: 600 }}>Loading calendar...</div>; return <CalendarComponent containerRef={calendarRef} />; }
useEffect never runs on the server. By gating the calendar behind a mounted flag, the server sends a placeholder <div> and the calendar renders only after hydration. This works for any library and requires zero extra packages.
2. The .client.tsx convention
Rename a file to Calendar.client.tsx and Remix strips it from the server bundle entirely. Exports become undefined on the server:
// components/Calendar.client.tsx export { default as Calendar } from "some-calendar-lib";
The catch: you must still guard against rendering undefined on the server. This convention is best paired with a useEffect check or a ClientOnly wrapper. With Remix Vite, you can also use .client/ directories.
3. ClientOnly from remix-utils
The remix-utils package provides a ClientOnly component that renders its children only after hydration:
import { ClientOnly } from "remix-utils/client-only"; export default function CalendarPage() { return ( <ClientOnly fallback={<div style={{ height: 600 }}>Loading...</div>}> {() => <CalendarComponent />} </ClientOnly> ); }
Clean and declarative — but it adds a dependency. For a single calendar component, the useEffect guard is equivalent and dependency-free.
Option 1: react-calendar (date picker only)
If you only need date selection — a form input, not an event grid — react-calendar is the simplest path:
npm install react-calendar
import { useState } from "react"; import Calendar from "react-calendar"; import "react-calendar/dist/Calendar.css"; export default function DatePicker() { const [date, setDate] = useState(new Date()); return <Calendar onChange={setDate} value={date} />; }
This renders a month grid where users pick a date. It cannot display events on a week grid, show time slots, or handle event click interactions. If you need any of those, you need an event calendar.
Option 2: FullCalendar in Remix
FullCalendar is the most popular JavaScript event calendar with roughly 680,000 weekly npm downloads across its packages. In Remix, the setup requires careful SSR handling:
npm install @fullcalendar/core @fullcalendar/react @fullcalendar/daygrid
// components/EventCalendar.client.tsx import FullCalendar from "@fullcalendar/react"; import dayGridPlugin from "@fullcalendar/daygrid"; export default function EventCalendar() { return ( <FullCalendar plugins={[dayGridPlugin]} initialView="dayGridMonth" events={[{ title: "Team Standup", date: "2026-09-15" }]} /> ); }
// routes/calendar.tsx import { ClientOnly } from "remix-utils/client-only"; import { lazy } from "react"; const EventCalendar = lazy(() => import("~/components/EventCalendar.client")); export default function CalendarRoute() { return ( <ClientOnly fallback={<div style={{ height: 600 }}>Loading calendar...</div>}> {() => <EventCalendar />} </ClientOnly> ); }
Note the trade-offs:
- Three npm packages minimum before a single event renders — plus
remix-utilsforClientOnly - The
.client.tsxfile convention pluslazyimport plusClientOnlywrapper — three layers of SSR avoidance - Total bundle cost: ~43 KB gzipped, growing with each additional view plugin (timegrid, list, interaction)
- Advanced features like resource scheduling require a premium license starting at $480 per developer
Option 3: Vanilla JS with SimpleCalendarJS (recommended)
A vanilla JavaScript calendar sidesteps React's SSR entirely. It initialises inside useEffect, which only runs on the client. The server render produces an empty <div>. The client produces the same empty <div>, then useEffect fires and the calendar mounts. No hydration mismatch, no .client.tsx file, no ClientOnly wrapper needed.
Here's how to add a calendar to a Remix app using SimpleCalendarJS — ~14 KB gzipped, zero dependencies:
npm install simple-calendar-js
// components/EventCalendar.tsx import { useEffect, useRef } from "react"; export default function EventCalendar() { const containerRef = useRef(null); const calendarRef = useRef(null); useEffect(() => { let destroyed = false; import("simple-calendar-js").then(({ default: SimpleCalendarJs }) => { if (destroyed || !containerRef.current) return; import("simple-calendar-js/dist/simple-calendar-js.min.css"); calendarRef.current = new SimpleCalendarJs(containerRef.current, { defaultView: "month", locale: "en-US", enabledViews: ["month", "week", "day"], fetchEvents: async (start, end) => { const res = await fetch( `/api/events?from=${start.toISOString()}&to=${end.toISOString()}` ); return res.json(); }, onEventClick: (event) => console.log("Event clicked:", event), onSlotClick: (date) => console.log("Slot clicked:", date), }); }); return () => { destroyed = true; calendarRef.current?.destroy(); }; }, []); return <div ref={containerRef} />; }
// routes/calendar.tsx import EventCalendar from "~/components/EventCalendar"; export default function CalendarRoute() { return ( <div> <h1>Team Calendar</h1> <EventCalendar /> </div> ); }
That's it. One component file, one route file. The dynamic import() inside useEffect ensures the library never loads on the server — no .client.tsx rename, no ClientOnly wrapper, no extra packages. The destroyed flag prevents a race condition if the component unmounts before the import resolves.
What this gives you
- Month, week, and day views with a built-in toolbar
- Async event fetching —
fetchEventsfires with the visible date range on every navigation, loading only what's on screen - Click handlers —
onEventClickfor existing events,onSlotClickfor empty time slots - 34+ locales built in — pass
locale: 'pt-BR'orlocale: 'ja-JP' - Automatic cleanup —
destroy()in the useEffect return prevents memory leaks
Full-stack pattern with Remix loaders
Remix's loaders let you fetch data on the server and pass it to the route component. You can pre-fetch events in the loader and pass them as initial data, then let the calendar fetch subsequent pages client-side:
// routes/calendar.tsx import { json } from "@remix-run/node"; import type { LoaderFunctionArgs } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; import EventCalendar from "~/components/EventCalendar"; export async function loader({ request }: LoaderFunctionArgs) { const url = new URL(request.url); const month = url.searchParams.get("month") || new Date().toISOString(); const events = await fetch( `https://your-api.com/events?month=${month}` ).then((r) => r.json()); return json({ events }); } export default function CalendarRoute() { const { events } = useLoaderData<typeof loader>(); return ( <div> <h1>Team Calendar</h1> <EventCalendar initialEvents={events} /> </div> ); }
The loader runs on the server and returns serialised JSON. The calendar component receives initialEvents as a prop and uses them on first render — no loading spinner for the initial view. Subsequent navigations (clicking next month, switching to week view) trigger fetchEvents client-side.
Theming the calendar
SimpleCalendarJS uses CSS custom properties. Override them in your Remix app's global stylesheet or a route-scoped CSS file:
.uc-calendar { --cal-primary: #3992ff; --cal-primary-dark: #1a5ab8; --cal-today-bg: #eef6ff; --cal-font-size: 14px; }
Four lines and your calendar matches Remix's signature blue. For dark mode, scope the overrides to your dark mode selector:
.dark .uc-calendar { --cal-bg: #1a1a2e; --cal-text: #e4e4e7; --cal-border: #2d2d44; --cal-today-bg: #2d2d44; }
Bundle size comparison
Every kilobyte of client-side JavaScript in a Remix app affects Largest Contentful Paint (LCP) and Total Blocking Time (TBT) — both Core Web Vitals that impact search rankings.
| Setup | Gzipped Size | npm Packages | SSR Workaround |
|---|---|---|---|
| FullCalendar + React adapter | ~43 KB | 3+ | .client.tsx + ClientOnly + lazy import |
| react-big-calendar | ~30 KB | 2 (+ moment/date-fns) | ClientOnly wrapper |
| react-calendar (date picker only) | ~7 KB | 1 | Minimal — mostly SSR-safe |
| SimpleCalendarJS | ~14 KB | 1 | useEffect only — no wrapper needed |
SimpleCalendarJS hits the sweet spot: full event calendar features (month, week, day views, async fetching, click handlers) at a fraction of FullCalendar's bundle cost, with the simplest SSR integration pattern of any event calendar.
A note on Remix and React Router v7
Remix merged into React Router v7 in late 2024. React Router v7 in "framework mode" is the direct successor to Remix v2 — same server-rendering model, same loaders and actions, same file conventions. If you're starting a new project, you'll use React Router v7 (now progressing to v8). If you're maintaining an existing Remix v2 app, the patterns in this post work identically — the client-only strategies (useEffect guards, .client.tsx convention, ClientOnly wrapper) are unchanged.
When to use a React-specific calendar instead
There are valid reasons to choose a React-native calendar in Remix:
- Date selection only: If you need a date input for a form, react-calendar or react-day-picker are SSR-safe, lightweight, and purpose-built for that use case.
- Drag-and-drop rescheduling: FullCalendar's interaction plugin has mature drag-and-drop support for moving and resizing events on the grid.
- Deep React integration: If your calendar must be fully controlled via React state and props with tight integration into your component tree, a React-native library handles this natively.
For the majority of Remix apps that need to display events on a calendar and let users interact with them, a vanilla JS approach initialised in useEffect is simpler, lighter, and avoids the multi-layered SSR workarounds that React-based calendar libraries require.
Summary
- Remix renders every route on the server — calendar libraries that access
windowordocumentcrash withReferenceErrorunless isolated to the client - Three client-only patterns exist:
useEffecthydration guard (simplest),.client.tsxfile convention (build-level), andClientOnlyfrom remix-utils (declarative) - FullCalendar requires 3+ packages, a
.client.tsxfile, aClientOnlywrapper, and a lazy import — four layers of SSR avoidance - A vanilla JS calendar in
useEffectnever touches the server render — dynamicimport()insideuseEffectis all you need - SimpleCalendarJS ships month, week, and day event views with async fetching, click handlers, and 34+ locales in ~14 KB gzipped — the simplest SSR integration of any event calendar in Remix
- Pair it with Remix loaders to pre-fetch initial events on the server for instant first render
Sources & Further Reading
Research & References
- Disabling SSR for a Component — remix-run/remix GitHub Discussion #1023
- .client modules — Remix Documentation
- Server vs. Client Code Execution — Remix Documentation
- remix-utils ClientOnly component — sergiodxa/remix-utils GitHub
- FullCalendar SSR (server-side rendering) — fullcalendar/fullcalendar GitHub #4784
- How to fix ReferenceError: Window is Not Defined in Remix — devtools.tech
- React Router v7 announcement — remix.run
- React Router v7 Complete Guide 2026 — DEV Community
- Remix Best Practices & Engineering Tips 2026 — Softaims
- Use Suspense for Client-Only Components in Remix — Tom Sherman
Image Credits
- Cover: Coding on a Laptop — Pexels
All images free to use under the Pexels License.
Frequently Asked Questions
How do I add a calendar to a Remix app?
Install a calendar library via npm, create a component that initialises the calendar inside a useEffect hook (which only runs on the client), and use a hydration guard or the ClientOnly component from remix-utils to prevent the component from rendering during SSR. For vanilla JS libraries like SimpleCalendarJS, useEffect with a ref is all you need — one package, no adapter, no .client.tsx file.
Why does my calendar crash with 'window is not defined' in Remix?
Remix renders every route on the server by default. Calendar libraries depend on browser APIs like window, document, and DOM measurements that don't exist in Node.js. When the server tries to import or render a calendar component, it throws a ReferenceError. The fix is to ensure the calendar code only runs on the client — using useEffect, the .client.tsx file convention, or the ClientOnly wrapper from remix-utils.
What is the best calendar library for Remix?
It depends on what you need. react-calendar and react-day-picker are date pickers — they let users select dates but have no week or day event views. FullCalendar works but requires 3+ packages and SSR workarounds. SimpleCalendarJS (~14 KB, zero dependencies) provides month, week, and day event views with a simple useEffect initialisation pattern that avoids SSR issues entirely.
Is Remix the same as React Router v7?
Remix merged into React Router v7 in late 2024. React Router v7 in 'framework mode' is the direct successor to Remix v2, with the same server-rendering model, loaders, actions, and file conventions. The client-only patterns described here — useEffect guards, .client.tsx files, and ClientOnly wrappers — work identically in both Remix v2 and React Router v7 framework mode.
Does Remix have a built-in ClientOnly component?
No, Remix does not ship a built-in ClientOnly component. The most popular option is the ClientOnly component from the remix-utils package by Sergio Xalambrí. Alternatively, you can build your own with a useState/useEffect hydration guard — set a flag to false, flip it to true in useEffect, and conditionally render the calendar only when the flag is true.
Can I use a vanilla JavaScript calendar in Remix?
Yes. Since Remix uses React, you can use any vanilla JS library by creating a ref for the DOM container and initialising the calendar inside useEffect. The server render produces an empty div, useEffect fires on the client and mounts the calendar, and cleanup runs in the useEffect return function. No .client.tsx file or ClientOnly wrapper required.
Add a calendar to your app today
Free for personal projects. $49/year or $199 lifetime per commercial project.
