Navigation Menu

Axelot

A modern, fast, and secure collaborative document/story platform

react
nextjs
firebase
realtime
yjs
Word Count: 655
Published Date:
Reading Time: 2m

Overview

I started developing Axelot because I wanted a collaborative document/story platform that actually feels modern, fast, and secure. I was initially inspired by how https://gist.github.com/ works. Most existing tools either lack real-time editing features, have clunky authentication, or don’t scale well to edge runtimes.

The goal was to combine Next.js, Firebase, and Yjs to build something that s both powerful and future-proof. Many features in this project were ported from Kanjou and adjusted to be compatible with Material UI (MUI).

Authorization

Authentication begins with Auth.js (NextAuth-style flows) to support OIDC and social providers. After sign-in we mint a Firebase custom token server-side and hand it to the client. The client exchanges the custom token for a Firebase SDK session and uses the UID provided by that token for Firestore access.

All Firestore rules reference request.auth.uid so reads/writes are validated at the database layer. Session handling is stateless and compatible with Edge runtimes (no in-memory caches or long-running processes required).

Collaboration in Real-Time

Yjs handles CRDT state for documents and presence (awareness). WebRTC is used for peer-to-peer replication between collaborators, while Firestore is used for signaling (offer/answer exchange). All updates are encrypted on the client and merged using Yjs, so conflicts are deterministic and predictable.

We use y-protocols/awareness to sync cursors and presence metadata (name, color, current selection). The system falls back to Firestore realtime updates when WebRTC peers cannot be established.

Server-rendered non-user-specific sections

A practical example of when server rendering helps is the project dashboard. Some sections (for example, a list of recently published stories) are global and do not depend on the currently-authenticated user. Rendering these parts on the server avoids an unnecessary client round-trip and reduces perceived load time.

Server-rendered approach (before / after):

app/dashboard/page.tsx
"use client" import React from 'react' export default function Page() { // Client-side fetch during mount — blocks visible content until network completes const [recentlyAdded, setRecentlyAdded] = React.useState(null) React.useEffect(() => { // Client-side fetch firebase.firestore() .collection('documents') .orderBy('createdAt', 'desc') .limit(10) .get() .then( snap => setRecentlyAdded( snap.docs.map(d => ({ id: d.id, ...(d.data() as any) })) ) ) }, []) // Fetch global dashboard data with server-side rendering const snap = await firebaseAdmin .firestore() .collection('documents') .orderBy('createdAt', 'desc') .limit(10) .get() const recentlyAdded = snap.docs.map(d => ({ id: d.id, ...(d.data() as any) })) return <DashboardShell recentlyAdded={recentlyAdded} /> }

The DashboardShell can render the recentlyAdded list directly and mount lightweight client-only widgets for personalized sections (notifications, user drafts, presence) after the shell is visible.

Why this helps:

  • The server-rendered recentlyAdded list is cached at the edge, so all clients receive HTML immediately and avoid an extra client fetch.
  • Personalized widgets remain client-only, so sensitive data or session-bound requests are still retrieved securely on the client.

Multi-Layer Caching Strategy

Public API endpoints like /api/stories/discover implement a three-tier caching approach to minimize Firestore reads and improve response times:

  1. Client-side: Browser caches responses via Cache-Control headers (10 min for trending, 2 min for fresh content)
  2. Server-side: Next.js unstable_cache memoizes Firestore queries with tag-based invalidation
  3. Edge/CDN: Responses cached at edge nodes with stale-while-revalidate for instant delivery
Implementation:
app/api/stories/discover/route.ts
const getCachedStories = unstable_cache( async (mode: string, page: number, pageSize: number) => { let query = firebaseAdminFirestore .collection("stories") .where("isPublic", "==", true) .where("isArchived", "==", false) if (mode === "fresh") query = query.orderBy("created", "desc") else if (mode === "foryou") query = query.orderBy("trendingScore", "desc") else query = query.orderBy("lastUpdated", "desc") return (await query.limit(pageSize).offset(offset).get()).docs.map(/* ... */) }, ["discover-stories"], { tags: ["discover-stories"] } ) // Cache duration varies by mode: trending (10min), fresh (2min) return NextResponse.json(result, { headers: { "Cache-Control": `s-maxage=${revalidate}, stale-while-revalidate` } })

Impact: ~95% reduction in Firestore reads during peak traffic, sub-50ms response times for cached hits vs 200-500ms cold queries.