Published on

The Epic Saga of Building a Next.js + LangChain Boilerplate (Or: How I Learned to Stop Worrying and Love the CORS)

Authors
  • avatar
    Name
    David Nguyen
    Twitter

Chapter 1: The Innocent Beginning

It started, as all great developer stories do, with dangerous levels of optimism and a cup of coffee.

"I'll just build a simple chatbot boilerplate," I said, naively. "How hard could it be? Next.js for the frontend, FastAPI for the backend, throw in some LangChain magic, and boom—we're done by lunch!"

Narrator: It was not done by lunch. It was not done by many lunches.

Chapter 2: The Tech Stack Assembly (aka The Avengers Initiative)

First, I needed to assemble my tech stack dream team:

  • Next.js 14: The speedster of the group, always running ahead with its app router
  • TypeScript: The strict parent who won't let you play until you've defined all your types
  • FastAPI: The Python backend that promised to be "fast" (spoiler: it was)
  • LangChain: The mysterious wizard who speaks to LLMs in tongues
  • Supabase: The authentication bouncer and database keeper
  • Tailwind CSS: The fashion designer who insists everything must be utility-first
  • Docker: The container ship captain who packages everything neatly

Little did I know, getting this team to work together would be like organizing a group project where everyone speaks different languages and has conflicting schedules.

Chapter 3: The CORS Wars

Day 3 of development. I had my frontend talking sweetly to my backend in development. Life was good. Then I tried to deploy.

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # Adjust this for production
    # ^^^ Famous last words
)

That innocent comment—"Adjust this for production"—haunted me like a ghost in the machine. The browser console lit up like a Christmas tree with CORS errors.

"No 'Access-Control-Allow-Origin' header," it screamed. "Preflight request failed," it wailed. "CORS policy blocked," it sobbed.

I tried everything:

  • Specific origins ✗
  • Wildcards ✗
  • Sacrificing a rubber duck to the CORS gods ✗

Finally, after hours of debugging, I discovered I had been setting headers in three different places. The middleware was fighting itself. Classic.

Chapter 4: The Great Type Migration

TypeScript and I have a love-hate relationship. I love that it catches my mistakes. I hate that it catches ALL my mistakes.

// My optimistic type definition
type Message = {
  content: string;
  role: string;
}

// TypeScript's reality check
type Message = {
  content: string;
  role: 'user' | 'assistant' | 'system';
  id: string;
  created_at: Date;
  // ... 47 more properties you forgot about
}

The real fun began when I tried to make TypeScript, Pydantic, and Supabase agree on what a "User" object looks like. It was like being a diplomat at a UN meeting where everyone's speaking different languages but they're all angry about the same thing.

Chapter 5: The Authentication Tango

Implementing authentication with Supabase seemed straightforward. The documentation said "just a few lines of code!" They didn't mention those few lines would spawn a thousand edge cases.

The authentication flow went something like this:

  1. User signs up → Success!
  2. User logs in → Token generated!
  3. User refreshes page → Who are you?
  4. User switches tabs → Identity crisis!
  5. User breathes → Session expired!

I spent an entire day chasing a bug where users would randomly get logged out. Turns out, I was checking authentication on every keystroke in the chat input. The JWT token was having an existential crisis trying to validate itself 50 times per second.

Chapter 6: The Streaming Saga

"Real-time streaming responses will make the chat feel more natural," I thought.

What followed was my descent into callback hell, promise purgatory, and async/await abyss all at once. The AI's responses would either:

  • Come all at once like a wall of text
  • Arrive one character at a time like a very slow typist
  • Get cut off mid-sentence like someone yanked the
  • Never arrive at all, lost in the digital void

The solution involved three different streaming protocols, a custom message parser, and what I can only describe as "aggressive buffering strategies."

// The streaming logic that finally worked
streamProtocol: 'data',  // Not 'text', not 'stream', but 'data'
// Why? Because Tuesday, that's why.

Chapter 7: The Docker Dilemma

"Let's containerize it!" I exclaimed, full of DevOps enthusiasm.

Writing the Dockerfiles was easy. Getting them to talk to each other was like trying to get two cats to share a food bowl.

services:
  frontend:
    depends_on:
      - backend
    # Translation: Frontend will wait approximately 0.001 seconds
    # before trying to connect to a backend that isn't ready yet

