Beyond Java: How I Conquered Frontend Development to Connect Friends Through Storytelling
- Silviu Popescu
- Apr 2
- 12 min read
A Google software engineer's journey into unfamiliar territory to build a collaborative storytelling platform that brought friends together across continents.
The Chrome window stared back at me mockingly as I resized it for the twentieth time that hour. CSS had become my nemesis, and I —a backend engineer at Google who comfortably wrestled with Java and Kotlin daily—was being humbled by a few lines of styling code. Yet this frustration was the price of bringing my scattered friends together across three continents through a project that would push me far beyond my comfort zone.
The Spark of Inspiration

As a backend software engineer at Google, my comfort zone is built with Java, Kotlin, and SQL — the reliable, structured languages that power systems behind the scenes. But when the holiday season approached last year, I found myself thinking about my friends scattered across Stockholm, London, and as far as Korea. I wanted to create something that would bring us together despite the distance, something that would showcase the humor and creativity that makes our group so special.
I always wanted to see their creativity in action and afterwards, create t-shirts that immortalize our experience together so we can be both embarrassed and look fondly on the experience.
This desire to connect and create something memorable coincided with my longstanding goal to learn web development—a field I'd always found challenging due to my struggles with CSS/SCSS and JavaScript.
"I took the challenge because I always thought that diving into areas you're not that comfortable with is a good place to grow," I shared.
And so, Story Spirals was born: a collaborative storytelling platform where friends could create story starters and contribute to each other's narratives, one 80-character snippet at a time.
Frontend Development through Stories: From Concept to Implementation
Before diving into code, I needed to design an architecture that would support collaborative storytelling while being manageable for a solo developer learning new technologies.

