Building a Calendar Interface in Astro
I wanted my website to have a clean, static calendar interface to showcase all my RSVP posts. At first, I considered using FullCalendar, an excellent package, but a bit too heavy for my needs. I wanted something that rendered at build time, without extra client-side scripting. Building a lightweight calendar in Astro with date-fns
was surprisingly straightforward.

Screenshot of the calendar interface for this website.
The Code
I am using date-fns to do a lot of the heavy lifting here.
Get the Static Paths
The getStaticPaths
generates multiple page routes from a single .astro
page component.
import { getCollection } from "astro:content";
import dateFns from "date-fns";
export const getStaticPaths = () => {
// The events collection is pretty generic, it would look something like
// this:
//
// const eventsCollection = defineCollection({
// loader: glob({ pattern: "*.md", base: "./src/content/events" }),
// schema: z.object({
// name: z.string(),
// startsAt: z.date(),
// endsAt: z.date().optional(),
// isAllDay: z.boolean(),
// }),
// })
const allEvents = getCollection("events");
// We want to get the upper and lower bound of the calendar pages without it
// we would have to have every possible month.
const eventDates = allEvents
.flatMap((post) => [post.data.startsAt, post.data.endsAt])
.filter(Boolean) as Date[];
const upperBound = dateFns.endOfMonth(dateFns.max(eventDates));
const lowerBound = dateFns.startOfMonth(dateFns.min(eventDates));
// We want to capture all the years and their months between the upper and
// lower bounds.
const yearsBetween = dateFns
.eachYearOfInterval({
start: lowerBound,
end: upperBound,
})
.map((yearStart) => {
const yearEnd = dateFns.endOfYear(yearStart);
const months = dateFns.eachMonthOfInterval({
start: dateFns.isBefore(yearStart, lowerBound) ? lowerBound : yearStart,
end: dateFns.isAfter(yearEnd, upperBound) ? upperBound : yearEnd,
});
return { months };
});
// Finally we can build all the pages that will be generated.
return yearsBetween.flatMap(({ months }) =>
months.map((monthStart) => {
const monthEnd = dateFns.endOfMonth(monthStart);
// Adjust the start and end to the nearest Monday and Sunday for a full
// week of coverage, this does mean the week will start on a Monday and
// end on a Sunday.
const calendarMonthStart = dateFns.isMonday(monthStart)
? monthStart
: dateFns.previousMonday(monthStart);
const calendarMonthEnd = dateFns.isSunday(monthEnd)
? monthEnd
: dateFns.nextSunday(monthEnd);
const days = dateFns
.eachDayOfInterval({
start: calendarMonthStart,
end: calendarMonthEnd,
})
.map((day) => ({
day,
events: allEvents.filter((event) =>
dateFns.isSameDay(event.data.startsAt, day),
),
}));
return {
params: {
// You'll want to change this to how your filename is, mine is
// `[...yyyymmm].astro`.
yyyymm: dateFns.format(monthStart, "yyyy-MM"),
},
props: {
start: calendarMonthStart,
end: calendarMonthEnd,
days,
},
};
}),
);
};
Build the Interface
The above builds three props: start
(first day of the calendar), end
(last day), and days
(an array of days and their events). I use Tailwind CSS for styling this website, I’ll be using their CSS classes.
{
/**
* The border attribute doesn't really work with grids, so to get this to
* work, we are going to add a slight gap between the grid's cells and
* subtract the margin a bit on the right. This means we'll have a
* tic-tac-toe border kind of like this:
*
* MON | TUE | WED | THU | FRI | SAT | SUN
* -----------------------------------------
* 29 | 31 | 1 | 2 | 3 | 4 | 5
* ...
* 27 | 28 | 29 | 30 | 31 | 1 | 2
*/
}
<div class:list={["-mr-0.5 grid grid-cols-7 gap-0.5 bg-black"]}>
{
["mon", "tue", "wed", "thu", "fri", "sat", "sun"].map((weekDay) => (
<h2
class:list={[...className, "bg-white text-right font-bold uppercase"]}
>
{weekDay}
</h2>
))
}
{
days.map(({ day, events }) => (
<div class:list={["h-32 bg-white"]}>
<h3 class:list={["px-1 py-0.5 text-right font-semibold"]}>
{dateFns.format(day, "d")}
</h3>
<ul class:list={["space-y-0.5 overflow-y-hidden text-sm"]}>
{events.map((event) => (
<li>
<a
href={`/events/${event.id}`}
class:list={[
"flex justify-between rounded-sm px-1 py-0.5",
"hover:bg-black/5",
]}
>
<span class:list={["block flex-shrink truncate"]}>
{event.data.name}
</span>
{!event.data.isAllDay && (
<span class:list={["block flex-shrink-0 text-black/75"]}>
{dateFns.format(event.data.startsAt, "h:mm a")}
</span>
)}
</a>
</li>
))}
</ul>
</div>
))
}
</div>
Wrap Up
Now my website has a calendar interface to my RSVP posts.