nyxcore-systems
9 min read

From Drafts to Public Pages: Our Journey to Launching Project Blogs

We just pushed a major feature to production: public-facing blog pages for our projects. This post dives into the technical implementation, the unexpected hurdles we faced, and the crucial lessons learned about database migrations, frontend styling, and robust deployments.

Next.jsPrismaPostgreSQLDatabase MigrationtRPCTailwindCSSCSS VariablesDeploymentSSHDevOps

It’s 2026-03-04, and the dust is finally settling on a significant production deployment. Our goal? To empower projects on nyxcore.cloud to publish their internal drafts as public, read-only blog posts. This wasn't just a UI change; it involved schema migrations, new API endpoints, public routing, and a healthy dose of production-level problem-solving.

The feature is now live, and we're already iterating on readability and design. But getting here was a journey through some familiar, and some surprisingly tricky, technical landscapes.

The Mission: Unlocking Public Project Blogs

The core idea was simple: give project owners the ability to share updates, insights, and stories directly from their project dashboards. This meant:

  1. Dedicated Public URLs: A clean, branded /b/[project-slug] route.
  2. Read-Only Content: No auth required for public visitors.
  3. Publishing Workflow: A clear mechanism for project owners to publish/unpublish their blog and individual posts.
  4. Robust Display: Markdown rendering, proper styling for readability, and a good user experience.

Under the Hood: Building the Blog Feature

Here's a breakdown of the key technical decisions and implementations that brought this feature to life:

1. Database Schema Evolution (prisma/schema.prisma)

To support the new public blog functionality, we augmented our existing Project and BlogPost models:

prisma
model Project {
  // ... existing fields ...
  blogSlug      String?  @unique @db.VarChar(255) // Public slug for the project's blog
  blogPublished Boolean  @default(false)        // Is the project's blog publicly visible?
  BlogPosts     BlogPost[]
}

model BlogPost {
  // ... existing fields ...
  slug        String?   @unique @db.VarChar(255) // Public slug for an individual blog post
  publishedAt DateTime?                        // Timestamp when the post was published
  Project     Project   @relation(fields: [projectId], references: [id])
  projectId   String
}
  • blogSlug on Project provides a clean URL for the project's blog index (e.g., /b/clarait-loom).
  • blogPublished controls the visibility of the entire project blog.
  • slug on BlogPost allows for friendly URLs for individual posts (e.g., /b/clarait-loom/my-first-post).
  • publishedAt tracks the publication date, crucial for sorting and display.

2. Public Routing (src/middleware.ts)

Our Next.js application uses middleware for authentication. For public blog pages, we needed to explicitly whitelist the new routes:

typescript
// src/middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Whitelist public blog routes
  if (pathname.startsWith('/b/')) {
    return NextResponse.next();
  }

  // ... existing auth logic for /dashboard routes ...
  // If not whitelisted and not authenticated, redirect to login
}

