A technical journey through creating a secure, whimsical journaling app that evolved from client-only storage to a full serverless architecture while maintaining zero-knowledge privacy
The Philosophy: Making Writing Feel Like Play
I've been a devoted user of 750words for years. There's something magical about that simple, focused interface that just gets out of your way and lets you write. But as someone who just graduated from Mount Royal University with a BA in English Honours, I found myself craving something more—a journaling experience that felt less like a daily obligation and more like a delightful ritual.
The idea for Journal for Me was born from a simple question: What if journaling felt as inviting as opening a favorite book?
I wanted to create something that embraced whimsical UX design—soft gradients, thoughtful typography (EB Garamond!), gentle animations, and an interface that feels more like a cozy reading nook than a sterile productivity tool. Every design decision was made with one goal: reduce friction and increase joy in the act of writing.
The early access badge in the footer, the elegant hero image with literary quotes, the smooth transitions between pages—these aren't just aesthetic choices. They're part of a philosophy that believes technology should feel human, approachable, and maybe even a little magical.
The Technical Journey: From Static Sites to Real Applications
My background has been primarily in JAMstack development—building static sites with generators like Jekyll and Hugo, deploying to Netlify, and calling it a day. It's a world where your biggest worry is whether your CSS animations are too bouncy.
Building Journal for Me was my first real foray into full-stack development. It's a completely different beast.
The Database Evolution: From Client-Only to Serverless
Coming from the static site world, the concept of persistent user data was exciting and terrifying. My initial approach was IndexedDB with client-side encryption:
// Phase 1: Client-only storage
const encryptedEntry: JournalEntry = {
...entry,
title: "this.encrypt(entry.title),"
content: this.encrypt(entry.content),
isEncrypted: this.encryptionKey !== null
}
This worked beautifully for single-device usage, but I quickly discovered a fundamental flaw: users expected their journals to sync across devices. The "it works perfectly on my laptop but disappears on my phone" problem became a Phase 1 MVP blocker.
The Great Migration: Railway to Netlify Serverless
What followed was an architectural evolution that taught me more about full-stack development than any tutorial could:
Phase 1: IndexedDB only (privacy ✅, sync ❌)
Phase 2: Express + Railway PostgreSQL (sync ✅, complexity 📈)
Phase 3: Netlify Functions + Neon PostgreSQL (sync ✅, serverless ✅, simplicity ✅)
The final architecture maintains zero-knowledge encryption while enabling cross-device sync:
// Netlify Function for encrypted entry storage
exports.handler = async (event, context) => {
const { neon } = require('@netlify/neon')
const sql = neon(process.env.NETLIFY_DATABASE_URL)
// Store encrypted data server-side
const result = await sql`
INSERT INTO entries (id, user_id, encrypted_title, encrypted_content, created_at)
VALUES (${id}, ${userId}, ${encryptedTitle}, ${encryptedContent}, ${createdAt})
`
}
The beauty of this approach:
- Serverless scaling (no server management)
- Zero-knowledge privacy (server stores encrypted blobs)
- Cross-device sync (entries available everywhere)
- Cost efficiency (pay-per-use functions)
Bugs!
The transition from "it works on my machine" static sites to "it needs to work for everyone, everywhere, all the time" applications is humbling. Some of my favourite bugs:
The Session Restoration Saga: The most challenging bug was session management across the client-server architecture. Users would log in successfully, then refresh the page and... poof! Gone. The issue was complex:
- Client-side: Session stored in localStorage
- Server-side: JWT tokens for API authentication
- Router: Navigation guards checking different session keys
The solution required unifying the session management:
// Unified session restoration
async restoreSession(): Promise<boolean> {
const sessionKey = localStorage.getItem('journal-session-key')
const userEmail = localStorage.getItem('journal-user-email')
if (sessionKey && userEmail) {
// Validate with server and restore local state
return await this.validateAndRestoreUser(userEmail, sessionKey)
}
return false
}
The Navigation Nightmare: After migrating to serverless architecture, I discovered that hard redirects (window.location.href
) were breaking the single-page application flow. Users would click "Get Started" and land on a blank page. The fix required eliminating all hard redirects in favor of proper router navigation:
// Before: Hard redirect (breaks SPA)
window.location.href = '/dashboard'
// After: Router navigation (maintains state)
this.router.navigateTo('/dashboard')
A Calendar That Forgot Time: Building a calendar component sounds simple until you realize that JavaScript's Date object has opinions about timezones, and those opinions don't always align with what users expect to see. The number of edge cases around "show me entries from this day" is enuinely impressive.
Security: The Weight of Other People's Words
Is trust taught in computer science? The moment someone trusts you with their personal thoughts, the responsibility becomes real in a way that's hard to describe.
Journal entries are data, but they're also someone's private thoughts, fears, dreams, and daily struggles. The security architecture had to reflect that gravity.
Zero-Knowledge Architecture: Privacy Through Encryption
The core principle remained constant through all architectural changes: I should never be able to read user entries, even if I wanted to. The serverless migration actually strengthened this:
// Client-side encryption (unchanged)
private deriveEncryptionKey(password: string): string {
return CryptoJS.PBKDF2(password, 'journalfor.me-salt', {
keySize: 256/32,
iterations: 10000
}).toString()
}
// Server stores encrypted blobs
const encryptedEntry = {
id: generateUUID(),
user_id: userId,
encrypted_title: "this.encrypt(entry.title),"
encrypted_content: this.encrypt(entry.content),
created_at: new Date().toISOString()
}
Now entries are encrypted client-side with AES-256, then stored as encrypted blobs in Neon PostgreSQL. The server never sees plaintext content—it's just a secure, encrypted storage layer that enables cross-device sync.
Hybrid Authentication: Client Encryption + Server Sessions
The serverless architecture enabled proper user authentication while maintaining zero-knowledge principles:
- Server Registration: Create user account with bcrypt-hashed passwords
- JWT Authentication: Server validates credentials and issues tokens
- Client Encryption: Derive encryption keys from user passwords (server never sees these)
- Session Sync: Maintain sessions across devices while keeping encryption keys local
// Netlify Function: User authentication
const bcrypt = require('bcryptjs')
const jwt = require('jsonwebtoken')
const isValidPassword = await bcrypt.compare(password, user.password_hash)
if (isValidPassword) {
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET)
return { success: true, token, user: { id: user.id, email: user.email } }
}
This hybrid approach gives users proper account management while ensuring their journal content remains encrypted and private.
Testing: Saving My Sanity One Test at a Time
After the fifteenth time manually testing the "register → login → create entry → view entry → edit entry" flow, I realized I needed to automate this madness.
Building a comprehensive test suite became essential not just for catching bugs, but for maintaining my sanity during development. The automated test runner now covers:
Authentication & Security Tests
// Test credential validation with specific scenarios
await this.runTest(suite, 'Credential Validation', async () => {
const validResult = await appStore.storage.validateUserCredentials('test@example.com', 'correctpass')
const invalidResult = await appStore.storage.validateUserCredentials('test@example.com', 'wrongpass')
// Verify both scenarios work as expected
})
Component Integration Tests
Every major component gets tested for both functionality and UI presence. The search component needs its search button, the calendar needs navigation controls, the settings page needs all its form elements. Tests catch the "oops, I renamed that ID and broke everything" bugs before users do.
Markdown Rendering Tests
Since the app supports rich markdown formatting, I built tests that verify headers render correctly, lists format properly, and links actually link. There's something satisfying about watching automated tests confirm that **bold text**
actually renders as bold text.
Testing Through Architectural Changes
The test suite became crucial during the serverless migration. What started as 44 tests with 93.2% success evolved into comprehensive coverage of both client and server functionality:
- Frontend Tests: Component integration, encryption/decryption, routing
- Backend Tests: API endpoints, database operations, authentication flows
- End-to-End Tests: Complete user journeys from registration to cross-device sync
The automated testing caught numerous issues during migration, from session management bugs to API endpoint failures. Without comprehensive tests, the architectural changes would have been far more risky.
The Road Ahead: Accessibility and Beyond
Building Journal for Me has been incredibly rewarding, but it's also highlighted how much work goes into making software truly accessible to everyone.
My next major focus is accessibility. The app currently meets basic standards, but I want to go further:
- Screen Reader Optimization: Ensuring every interactive element has proper ARIA labels and semantic markup
- Keyboard Navigation: Full keyboard accessibility for users who can't or prefer not to use a mouse
- High Contrast Mode: Better support for users with visual impairments
- Reduced Motion Options: Respecting users' motion sensitivity preferences
- Font Size Scaling: Better support for users who need larger text
Accessibility isn't just about compliance—it's about ensuring that the joy of journaling is available to everyone, regardless of how they interact with technology.
Technical Stack & Architecture
For the technically curious, here's what powers Journal for Me:
Frontend: Vanilla TypeScript with Vite for blazing-fast development and builds
Styling: Tailwind CSS for rapid, consistent design
Client Storage: IndexedDB with the idb
library for offline-first functionality
Server Functions: Netlify Functions (Node.js) for authentication and sync
Database: Neon PostgreSQL (serverless) for encrypted data persistence
Encryption: CryptoJS for AES-256 encryption and PBKDF2 key derivation
Authentication: JWT tokens with bcrypt password hashing
PWA Features: Service Worker with Workbox for offline functionality
Typography: EB Garamond for that literary feel, Inter for UI elements
Testing: Comprehensive test suite covering frontend and backend
Deployment: Netlify with optimized build pipeline and security headers
The app is now a hybrid architecture: client-side encryption with serverless backend sync, providing both privacy and cross-device functionality.
Performance & Security Highlights
- Serverless architecture (auto-scaling Netlify Functions + Neon PostgreSQL)
- Zero-knowledge privacy (client-side encryption, server stores encrypted blobs)
- Cross-device sync (access your journal from any device)
- Offline-first (write without internet, sync when connected)
- PWA capabilities (install on your device like a native app)
- Comprehensive testing (frontend + backend test coverage)
- Security hardened (JWT auth, bcrypt hashing, CSP headers)
- Production ready (live at journalforme.netlify.app)
A Call to Adventure
Journal for Me is now in early access, and I'm genuinely excited to see how people use it. But here's the thing about building software: you can test it a thousand times, and users will still find that one edge case you never considered.
If you're willing to take Journal for Me for a spin, I'd be incredibly grateful. And if you encounter a bug, have a suggestion, or just want to share your experience, please open an issue on GitHub or reach out to me directly.
Every bug report makes the app better. Every suggestion helps me understand how people actually want to use it. Every user who takes a chance on an indie project like this makes the independent web a little more vibrant.
The Bigger Picture
Building Journal for Me taught me that the gap between "I can build websites" and "I can build applications" is vast and filled with humbling lessons. But it also reinforced my belief that the web is an incredible platform for creating tools that genuinely help people.
In a world of surveillance capitalism and data harvesting, there's something rebellious about building a journaling app that prioritizes user privacy above all else. In a world of complex, overwhelming interfaces, there's something necessary about creating tools that feel approachable and human.
Journal for Me isn't just a technical project—it's a small statement about what software can be when it's built with care, privacy, and user joy as the primary concerns.
Try Journal for Me: journalforme.netlify.app
Source Code: GitHub Repository
Report Issues: GitHub Issues
Thank you for reading, and thank you for considering giving Journal for Me a try. Happy writing! ✨
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.