nyxcore-systems
7 min read

Shipping Public Project Blogs: A Deep Dive into Database Migrations, Stale Clients, and Hard-Won Lessons

We just rolled out public-facing project blogs, but the journey wasn't without its snags. From Prisma's 'Unsupported' types to stale client caches, here's a candid look at the challenges and crucial lessons learned shipping a new feature to production.

Next.jsPrismaPostgreSQLDatabase MigrationsDeploymenttRPCDeveloper Experience

Just yesterday, we pushed a significant update to production: public read-only blog pages for projects. This feature allows project owners to publish updates, insights, and stories directly from their dashboards, making their work more discoverable and shareable. While the final git push felt good, the path to a green health check was a classic developer's gauntlet, complete with database migration gotchas and container quirks.

This post isn't just a changelog; it's a candid look at the development journey, the architectural choices, and, most importantly, the hard-won lessons from bumping up against real-world deployment challenges.

The Feature: Bringing Project Blogs to Life

Our goal was clear: enable users to publish blog posts associated with their projects, accessible via a public URL. This involved several moving parts, from schema updates to new frontend routes and backend logic.

1. Database Schema: The Foundation

First, we needed to extend our data model to support blog functionality.

  • Project entity gained two new fields:
    • blogSlug: A String @unique for the project's public blog URL (e.g., /b/my-awesome-project).
    • blogPublished: A Boolean to control the public visibility of the entire project blog.
  • A new BlogPost entity was introduced, linked to Project, with:
    • slug: A String @unique for the individual post's URL (e.g., /b/my-awesome-project/first-post).
    • publishedAt: A DateTime to track when the post went live.
prisma
// prisma/schema.prisma

model Project {
  // ... existing fields ...
  blogSlug      String?   @unique
  blogPublished Boolean   @default(false)
  blogPosts     BlogPost[]
}

model BlogPost {
  id          String    @id @default(cuid())
  projectId   String
  project     Project   @relation(fields: [projectId], references: [id])
  title       String
  content     String    @db.Text // Storing markdown content
  slug        String?   @unique
  publishedAt DateTime?
  // ... other post metadata like tags, author, etc.
}

2. Public Access & Routing

