DEV Community

Zenpage
Zenpage

Posted on

I Built a Multi-Tenant Website Builder with One Next.js App. Here's the Architecture.

I spent the last few months building Zenpage, a free website builder for authors. One codebase serves both the dashboard (where authors build their sites) and every published author site - on subdomains and custom domains - using hostname-based routing in a single Next.js 15 deployment.

This post breaks down the architecture decisions, the tricky parts, and what I'd do differently.

The stack

  • Runtime: Bun
  • Framework: Next.js 15 App Router, React 19
  • Styling: Tailwind CSS v4, shadcn/ui
  • API: tRPC with SuperJSON
  • Database: PostgreSQL with Drizzle ORM
  • Storage: Cloudflare R2 (S3-compatible)
  • Email: Resend
  • Infrastructure: Cloudflare (CDN, SSL, DNS)

No external CMS. No headless anything. One repo, one deploy, one database.

The core problem: serving N websites from one app

Zenpage needs to do two things:

  1. Serve the dashboard at zenpage.io (auth, editing, settings)
  2. Serve every author's published site at authorname.zenpage.io or authorname.com

Most people solve this with separate apps or microservices. I wanted one codebase. Next.js middleware makes this surprisingly clean.

Hostname routing

The idea is simple: middleware intercepts every request, inspects the hostname, and decides where it goes. Dashboard hostnames pass through to the dashboard routes. Everything else gets rewritten to a dynamic route that looks up the site by hostname in the database and renders the right template.

The actual implementation handles a bunch of edge cases I didn't anticipate: www. redirects, canonical URL enforcement, and graceful fallbacks when the App Router sends corrupted flight state headers (a fun one to debug). But the core pattern is just hostname inspection + URL rewriting.

Next.js route groups make this work cleanly. The dashboard and published sites live in completely separate route groups within the same app. No shared layouts leaking between them, no conditional rendering based on "which mode are we in." They're effectively two apps that share a codebase and a database.

If you're building any kind of multi-tenant app with Next.js, this pattern is worth exploring. It scales well and keeps the codebase much simpler than running separate deployments.

The template system

Zenpage has 5 templates, each with multiple color schemes - 21 combinations total. I needed a system where:

  • Templates feel genuinely different (not just recolored clones)
  • Color schemes are hot-swappable without touching component code
  • Each template has its own font pairing and layout personality

Each template is defined in a typed registry and rendered as React components that receive a colors object as props. The colors get applied via inline styles and CSS custom properties, so swapping a color scheme is just changing the props - no component code changes needed.

One useful pattern: appending a two-character hex alpha to a color value (${colors.primary}20) gives you transparent variants without needing rgba() or extra color tokens. 20 is ~12% opacity, 45 is ~27%, and so on. Keeps the color system minimal while allowing subtle layering.

The Tailwind v4 gotcha

This took me longer to figure out than I'd like to admit. Tailwind v4 uses CSS @layer for its reset (Preflight), which means globals.css styles don't reliably override Preflight due to cascade layer ordering. Any raw HTML rendered via dangerouslySetInnerHTML (like sanitized markdown blog content) gets stripped of heading sizes, margins, list styles - everything.

The workaround: inline <style> tags inside components that render sanitized HTML. These bypass Tailwind's PostCSS processing entirely and apply styles directly to the rendered content.

function BlogContent({ sanitizedHtml }: { sanitizedHtml: string }) {
  return (
    <article>
      <style>{`
        .blog-content h1 { font-size: 2em; margin: 0.67em 0; font-weight: bold; }
        .blog-content h2 { font-size: 1.5em; margin: 0.83em 0; font-weight: bold; }
        .blog-content p { margin: 1em 0; }
        .blog-content ul { list-style: disc; padding-left: 2em; }
        /* ... */
      `}</style>
      <div className="blog-content" dangerouslySetInnerHTML={{ __html: sanitizedHtml }} />
    </article>
  )
}
Enter fullscreen mode Exit fullscreen mode

It's not elegant, but it works reliably. All content is sanitized server-side before rendering. If you're using Tailwind v4 with user-generated HTML content, this is probably the simplest solution.

The image pipeline

Authors upload cover art and headshots in every format and size imaginable. The upload endpoint validates, resizes, converts to WebP, and stores the result on Cloudflare R2. Immutable filenames mean the CDN can cache forever.

