top of page
  • Youtube
  • Black Instagram Icon
  • Black Facebook Icon

Beyond Java: How I Conquered Frontend Development to Connect Friends Through Storytelling

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


Animated boy in a yellow shirt, excitedly typing at a computer in a room with comics on shelves, a poster, and a window with blue sky.

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.


Flowchart for story management: user actions like login, submit, continue, upvote stories processed through Next.js, server, API, and MongoDB.
The overall architecture of Story Spirals showing the key user flows.

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:


  1. User Authentication System: Token-based signup with secure session management

  2. Story Creation and Continuation: Interface for posting and extending stories

  3. Voting Mechanism: Reddit-inspired system for promoting quality content

  4. Trending Algorithm: To surface the most engaging stories

  5. 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:

  1. Only users with valid tokens could register

  2. Password hashing with bcrypt protected user credentials

  3. 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:


  1. 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.

  2. Vote Balance: The algorithm uses net votes (upvotes - downvotes) to measure quality.

  3. MongoDB Aggregation Pipeline: The implementation uses MongoDB's powerful aggregation framework to perform these calculations efficiently on the database side.

  4. 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:


  1. Vote Toggling: Users could toggle votes on and off, requiring careful state management

  2. MongoDB Updates: Using array operations in MongoDB to efficiently update vote collections

  3. 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:


  1. Image Support: Allowing users to add pictures to their stories

  2. Leaderboard: Showcasing the most active and liked contributors

  3. Email Verification System: Improving account security

  4. 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:


  1. Next.js Documentation - Particularly the sections on data fetching and rendering strategies

  2. CSS-Tricks - For learning flexbox and responsive design principles

  3. MongoDB Aggregation Documentation - Essential for implementing complex data operations

  4. TypeScript Handbook - For understanding TypeScript's type system and integration with React

  5. 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


© 2024 Silviu Popescu

  • Facebook
  • Instagram
  • LinkedIn
bottom of page