ProgrUmar Logo
Module 11: Capstone — Full-stack Blog Platform

Project Setup & Architecture Planning

Duration: 12 mins

Blueprint First

Before writing code, define your data model (Users, Posts, Tags, Comments), your URL structure, and which components will be Server vs Client. A clear plan prevents expensive refactors later.

Project Setup & Architecture Planning

To solidify your mastery of Next.js 15, we will build a production-grade Capstone Project: a full-stack blogging platform with a CMS dashboard, rich text publishing, categories, comment sections, dynamic sitemaps, open-graph image generators, and automated deployments. Building complex systems without planning leads to code spaghetti and circular imports. In this lesson, we will lay out the system architecture, design our database schemas using Drizzle ORM, structure our directories with Route Groups, and map out the boundaries between Server and Client Components.


1. Designing the Data Model Schema

Our blogging engine requires four core tables: User (authors and readers), Post (articles), Category (organizing articles), and Comment (community reader interactions).

Here is the database schema definition using Drizzle ORM:

// src/db/schema.ts
import { pgTable, serial, text, timestamp, integer, boolean } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
  role: text('role').$type<'ADMIN' | 'READER'>().default('READER'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: text('title').notNull(),
  slug: text('slug').notNull().unique(),
  content: text('content').notNull(),
  published: boolean('published').default(false).notNull(),
  authorId: integer('author_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
  categoryId: integer('category_id').references(() => categories.id, { onDelete: 'set null' }),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
});

export const categories = pgTable('categories', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
  slug: text('slug').notNull().unique(),
});

export const comments = pgTable('comments', {
  id: serial('id').primaryKey(),
  content: text('content').notNull(),
  postId: integer('post_id').references(() => posts.id, { onDelete: 'cascade' }).notNull(),
  authorId: integer('author_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

And the table relationship definitions:

// Relationships configuration
export const postsRelations = relations(posts, ({ one, many }) => ({
  author: one(users, { fields: [posts.authorId], references: [users.id] }),
  category: one(categories, { fields: [posts.categoryId], references: [categories.id] }),
  comments: many(comments),
}));

export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
  comments: many(comments),
}));

2. Folder Structures using Next.js Route Groups

Our application contains two distinct sections: the public-facing blog and the protected CMS administrator dashboard. We isolate their layouts, navigation styles, and stylesheets using **Route Groups** (folders named inside parentheses, which Next's router ignores when computing paths):

src/
├── app/
│   ├── (public)/         ← Public-facing blog layout & routes
│   │   ├── layout.tsx    ← Standard user layout (header, footer)
│   │   ├── page.tsx      ← Blog home page
│   │   └── posts/[slug]/ ← Blog article page
│   ├── (admin)/          ← Protected administrator dashboard
│   │   ├── layout.tsx    ← Admin sidebar layout
│   │   └── admin/        ← Exposed at /admin
│   │       ├── page.tsx  ← Admin dashboard overview
│   │       └── new/      ← Draft editor page
│   ├── api/              ← Route Handlers (stripe, webhooks)
│   └── layout.tsx        ← Root HTML shell (providers, font load)

3. Client vs. Server Component Mapping

To keep our browser bundle sizes small and maintain rapid page loads, we map Server vs Client boundaries based on interactivity needs:

Component Target Component Type Rationale
Article Page (posts/[slug]) Server Component Queries the database directly and outputs raw HTML. No client JavaScript is required to read text.
Blog Navigation Bar Server Component Displays static menu items and logo links. Doesn't require React states.
Rich-Text Editor Panel Client Component Utilizes DOM selectors, captures text keystrokes, and requires active React event listeners.
Comments Panel Client Component Handles dynamic comment submissions and displays optimistic states without reloading pages.

4. Architecture Guidelines for clean modules

To prevent circular dependency errors (e.g. File A imports File B, which imports File A), enforce these architectural guidelines:

  • Separate DB Configurations: Place the database client in a separate module (e.g. src/db/index.ts) so that schema modifications or server actions can import the db client without pulling in layout styles.
  • Keep Client Hooks in Leaf Nodes: Do not add 'use client' at the top of page layout wrappers. Keep pages as Server Components, and import interactive Client Components (like search forms or toggle buttons) as child elements.
  • Co-locate Validations: Store Zod validator schemas inside a dedicated directory (e.g. src/lib/validations/) so that both client-side form libraries and server-side route API validations can reference the exact same schemas.

5. Common Gotchas

  • Mixing Route Groups and Dynamic Paths: Placing a route group folder (like (public)) and a dynamic segment (like [slug]) in a way that overlaps paths. For example, structuring app/(public)/[slug]/page.tsx and app/admin/page.tsx. Next.js will throw a build error if the dynamic parameter matches admin, creating routing ambiguities. Keep protected dashboards nested (e.g. app/(admin)/admin/page.tsx).
  • Forgetting Cascade Rules in Foreign Keys: Creating table relationships (like deleting a user profile) without configuring cascade deletion rules. If you attempt to delete a user from your database, the DBMS will throw a constraint error because post tables still reference that user ID. Use onDelete: 'cascade' on foreign keys to clean up related records automatically.

Key Takeaways

  • Plan database layouts and relationships early to avoid database migrations down the line.
  • Leverage Route Groups to organize admin dashboard layouts and public blog templates cleanly.
  • Evaluate components case-by-case to keep dynamic interactivity restricted to client leaf nodes.
  • Maintain a dedicated database instantiation module to prevent circular dependency imports.
  • Define delete cascade constraints on foreign key definitions to avoid database orphan records.

With our data models planned and our folder structures created, we are ready to write our first line of project code. In the next lesson, we will cover Building the CMS Dashboard, setting up Auth.js middleware gates, embedding rich-text editors, and handling image uploads via Server Actions.

Chat with us