A few things I learned the hard way about image processing with Sharp:

  • Never upscale. Sharp's withoutEnlargement option is essential. Without it, a 600px-wide headshot gets stretched into a blurry mess at your target size.
  • WebP quality settings matter more than you'd think for book covers specifically. Compression that looks fine on photos can introduce visible artifacts on cover art with sharp text and flat colors. Spend time finding your sweet spot.
  • Use different size limits for different content types. A headshot doesn't need the same resolution as a hero blog image. This sounds obvious but it's easy to just pick one max width and call it done.

Book import

One of the features I'm most happy with. An author types one ISBN, and Zenpage pulls their entire catalog: covers, descriptions, and metadata.

The key architectural lesson here is about resilience. Book data APIs are unreliable. Any single source will be down, incomplete, or missing your specific book at some point. The solution is querying multiple sources in parallel using Promise.allSettled() rather than Promise.all(), so one failure doesn't block the others. If your app depends on third-party data APIs, design for partial failure from the start.

Database design

A few schema decisions that shaped the rest of the architecture:

One website per user. A unique constraint on the user ID in the websites table makes this a hard one-to-one relationship. You can't accidentally create a second website. This simplifies the entire API - every authenticated request implicitly refers to "the user's website." No site selector, no multi-site management, no ambiguity.

Composite slug uniqueness. In a multi-tenant app, slugs need a composite unique index scoped to the site. Two different authors can both have a book slugged the-great-gatsby, but within a single site, slugs must be unique. This is easy to get wrong if you're used to single-tenant apps where a global unique slug is fine.

Cascade deletes for clean teardown. When a user deletes their account, everything cascades: website, content, blog posts, events. No orphaned rows, no cleanup jobs. Drizzle makes this declarative in the schema definition.

Per-site SEO

Each published site gets its own dynamically generated sitemap, RSS feed, and robots.txt. These are Next.js route handlers that query the database and return XML.

A few decisions worth noting:

  • RSS feeds return 404 when empty. If an author hasn't published any blog posts, the feed endpoint returns a 404 instead of an empty feed. This prevents search engines from indexing a useless page.
  • lastmod uses actual timestamps. Sitemaps pull from the real updatedAt field on each record, not the generation time. Crawlers use this to decide whether to re-index a page.
  • Tag-based cache invalidation. Sitemaps and feeds are cached with ISR so they don't hit the database on every request, but invalidate when content changes.

tRPC

The API uses tRPC with SuperJSON for serialization and a middleware chain for auth. Two tiers: public and authenticated. The auth middleware narrows the context type so ctx.user is guaranteed non-null in protected procedures. TypeScript enforces this at compile time.

SuperJSON as the transformer is one of those small decisions that saves hours of debugging. Dates, Maps, and Sets serialize correctly over the wire. No more JSON.parse(JSON.stringify(date)) nonsense.

Writing a custom markdown parser (and why I regret it)

Instead of pulling in a markdown library, I wrote a custom parser. It handles headers, emphasis, links, images, blockquotes, code blocks, nested lists, and GFM tables.

It works. But every edge case I didn't think of becomes a bug report. Nested blockquotes inside list items. Tables with pipe characters in cell content. Code blocks containing markdown syntax. Each one is a small fix, but they add up.

If I were starting over, I'd use unified with remark and rehype. The custom parser was a fun exercise, but it's not the kind of code you want to maintain forever on a production app. Lesson learned: only build from scratch when the existing tools genuinely don't fit your use case.

What I'd do differently

Use a proper markdown library. Covered above. The custom parser was educational but not practical long-term.

Separate the template CSS more cleanly. Inline styles work but make template components verbose. The Tailwind v4 layer issue pushed me toward inline styles, but there's probably a better middle ground using CSS modules or scoped styles.

Add analytics from day one. I punted on analytics and now I'm retrofitting it. Building the tracking tables into the initial schema would have saved a migration.

Try it

Zenpage is live at zenpage.io. If you're an author (or know one), you can create a free site in about 15 minutes.

If you're a developer interested in multi-tenant Next.js architecture or building a template system with CSS variables, I hope this breakdown was useful. Happy to answer questions in the comments.

Top comments (0)