This ensures /b/* routes bypass authentication, allowing anyone to view the content.

3. Backend Logic & API (src/server/trpc/routers/projects.ts)

We extended our tRPC router for projects to handle the new publishing workflow:

  • publishBlog / unpublishBlog mutations: These mutations update blogPublished on the Project model and automatically generate a blogSlug if one doesn't exist, usually derived from the project name.
  • blogPosts.update enhancements: When a blog post is published, we now automatically set its publishedAt timestamp and generate a unique slug for it.

4. Frontend & UI (Next.js App Router)

The public-facing UI was built with a clean, minimalist aesthetic:

  • /app/(public)/b/[slug]/layout.tsx: A simple layout with a white background, ensuring our nyxcore CSS variables (which default to dark mode) don't interfere. We forced light-mode CSS variables via inline styles here.
  • /app/(public)/b/[slug]/page.tsx (Blog Listing): Displays a project header, a stats bar, and a list of blog post cards. Each card includes the post date, tags, an excerpt, and a "Read" link.
  • /app/(public)/b/[slug]/[postSlug]/page.tsx (Single Post View): Renders the full markdown content of a single blog post. It includes a back link, the post header, and tags.
  • /app/(public)/b/[slug]/[postSlug]/content.tsx: This component wraps our MarkdownRenderer and is crucial for styling. It applies a light-mode prose wrapper, overriding prose-invert and --tw-prose-* CSS variables to ensure text is perfectly readable on a light background.

5. Dashboard Integration (src/app/(dashboard)/dashboard/projects/[id]/page.tsx)

For project owners, we added a clear "Publish Blog" banner to the BlogTimeline section within the project dashboard. This includes:

  • A globe icon indicating public visibility.
  • "Publish Blog" / "Unpublish Blog" buttons.
  • The public URL for the project's blog, with a convenient copy button.

6. Critical Fix: Handling Mixed Slugs/IDs

A minor but important fix emerged during testing. Our blog post retrieval logic initially tried to match either a slug or an id using an OR clause: OR: [{ slug }, { id }]. However, Prisma strictly validates UUID formats for @db.Uuid columns. If postSlug was a human-readable string (e.g., "my-first-post"), it would crash when trying to match it against a UUID id column.

The solution was to conditionally apply the id match:

typescript
// Old (buggy):
// const post = await prisma.blogPost.findUnique({
//   where: {
//     OR: [{ slug: postSlug }, { id: postSlug }] // Fails if postSlug is not a UUID
//   }
// });

// New (robust):
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const whereClause: Prisma.BlogPostWhereUniqueInput = {
  slug: postSlug,
};

if (UUID_RE.test(postSlug)) {
  (whereClause as any).OR = [{ slug: postSlug }, { id: postSlug }];
}

const post = await prisma.blogPost.findUnique({
  where: whereClause
});

This ensures we only attempt to match by id if the postSlug looks like a UUID.

7. Mass Publishing & Database Maintenance

To get our initial content live, we mass-published 12 draft blog posts for the clarait-loom project directly via SQL. This was a quick way to seed the public blog. We also had to re-apply RLS policies via rls.sql after some migration issues.

The Gauntlet: Lessons Learned from the "Pain Log"

No significant deployment goes without its share of challenges. These "pain points" are often the most valuable learning opportunities.

Lesson 1: Database Migrations Require Extreme Caution (Especially --accept-data-loss)

The Pain: In a rush to push schema changes, I used npx prisma@5.22.0 db push --accept-data-loss directly on production. The Failure: This command dropped a critical embedding vector(1536) column on our workflow_insights table, losing 24 non-null values essential for AI features. The Workaround: I had to manually re-add the column and its HNSW index via raw SQL. The values, of course, were NULL, requiring a re-embedding process. The Takeaway: NEVER use --accept-data-loss on production unless you are absolutely certain and have a full backup strategy. Always use controlled, environment-specific migration scripts. We have a scripts/db-migrate-safe.sh script specifically designed to filter out dangerous column drops, and I clearly bypassed it. This was a stark reminder of why those safety nets exist.

Lesson 2: Prisma's UUID Validation is Strict (and unforgiving in OR clauses)

The Pain: As mentioned above, my initial Prisma query OR: [{ slug: postSlug }, { id: postSlug }] crashed with a P2023 error when postSlug was a non-UUID string but id was a @db.Uuid column. Prisma tried to validate the non-UUID string against the UUID type. The Failure: Application crash, P2023 error. The Workaround: Conditionally include the id match only if the input postSlug passes a UUID regex test (UUID_RE.test(postSlug)). The Takeaway: When constructing OR queries with potentially mixed data types (like slugs and UUIDs), be explicit and type-check inputs before passing them to Prisma, especially for fields with strict @db.Uuid constraints. This prevents unexpected validation errors.

Lesson 3: Dark Mode CSS Variables Can Haunt Light Mode Layouts

The Pain: Our public blog pages needed a clean, white background. However, our MarkdownRenderer and core UI components rely heavily on prose-invert and nyx-* CSS variables, which are optimized for dark mode. The Failure: Text became light/invisible on the light background, leading to unreadable content. The Workaround: We explicitly overrode --tw-prose-* and --nyx-* CSS variables via inline styles within the layout.tsx and content.tsx wrappers. This forced the light mode appearance for the public blog section. The Takeaway: When mixing UI themes or introducing new layouts with different background contexts, be mindful of global CSS variables and theme-specific utility classes. Explicit overrides are often necessary to prevent color clashes and ensure readability.

Lesson 4: Long SSH Builds Need Backgrounding

The Pain: Deploying to our production server often involves long-running docker build commands over SSH. The Failure: The SSH connection would frequently drop after about 2 minutes, interrupting the build process. The Workaround: We adopted nohup bash -c '...' > /tmp/deploy.log 2>&1 & to run the build command in the background on the server. This detaches the process from the SSH session, allowing it to complete even if the connection drops. We could then monitor progress with tail -f /tmp/deploy.log. The Takeaway: For any long-running command initiated over an SSH session, especially deployments, use nohup or screen/tmux to ensure the process continues uninterrupted. This significantly improves deployment reliability.

Minor Notes:

  • Local development was briefly hampered by a clarait-loom-db container occupying port 5432, preventing nyxcore's local DB from starting. A quick docker stop fixed it.

Active Status & Immediate Next Steps

As of this post, commit 826adca is live on production. The clarait-loom project's blog is published at https://nyxcore.cloud/b/clarait-loom, with all 12 initial posts accessible.

Our immediate priorities include:

  1. Google Safe Browsing: Our domain is currently flagged. We're verifying it in Search Console and requesting a review (or submitting a false positive report) to clear the warning.
  2. Re-embedding Insights: We need to re-process and re-embed the 24 workflow_insights rows that lost their embedding values during the migration mishap.
  3. Testing the Publish Flow: A thorough end-to-end test of the dashboard's "Publish Blog" button is crucial.
  4. Design Iteration: Gather user feedback on the blog design and make further improvements.
  5. Security Audit: Rotate mini-RAG secrets (OpenAI, Anthropic, JWT, encryption key, DB password) as a standard post-deployment practice.

This deployment was a microcosm of real-world development: feature implementation, careful integration, unexpected hurdles, and the continuous learning that comes with shipping to production. Each "pain" became a valuable "lesson" that strengthens our deployment practices and system resilience.


json
{
  "thingsDone": [
    "Added public read-only blog pages for projects",
    "Implemented `blogSlug` and `blogPublished` on Project model",
    "Implemented `slug` and `publishedAt` on BlogPost model",
    "Whitelisted `/b/*` routes for public access",
    "Developed `publishBlog`/`unpublishBlog` tRPC mutations with auto slug generation",
    "Updated `blogPosts.update` to set `publishedAt` and `slug` on publish",
    "Created public blog listing page (`/b/[slug]/page.tsx`)",
    "Created single public blog post page (`/b/[slug]/[postSlug]/page.tsx`)",
    "Implemented light-mode specific CSS overrides for MarkdownRenderer",
    "Integrated publish banner and URL copy button into dashboard",
    "Fixed Prisma UUID validation crash with conditional querying",
    "Published 12 draft blog posts via SQL for initial content",
    "Re-added `embedding vector(1536)` column and HNSW index (post-error)",
    "Re-applied RLS policies"
  ],
  "pains": [
    "Dropped critical `embedding` column on production with `db push --accept-data-loss`",
    "Prisma `P2023` error due to UUID validation on non-UUID slug in `OR` query",
    "Dark mode CSS variables (`prose-invert`, `nyx-*`) clashing with light-mode public blog background",
    "SSH connection dropping during long `docker build` commands on production",
    "Local DB port conflict with another Docker container"
  ],
  "successes": [
    "Successfully deployed public blog feature to production",
    "Learned to use `scripts/db-migrate-safe.sh` for production migrations (post-error)",
    "Developed robust conditional querying for mixed slug/ID inputs",
    "Mastered explicit CSS variable overrides for theme consistency",
    "Implemented `nohup` for reliable background deployments over SSH",
    "Public blog is live and accessible",
    "Identified immediate next steps for domain safety and data integrity"
  ],
  "techStack": [
    "Next.js",
    "Prisma",
    "PostgreSQL",
    "tRPC",
    "TailwindCSS",
    "TypeScript",
    "Docker",
    "SSH",
    "MarkdownRenderer"
  ]
}