DEV Community

pipipi-dev
pipipi-dev

Posted on

App Router Directory Design: Next.js Project Structure Patterns

This is Day 10 of Building SaaS Solo - Design, Implementation, and Operation Advent Calendar 2025.

Yesterday I wrote about "Why I Migrated to Better Auth." Today I'll explain App Router directory design with actual project structure examples.

πŸ“ Terminology Used in This Article

  • CSR (Client Side Rendering): A method where HTML is generated by executing JavaScript in the browser
  • SSR (Server Side Rendering): A method where HTML is generated on the server before sending to the browser. Results in faster display
  • Streaming: A method of sending HTML in chunks sequentially. Display begins without waiting for the entire page to load

πŸ“– App Router Basics

In App Router, introduced in Next.js 13+, the app/ directory structure directly maps to URLs.

app/
β”œβ”€β”€ page.tsx          β†’ /
β”œβ”€β”€ about/
β”‚   └── page.tsx      β†’ /about
└── contents/
    └── [id]/
        └── page.tsx  β†’ /contents/123
Enter fullscreen mode Exit fullscreen mode

Directories with page.tsx are recognized as pages, and dynamic segments like [id] can be used. Since you can understand URLs by looking at the directory structure, development is intuitive.

🎯 Key Design Considerations

When designing a project with App Router, I focused on these points:

  • Separation of concerns: Separate by role like app/, client/, server/
  • Layout sharing: Organize by screen type like auth pages, main app
  • Maintaining SSR: Keep layouts as Server Components

πŸ“ Overall Directory Structure

src/
β”œβ”€β”€ app/              # Routing definitions only
β”‚   β”œβ”€β”€ [locale]/     # Internationalization
β”‚   β”‚   β”œβ”€β”€ (auth)/   # Auth pages
β”‚   β”‚   β”œβ”€β”€ (main)/   # Main app
β”‚   β”‚   └── (marketing)/ # Marketing pages
β”‚   └── api/          # API endpoints
β”œβ”€β”€ client/           # Client-side code
β”‚   β”œβ”€β”€ components/   # React components
β”‚   β”œβ”€β”€ contexts/     # React Context
β”‚   β”œβ”€β”€ hooks/        # Custom hooks
β”‚   β”œβ”€β”€ lib/          # Client-only utilities
β”‚   β”œβ”€β”€ providers/    # Provider components
β”‚   └── stores/       # Zustand Store
β”œβ”€β”€ server/           # Server-side code
β”‚   β”œβ”€β”€ actions/      # Server Actions
β”‚   β”œβ”€β”€ api/          # Hono API handlers
β”‚   β”œβ”€β”€ interfaces/   # External service integration
β”‚   β”œβ”€β”€ lib/          # Server-only utilities
β”‚   β”œβ”€β”€ loaders/      # Server-side data fetching
β”‚   β”œβ”€β”€ repositories/ # Data access layer
β”‚   └── usecases/     # Business logic
β”œβ”€β”€ database/         # Drizzle ORM schemas
β”‚   β”œβ”€β”€ app_admin/    # Admin features
β”‚   β”œβ”€β”€ app_ai/       # AI features
β”‚   β”œβ”€β”€ app_auth/     # Authentication
β”‚   β”œβ”€β”€ app_billing/  # Billing
β”‚   β”œβ”€β”€ app_content/  # Content management
β”‚   β”œβ”€β”€ app_social/   # Social features
β”‚   └── app_system/   # System logs
β”œβ”€β”€ shared/           # Client/Server shared
β”‚   β”œβ”€β”€ lib/          # Shared utilities
β”‚   └── types/        # Common type definitions
β”œβ”€β”€ i18n/             # Internationalization config
└── messages/         # Translation files (ja.json, en.json)
Enter fullscreen mode Exit fullscreen mode

Separation by Role

With App Router, you can put all code in the app/ directory. However, as the project grows, it becomes harder to manage.

So I separated directories by role. This structure is inspired by the following article:

https://note.com/jujunjun110/n/na653d4120d7e

The clear separation between client/ and server/ was particularly effective. In Next.js, accidentally calling server-only modules from the client causes runtime errors, but separating at the directory level helps prevent such mistakes.

  • app/: Routing definitions only. No business logic
  • client/: Components and hooks requiring "use client"
  • server/: Server-side only code
  • database/: DB schema definitions (Drizzle ORM)
  • shared/: Pure functions and type definitions usable by both
  • i18n/, messages/: Internationalization

This separation makes it clear "where this code belongs."

Directory Structure Matching DB Schema

The database/ directory matches the PostgreSQL schema structure.

database/
β”œβ”€β”€ app_admin/        # Admin (tenants, teams, members)
β”œβ”€β”€ app_ai/           # AI features (embeddings, search_vectors)
β”œβ”€β”€ app_auth/         # Auth (users, sessions, accounts)
β”œβ”€β”€ app_billing/      # Billing (subscriptions, payment_history)
β”œβ”€β”€ app_content/      # Content management (contents, pages, tables)
β”œβ”€β”€ app_social/       # Social (bookmarks, comments, reactions)
└── app_system/       # System (activity_logs, system_logs)
Enter fullscreen mode Exit fullscreen mode

Each directory corresponds to a PostgreSQL schema. When looking for a table, thinking "which schema does it belong to?" tells you where the file is.

Since server/repositories/ also follows this schema structure, the flow from DB schema β†’ repository β†’ use case is easy to follow.

πŸ—‚οΈ Using Route Groups

Route Groups let you organize directories without affecting URLs.