The backend would start, the frontend would panic, the database would be confused, and I would be reaching for my fourth coffee.

Solution: Health checks, retry logic, and a startup script that basically says "everyone just CALM DOWN and wait your turn."

Chapter 8: The Database Drama

SQLAlchemy and I had philosophical differences about how relationships should work.

# What I wanted
user.chat_sessions  # Simple, elegant

# What SQLAlchemy wanted
db.query(ChatSession).filter(
    ChatSession.user_id == user.user.id
).order_by(ChatSession.updated_at.desc()).all()
# A SQL query that looks like it's applying for a mortgage

And don't get me started on the great UUID vs Integer ID debate. I chose UUIDs for their uniqueness. The database chose violence in response, making every query look like someone mashed their keyboard.

Chapter 9: The Feature Creep Chronicles

The scope started simple:

  • Send message
  • Get response
  • Save chat

But then the voices in my head (also known as "user requirements") started whispering:

  • "What about chat history?"
  • "Can we stop responses mid-stream?"
  • "Multiple chat sessions would be nice..."
  • "Markdown support?"
  • "LaTeX for math equations?"
  • "What about file uploads?"

Before I knew it, my "simple boilerplate" had more features than a Swiss Army knife. Each feature brought its own delightful bugs:

  • Chat history: Now with infinite scroll that sometimes scrolls infinitely into the void
  • Stop button: Works 90% of the time, every time
  • Markdown renderer: Interprets your code blocks as modern art
  • LaTeX support: Because who doesn't want to write differential equations in a chat?

Chapter 10: The Poetry vs NPM Showdown

Managing dependencies in two package managers is like being a child of divorced parents who don't talk to each other.

# Frontend
npm install everything-and-the-kitchen-sink

# Backend
poetry add the-entire-python-ecosystem

Version conflicts arose like plot twists in a soap opera. React wanted one version of a shared dependency, Python wanted another, and Docker just wanted everyone to specify exact versions or it would refuse to build.

The Grand Finale: It Actually Works!

After weeks of battles, debugging sessions that lasted until 3 AM, and more console.log statements than actual code, something magical happened.

It worked.

The chat was smooth. Authentication was solid. Messages persisted. Docker containers played nicely together. The frontend and backend were in perfect harmony, like a well-rehearsed orchestra.

I had created the boilerplate—a starting point for others to build upon. A foundation that handled the painful parts so others wouldn't have to suffer through the same CORS configurations, streaming protocols, and authentication flows.

Lessons Learned (The Hard Way)

  1. CORS is not your friend: It's that strict security guard who's just doing their job but makes your life miserable. Respect it, configure it properly, and never use allow_origins=["*"] in production (yes, I kept the comment).

  2. TypeScript is worth the pain: Like going to the gym, it hurts at first, but you'll be stronger for it. Your future self will thank you when refactoring.

  3. Start with Docker: Don't wait until the end to containerize. Docker from day one saves you from "but it works on my machine" syndrome.

  4. Streaming is complicated: Budget extra time for implementing streaming responses. Then double it. Then add a week.

  5. Documentation lies: Not maliciously, but documentation is often optimistic. "Just a few lines of code" means "just a few lines of code plus 47 edge cases we didn't mention."

  6. Boilerplates are never really done: There's always one more feature, one more optimization, one more bug fix. The key is knowing when to stop and call it "good enough for v1."

The Aftermath

The repository now sits on GitHub, a monument to perseverance and stubbornness. It has:

  • Clean separation between frontend and backend
  • Working authentication that doesn't randomly forget who you are
  • Real-time streaming that actually streams in real-time
  • Docker setup that doesn't require a PhD to understand
  • A README that tells the truth (mostly)

To future developers who stumble upon this boilerplate: You're welcome. I've already fought these battles so you don't have to. May your CORS be configured, your types be defined, and your containers be healthy.

And remember, when you're knee-deep in debugging at 2 AM, wondering why the chat history won't persist, know that someone else has been there. That someone was me. And I survived to tell this tale.

P.S. - The TODO List of Eternal Optimism

The README still has a TODO section:

  • Regenerate Answer
  • Add Attachments

I put them there three months ago. They mock me daily. One day, I tell myself. One day.

But not today. Today, I rest. And maybe tomorrow. Next week for sure though...


Found this helpful? Star the repo and share your own war stories in the issues section. Misery loves company, and debugging loves witnesses.