To make these pages accessible without authentication, we updated our src/middleware.ts to whitelist /b/* routes. This ensures that visitors can browse project blogs and individual posts without needing to log in.

On the frontend, we set up two new public routes using Next.js App Router:

  • src/app/(public)/b/[slug]/page.tsx: This serves as the project's blog listing page. It features a project header, key stats, and a clean grid of post cards, each displaying the date, relevant tags, and a concise excerpt. The layout includes a light gradient with soft pastel decorative orbs for a pleasant aesthetic.
  • src/app/(public)/b/[slug]/[postSlug]/page.tsx: This is the single blog post view. It includes a back link to the project blog, the post header, tags, and the full markdown content rendered beautifully.

The markdown rendering itself is handled by a client wrapper, src/app/(public)/b/[slug]/[postSlug]/content.tsx, which applies prose styling for optimal readability.

3. Backend Logic: Publishing Workflow

The core publishing logic lives in src/server/trpc/routers/projects.ts. We added:

  • publishBlog and unpublishBlog mutations: These handle setting blogPublished on the Project and automatically generate a unique blogSlug if one doesn't exist.
  • blogPosts.update: This mutation was enhanced to set publishedAt and generate a unique slug for a BlogPost when it's published.

Finally, in the dashboard, src/app/(dashboard)/dashboard/projects/[id]/page.tsx, we integrated a publish banner into the BlogTimeline section. This provides a clear "Publish/Unpublish Blog" button and displays the public URL with a convenient copy button once the blog is live.

The Gauntlet: Lessons Learned in Production

While the feature itself came together smoothly, deploying to production unveiled a couple of critical lessons. These are the moments that truly shape our development practices.

Lesson 1: Prisma's db push and the Unsupported Type Trap

My first instinct for applying the new schema changes to production was npx prisma db push. This is often quick and convenient for development environments, but it can be dangerous in production, especially when dealing with non-standard database types.

The Problem: Our PostgreSQL database includes an embedding vector(1536) column with an HNSW index, used for AI-powered search and recommendations. Since vector is not a natively supported type by Prisma, it's represented as Unsupported("vector(1536)") in schema.prisma. When db push encountered this, it decided to drop the column because it didn't understand how to migrate it. Result? 24 rows lost their embedding values.

The Workaround: After the db push completed and the new columns were added, I had to manually re-add the embedding column and its HNSW index via raw SQL:

sql
ALTER TABLE "Project" ADD COLUMN "embedding" vector(1536);
CREATE INDEX "Project_embedding_idx" ON "Project" USING hnsw ("embedding" hnsw_vector_ops);

While the column and index were restored, the embedding values for those 24 rows were permanently lost (now NULL).

The Crucial Lesson: ALWAYS use safe migration scripts for production deployments. We have a custom scripts/db-migrate-safe.sh precisely for this reason. It filters out DROP COLUMN commands for specific sensitive columns (like embedding) before applying migrations. I momentarily forgot this critical safety net. Never trust db push on production with Unsupported types unless you are absolutely certain of its behavior. Understand your ORM's limitations and build safeguards around them.

Lesson 2: Stale Prisma Client in Containerized Deployments

After the database schema was updated and the new application image deployed, the health check immediately failed with projects.blogSlug does not exist. My nerves frayed for a moment, thinking the database migration hadn't fully applied or something was fundamentally wrong.

The Problem: The database did have the blogSlug column. The issue was that the Prisma client running inside our app Docker container was compiled (or cached) against the old database schema. When it tried to query, it didn't recognize the new column.

The Workaround: A simple docker compose restart app was all it took. Restarting the container forced the application to reload, which in turn caused the Prisma client to re-initialize and pick up the new database schema. The health check immediately turned green.

The Crucial Lesson: In containerized environments, especially after database schema changes, remember that your application's ORM client might be holding onto a cached or compiled version of the schema. A simple container restart is often necessary to ensure it's working with the latest database state. This is a common "gotcha" that can make you second-guess your database migrations when the real issue is client-side.

Current State & Next Steps

Despite the production hiccups, the new public blog routes are live!

  • Production commit 591820e is deployed and healthy.
  • The embedding column is back, though 24 rows need re-embedding.
  • Public routes /b/[slug] and /b/[slug]/[postSlug] are accessible.

The next immediate steps involve:

  1. Re-embedding the lost workflow insights (those 24 NULL rows).
  2. Thoroughly testing the publish flow from the dashboard: dashboardprojectBlog tabPublish Blogvisit public URL.
  3. Rotating various production secrets (OpenAI, Anthropic, JWT, encryption key, DB password) for good measure after a deployment.
  4. E2E testing other critical features to ensure nothing else was inadvertently affected.

Conclusion

Shipping new features is a mix of excitement and challenge. This deployment was a stark reminder of the importance of robust migration strategies and understanding the nuances of how ORMs and containerized applications interact with database schema changes. Every "pain log" entry becomes a valuable lesson, reinforcing best practices and improving our development workflow. Here's to smoother sailing on the next feature!

json
{
  "thingsDone": [
    "Added `blogSlug` and `blogPublished` to Project schema",
    "Added `slug` and `publishedAt` to BlogPost schema",
    "Whitelisted `/b/*` routes in middleware for public access",
    "Implemented `publishBlog` and `unpublishBlog` tRPC mutations with auto slug generation",
    "Updated `blogPosts.update` to set `publishedAt` and `slug` on publish",
    "Created public blog listing page (`/b/[slug]`) with project header, stats, and post cards",
    "Created public single blog post page (`/b/[slug]/[postSlug]`) with back link, header, tags, and markdown content",
    "Implemented client-side MarkdownRenderer with prose styling",
    "Integrated publish banner and public URL display into dashboard project page",
    "Deployed all changes to production",
    "Restored `embedding vector(1536)` column and HNSW index via raw SQL after `db push` dropped it",
    "Restarted Docker container to resolve stale Prisma client schema issue"
  ],
  "pains": [
    "Prisma `db push` dropping `Unsupported` type columns (e.g., `vector`) on production, leading to data loss.",
    "Health check failing after deploy due to stale Prisma client in Docker container not recognizing new schema columns."
  ],
  "successes": [
    "Successfully deployed public blog feature to production.",
    "Quickly identified and resolved production database migration and client schema issues.",
    "Reinforced critical lessons about safe production migration practices and containerized application behavior."
  ],
  "techStack": [
    "Next.js",
    "Prisma",
    "PostgreSQL",
    "tRPC",
    "Docker",
    "TypeScript",
    "Markdown"
  ]
}