Choosing the Stack
Influenced by content from Marc Lou, a French solopreneur based in Bali, I decided to build the application with Next.js and MongoDB. I opted for TypeScript over JavaScript because, as a backend engineer, I "crave stability in the code" and appreciate TypeScript's static typing. For styling, I chose SCSS, partly out of curiosity about a technology a former frontend colleague had passionately championed years ago.
Core Technical Components
The application consisted of several key components:
User Authentication System: Token-based signup with secure session management
Story Creation and Continuation: Interface for posting and extending stories
Voting Mechanism: Reddit-inspired system for promoting quality content
Trending Algorithm: To surface the most engaging stories
User Profiles: Displaying statistics and contribution history
The Frontend Challenge: Learning Through Struggle
The development process was not without its challenges, particularly for someone more comfortable in the backend world.
The Story Spirals homepage showing trending stories and the contribution interface.
CSS: My Unexpected Nemesis
"I got so annoyed trying to make the UI responsive," I admitted. "I think I've spent days just resizing the Chrome window and getting annoyed when the interface would just overflow or behave in any weird way."
One particularly challenging component was the story display, which needed to balance aesthetics with functionality:
// From src/components/story/story-client.tsx (simplified)
export default function StoryBitClientComponent({
storyBit,
storyChain,
childrenStoryBits,
printStory,
collectionLinkPrefix,
allowAddStoryBit,
showChildren,
backButton,
sessionData,
}: StoryBitClientComponentProps) {
// State for votes and handlers for interactions
const [votesState, setVotesState] = useState(storyBit.votes || []);
const userId = sessionData?.userId;
// Vote handler function
const handleVote = async (storyBitId: ObjectId, voteType: "upvote" | "downvote") => {
// Voting logic...
};
return (
<>
<div key={storyBit._id.toString()} className={styles.storyBit}>
{/* Back button navigation */}
{backButton && storyBit.parentId && (
<Link
href={`${collectionLinkPrefix}/${storyBit.parentId}`}
className={styles.storyBitLink}
>
<Image src={"/back.svg"} alt={"Back"} width={20} height={20} />
</Link>
)}
<div className={styles.storyBitButtonAndContentContainer}>
{/* Full story display mode */}
{printStory && (
<>
<a href={`${collectionLinkPrefix}/${storyBit._id}`}>
<h1 className={styles.storySoFar}>Story so far:</h1>
{storyChain && (
<p>
{storyChain}{" "}
<span className={styles.lastParagraph}>
{storyBit.text}
</span>
</p>
)}
{!storyChain && <p>{storyBit.text}</p>}
</a>
</>
)}
{/* Single story bit display mode */}
{!printStory && (
<>
<a href={`${collectionLinkPrefix}/${storyBit._id}`}>
<p className={styles.storyBitText}>{storyBit.text}</p>
</a>
</>
)}
{/* Voting controls */}
<div className={styles.storyBitButtonsContainer}>
<div className={styles.storyBitButtons}>
<ThumbsButton
className={
votesState
.filter((vote) => vote.userId === userId)
.some((vote) => vote.voteType === "upvote")
? thumbsStyles.toggle
: ""
}
onClick={() => handleVote(storyBit._id, "upvote")}
/>
<p>
{" "}
{votesState.filter((vote) => vote.voteType === "upvote")
.length -
votesState.filter((vote) => vote.voteType === "downvote")
.length}{" "}
</p>
<ThumbsButton
style={{ transform: "rotate(180deg)" }}
className={
votesState
.filter((vote) => vote.userId === userId)
.some((vote) => vote.voteType === "downvote")
? thumbsStyles.toggle
: ""
}
onClick={() => handleVote(storyBit._id, "downvote")}
/>
</div>
</div>
</div>
</div>
</>
);
}
The CSS Challenge: Making this component responsive required understanding flexbox layouts and careful consideration of element positioning. The corresponding SCSS demonstrates my approach:
// From src/components/story/styles.module.scss (partial)
.storyBit {
list-style: none;
margin: 10px 0;
padding: 10px;
border: 1px solid #ccc;
border-radius: 5px;
background-color: #f9f9f9;
}
.storyBitButtonAndContentContainer {
display: flex;
align-items: center;
gap: 1rem;
// Responsive behavior for mobile screens
@media (max-width: 768px) {
flex-direction: column;
align-items: flex-start;
}
}
.storyBitButtons {
display: flex;
align-items: center;
flex-direction: column;
align-self: flex-end;
justify-content: flex-end;
}
.storyBitButtonsContainer {
align-self: flex-end;
margin-left: auto;
// Responsive positioning for mobile
@media (max-width: 768px) {
margin-left: 0;
align-self: center;
margin-top: 10px;
}
}
.lastParagraph {
background-color: $color-button;
color: $nav-background;
}
Next.js: Static vs. Dynamic Rendering
My battle with Next.js's static vs. dynamic page rendering also proved frustrating. The development environment worked perfectly, but the production environment revealed issues with page updates—a result of Next.js pre-rendering pages not explicitly marked as dynamic.
Learning Insight: For any backend developers venturing into Next.js, understanding the getServerSideProps vs. getStaticProps distinction is crucial. Pages that need fresh data on each request must use server-side rendering.
Building the Core Features
Secure User Authentication
Rather than opening registration to anyone, I implemented a token system that ensured only invited friends could join. "I only wanted my friends to be able to post instead of allowing anyone on the Internet to do so," I explained.
// From src/app/api/signup/route.ts
export async function POST(request: Request) {
// Initialize MongoDB connection
await getMongoose();
// Extract user data from request body
const { username, email, password, token } = await request.json();
// Check if user already exists with this email
const existingUser = await User.findOne({ email: email }).exec();
if (existingUser) {
return NextResponse.json(JSON.stringify({ error: "User already exists" }), {
status: 400,
});
}
// Verify and consume signup token
const signupToken = await SignupToken.findOneAndUpdate(
{ token: token, email: { $exists: false } },
{ email: email }
).exec();
if (!signupToken) {
return NextResponse.json(JSON.stringify({ error: "Invalid token" }), {
status: 400,
});
}
// Create new user with hashed password and random avatar
const hashedPassword = await bcrypt.hash(password, 10);
const newUser = await User.create({
username: username,
email: email,
password: hashedPassword,
imageUrl: `https://picsum.photos/seed/${crypto.randomUUID()}/200`, // Random avatar
});
// Create session cookie with user data
const cookie = await createSessionCookie({
userId: newUser.id,
email: newUser.email,
username: newUser.username,
imageUrl: newUser.imageUrl,
admin: newUser.admin,
createdAt: new Date(),
});
// Return success response with session cookie
return new NextResponse(JSON.stringify({ message: "User created" }), {
status: 200,
headers: {
"Set-Cookie": `${cookie}`,
"Content-Type": "application/json",
},
});
}
Security Considerations: This approach provided both exclusivity and security:
Only users with valid tokens could register
Password hashing with bcrypt protected user credentials
Server-side session management prevented client-side manipulation
The 80-Character Constraint: Designing for Collaboration
Each contribution was capped at 80 characters to encourage collaboration and prevent any single user from dominating a story. This constraint was implemented in the story submission form:
// From src/components/story/new/page.tsx
export default function NewStoryBitForm() {
const [showLoginError, setShowLoginError] = useState(false);
const [charCount, setCharCount] = useState(0);
const MAX_CHARS = 80;
const handleInputChange = (e) => {
setCharCount(e.target.value.length);
};
const handleCreateStory = async (e: FormData) => {
try {
await addFreshStoryBit(e);
window.location.reload();
} catch (error) {
setShowLoginError(true);
// Hide error after 3 seconds
setTimeout(() => setShowLoginError(false), 3000);
console.log("Error creating story bit:", error);
}
};
return (
<>
<form className={styles.createStoryForm} action={(e) => handleCreateStory(e)}>
<textarea
placeholder="Start your story here..."
className={styles.storyInput}
name="text"
rows={4}
maxLength={MAX_CHARS}
onChange={handleInputChange}
/>
<div className={styles.charCounter}>
{charCount}/{MAX_CHARS} characters
</div>
<Button className={styles.submitStoryButton} type="submit" text="Create Story" />
</form>
{showLoginError && (
<div className={styles.loginErrorPopup}>
Please log in to create a story
</div>
)}
</>
);
}
Design Insight: The character limit created a unique collaborative dynamic where no single user could control the narrative direction. This constraint actually enhanced creativity by forcing concise, impactful contributions.
The Reddit-Inspired Trending Algorithm: Making Content Discoverable
Among the various technical aspects of the project, implementing the trending algorithm for the home page proved particularly satisfying.
"I had to research the 'Reddit trending algorithm' which was quite cool," I explained. "I've learned how some social networks calculate these things which I've never thought about before. That was a really cool learning experience."
export async function getTrendingStoryBits(count: number): Promise<StoryBit[]> {
await getMongoose();
// Filter to create subsets of votes by type
const totalVotesFilter = (voteType: "upvote" | "downvote") => ({
$filter: {
input: "$votes",
as: "vote",
cond: { $eq: ["$$vote.voteType", voteType] },
},
});
const upvoteFilter = totalVotesFilter("upvote");
const downvoteFilter = totalVotesFilter("downvote");
// Filter stories from the last 7 days
const dateFilter = {
createdAt: { $gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
};
// Calculate base metrics for each story
const baseExtraFields = {
hoursOld: {
$divide: [{ $subtract: [new Date(), "$createdAt"] }, 1000 * 60 * 60],
},
upvotes: { $size: upvoteFilter },
downvotes: { $size: downvoteFilter },
};
// Calculate vote-related scores
const scoreFields = {
votesScore: { $subtract: ["$upvotes", "$downvotes"] },
totalVotes: { $add: ["$upvotes", "$downvotes"] },
};
// Reddit-inspired ranking algorithm:
// Score = (Net Votes) / ((hours_since_creation + 2)^1.8)
const trendingScoreField = {
trendingScore: {
$multiply: [
{
$divide: ["$votesScore", { $pow: [{ $add: ["$hoursOld", 2] }, 1.8] }],
},
],
},
};
const storyBits = await Story.aggregate([
{ $match: dateFilter },
{ $addFields: baseExtraFields },
{ $addFields: scoreFields },
{ $addFields: trendingScoreField },
{ $sort: { trendingScore: -1 } },
{ $limit: count },
]).exec();
return JSON.parse(JSON.stringify(storyBits));
}
Algorithm Explanation:
Time Decay Factor: Stories lose relevance over time through the (hours_since_creation + 2)^1.8 denominator. The +2 prevents division by zero for very new posts.
Vote Balance: The algorithm uses net votes (upvotes - downvotes) to measure quality.
MongoDB Aggregation Pipeline: The implementation uses MongoDB's powerful aggregation framework to perform these calculations efficiently on the database side.
Recent Content Filter: Only content from the last 7 days is considered, ensuring fresh stories.
The result is a trending algorithm that balances recency and popularity, giving newer content a chance to rise while still rewarding quality.
Implementing the Voting System
Users could upvote or downvote story contributions, with the highest-scoring continuations appearing at the top when viewing a story. The voting mechanism required some careful implementation to handle toggling votes:
// From src/components/story/story-server.tsx
export async function toggleVote(
storyBitId: ObjectId,
voteType: "upvote" | "downvote"
) {
await getMongoose();
const sessionData = await checkSessionCookieInServer();
// Check if the user has already voted with this specific vote type
const existingVoteCondition = {
$ne: [
0,
{
$size: {
$filter: {
input: "$votes",
as: "vote",
cond: {
$and: [
{ $eq: ["$$vote.userId", sessionData.userId] },
{ $eq: ["$$vote.voteType", voteType] },
],
},
},
},
},
],
};
// Remove the vote from the story bit if it exists
const removeVote = {
$filter: {
input: "$votes",
as: "vote",
cond: { $ne: ["$$vote.userId", sessionData.userId] },
},
};
// Add the vote to the story bit
const addVote = {
$concatArrays: [
removeVote,
[
{
userId: sessionData.userId,
username: sessionData.username,
voteType: voteType,
updateTime: new Date(),
},
],
],
};
// Update the story bit with the new vote state
await Story.updateOne({ _id: storyBitId }, [
{
$set: {
votes: {
$cond: {
if: existingVoteCondition,
then: removeVote,
else: addVote,
},
},
},
},
]);
return await retrieveStoryBitFromDatabase(storyBitId.toString());
}
Implementation Challenges:
Vote Toggling: Users could toggle votes on and off, requiring careful state management
MongoDB Updates: Using array operations in MongoDB to efficiently update vote collections
Server-Side Validation: Preventing vote manipulation by handling all voting logic server-side
The Grand Unveiling: Launch and User Experience
The Story Spirals platform after launch, showing user contributions and voting functionality.
With the platform built and tested by two developer friends, it was time to launch. I created a WhatsApp group with all invited friends and sent what I called a "sales copy style initial message" that perfectly captured the spirit of the project:
Welcome to the first edition of the "Story Spiral Winter Write-Off"! Because a group this chaotic deserves to be immortalized in wearables. I was looking for a way for us to reconnect and make something awesome together. Something that might be discovered by an archaeologist 3000 years from now and lead them to safely conclude that we all needed more therapy.
The message outlined the simple rules: start stories, hijack others' narratives, vote on the best content, and contribute at least 30 story bits to earn a personalized t-shirt featuring the collective's best writing.
Stories Unfold: User-Generated Content
While participation didn't quite reach the ambitious 30-contributions-per-person goal ("most people posted around 1-5 stories," with a core group of 4-5 users doing "the bulk of the work"), the platform still generated some hilariously creative content.
The winning story showcased the group's irreverent humor:
"I sit on my hand and wait for it to get numb But I have big plans. Maky is coming over. All lubed up. That's because of An inspirational man in the name of Carl. He is also known as carlito the burito Carleto the Furrito, or Carlitoe to Feetito. Those were his three nicknames. Suddenly, I felt starving. I was craving tacos. Torn between hunger and arousal, I sat on a taco, destroying it completely. Yet somehow, I felt fulfilled. Nothing could stop me now. Well, maybe my hunger, or my lack of judgement."
Another popular entry delved into absurdist territory:
"Today my personal hygiene reached a new low, because I finally decided to stop using soap. The local wildlife has accepted me as their leader, and now the gang of us go around dismantling all the neighbours showers in the hope that the resulting stench will attract wild bears for our new Stinky Safari"
And a brilliantly concise one-liner played with reader expectations:
"The missus screams 'deeper, deeper! Oh my god! This is amazing!' ... She's really enjoying Minecraft."
Key Technical Takeaways for Backend Developers
From this journey into frontend development, I gained several valuable insights that can benefit other backend developers looking to expand their skillset:
1. Start with TypeScript, Not JavaScript
As a backend developer accustomed to strongly-typed languages, TypeScript provided a crucial safety net when working with frontend code. It caught numerous errors at compile time that would have been runtime issues in plain JavaScript.
2. Responsive Design Requires Systematic Testing
Don't rely on "it looks good on my screen." Test your UI across multiple device sizes from the beginning. Consider using frameworks like Tailwind CSS that make responsive design more manageable.
3. State Management is Complex but Critical
Frontend state management is a different paradigm from backend development. Understanding component state, context, and when to use more complex solutions like Redux is essential for building responsive UIs.
4. Next.js Has a Learning Curve
The distinction between server-side rendering and static generation in Next.js is powerful but requires a mental model shift. Be explicit about which pages need dynamic data.
5. Database Queries Require Different Optimization Techniques
MongoDB aggregation pipelines are powerful but require a different approach than SQL queries. Learning how to leverage database-specific features for complex operations (like the trending algorithm) pays off in performance.
Lessons and Personal Growth
Building Story Spirals transformed my perspective on frontend development and user experience design.
"As a backend developer I gained a whole new dimension from this project," I reflected. "I feel like I understand the struggles of frontend development even more now and I understand that it's not as easy as I thought it'd be."
Beyond the technical learnings, the project unlocked something unexpected: creative expression through code.
"As a backend engineer I don't do a lot of creative work. This project really pushed my boundaries on that regard and I felt like the artist in me that didn't have much space to express himself, had autonomy to finally create and be free."
This newfound appreciation for frontend development has even enhanced my work at Google, allowing me to "speak more confidently about UX choices and the way UI code is organized" during project reviews.
Looking Ahead: Story Spirals 2.0
Story Spirals may have been created for a specific holiday season, but its future looks bright. Plans for a "second edition" include:
Image Support: Allowing users to add pictures to their stories
Leaderboard: Showcasing the most active and liked contributors
Email Verification System: Improving account security
Animated Mascot: Creating a character that "interacts with the page in cute ways"
With hindsight, I'd make some architectural changes in version 2.0: "I would not do the API bit in Next.js but instead have a Python backend that handles all the database interactions," noting that Python's ORMs and database components feel "much more intuitive and easier to use" compared to TypeScript.
Resources for Backend Developers Exploring Frontend
For other backend developers looking to make a similar journey, these resources were particularly helpful:
Next.js Documentation - Particularly the sections on data fetching and rendering strategies
CSS-Tricks - For learning flexbox and responsive design principles
MongoDB Aggregation Documentation - Essential for implementing complex data operations
TypeScript Handbook - For understanding TypeScript's type system and integration with React
React Hooks Documentation - For mastering state management in functional components
Conclusion: The Value of Crossing Technical Boundaries
Story Spirals began as a personal challenge to learn frontend development and create a way for distant friends to connect through collaborative storytelling. While it didn't achieve the full participation I'd hoped for, it succeeded in other meaningful ways: generating creative content, providing a platform for friends to interact, and most importantly, pushing me as a developer to explore new territories.
The code I wrote—from the trending algorithm to the responsive UI components—represented not just functional solutions to technical problems, but steps in my journey from backend comfort to frontend competence.
The project stands as a testament to the value of stepping outside one's comfort zone and blending technical skills with creative expression. As I look forward to the next iteration, I carry with me not just improved frontend skills, but a deeper appreciation for the art of creating digital spaces that bring people together in imaginative ways.
Sometimes, the best way to grow as a developer isn't to go deeper into what you already know, but to venture into the unfamiliar—just like the unpredictable twists and turns of a good story spiral.
Comentários