app/[locale]/
β”œβ”€β”€ (auth)/           # Auth flow layout
β”‚   β”œβ”€β”€ login/
β”‚   β”œβ”€β”€ register/
β”‚   └── layout.tsx    # Auth page layout
β”œβ”€β”€ (main)/           # Main app layout
β”‚   β”œβ”€β”€ contents/
β”‚   β”œβ”€β”€ settings/
β”‚   └── layout.tsx    # Layout with sidebar
└── (marketing)/      # Marketing pages
    β”œβ”€β”€ landing/
    └── about/
Enter fullscreen mode Exit fullscreen mode

By placing layout.tsx in each Route Group, you can apply different layouts. Auth pages get a simple layout, main app gets a layout with sidebar.

URLs stay simple like /login, /contents, while layouts are separated.

🌐 API Routing Design

API endpoints are separated by role.

app/api/
β”œβ”€β”€ [[...route]]/     # Proxy to Hono API
β”œβ”€β”€ auth/             # Better Auth
β”‚   └── [...all]/
β”œβ”€β”€ og/               # OGP image generation
└── webhooks/         # Webhook reception
    └── stripe/
Enter fullscreen mode Exit fullscreen mode

Integrating Hono into Next.js

The main API is implemented with Hono. The API implementation lives in server/api/, while app/api/ only contains minimal code for connecting to Next.js.

Benefits of using Hono:

  • Flexible directory structure: Next.js Route Handlers require files under app/api/, but with Hono you can organize freely in server/api/
  • Auto-generated OpenAPI specs: Using @hono/zod-openapi, you can auto-generate API documentation (openapi.json)
  • Framework agnostic: If you migrate away from Next.js in the future, the API part can be reused
// app/api/[[...route]]/route.ts
// Only the connection to Next.js
import { handle } from 'hono/vercel';
import { app } from '@/server/api';

export const GET = handle(app);
export const POST = handle(app);
export const PUT = handle(app);
export const DELETE = handle(app);
Enter fullscreen mode Exit fullscreen mode

Separating Auth API

Better Auth is handled at a dedicated endpoint.

// app/api/auth/[...all]/route.ts
import { toNextJsHandler } from 'better-auth/next-js';
import { auth } from '@/server/lib/auth/better-auth';

const handler = toNextJsHandler(auth);

export async function GET(request: NextRequest) {
  return await handler.GET(request);
}
Enter fullscreen mode Exit fullscreen mode

/api/auth/* is handled by Better Auth, everything else by Hono.

πŸ–₯️ Designing for Server Components

The biggest advantage of App Router is Server Components. To maximize this benefit, I keep layouts as Server Components.

Before: Layout as Client Component

// ❌ If layout.tsx has "use client", all pages become CSR
"use client";

export default function MainLayout({ children }) {
  const [state, setState] = useState();
  return <div>{children}</div>;
}
Enter fullscreen mode Exit fullscreen mode

After: Keep Layout as Server Component

Keep layout.tsx itself as a Server Component, and extract only the parts needing state management as Client Components.

// βœ… Keep layout.tsx as Server Component
export default function MainLayout({ children }) {
  return (
    <div className="flex">
      <Sidebar />
      <ClientProvider>  {/* Only state management as Client Component */}
        {children}
      </ClientProvider>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
// ClientProvider.tsx
"use client";

export function ClientProvider({ children }) {
  const [state, setState] = useState();
  return <Context.Provider value={state}>{children}</Context.Provider>;
}
Enter fullscreen mode Exit fullscreen mode

This way, child pages under layout.tsx can benefit from SSR and Streaming.

πŸ”€ Bonus: Parallel Routes and Intercepting Routes

For more advanced routing, there are Parallel Routes and Intercepting Routes. In Memoreru, I use these for table content row editing.

Structure

contents/table/[id]/
β”œβ”€β”€ page.tsx           # Table detail page
β”œβ”€β”€ layout.tsx         # Parallel Routes definition
β”œβ”€β”€ @roweditor/        # Row edit panel (Parallel Route)
β”‚   β”œβ”€β”€ default.tsx    # Default (show nothing)
β”‚   └── (.)rows/       # Intercepting Route
β”‚       └── [rowId]/
β”‚           └── page.tsx
└── rows/              # Regular row edit page
    └── [rowId]/
        └── page.tsx
Enter fullscreen mode Exit fullscreen mode

How Parallel Routes Work

layout.tsx receives multiple slots.

export default function TableContentLayout({
  children,
  roweditor,
}: {
  children: ReactNode;
  roweditor: ReactNode;
}) {
  return (
    <>
      {children}   {/* Table detail */}
      {roweditor}  {/* Row edit panel */}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Effect of Intercepting Routes

(.)rows/[rowId]/ detects link clicks within the table detail page and switches to a different display method.

  • Direct access /contents/table/123/rows/456 β†’ Dedicated row edit page
  • Navigation from table β†’ Slide-in panel display

Users experience different UIs for the same URL depending on how they accessed it.

// Slide-in panel implementation
export default function RowEditorSlideIn({ params }) {
  const router = useRouter();
  const { id, rowId } = use(params);

  const handleClose = () => {
    router.back(); // Go back in history to close panel
  };

  return <TableRowEditPanel tableId={id} rowIndex={rowId} onClose={handleClose} />;
}
Enter fullscreen mode Exit fullscreen mode

βœ… Summary

Here are the key points for App Router directory design.

Key Points:

  • app/ for routing definitions only, no logic
  • Separate concerns with client/, server/, shared/
  • Separate layouts with Route Groups
  • Keep layouts as Server Components

There's no single right answer for directory design, but establishing consistent rules makes code location predictable.

Tomorrow I'll explain "Why I Migrated from MPA to SPA."


Other Articles in This Series

  • Day 9: NextAuth.js to Better Auth: Why I Switched Auth Libraries
  • Day 11: Why I Migrated from MPA to SPA: App Router Refactoring in Practice

Top comments (0)