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
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)
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)
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/
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/
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 inserver/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);
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);
}
/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>;
}
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>
);
}
// ClientProvider.tsx
"use client";
export function ClientProvider({ children }) {
const [state, setState] = useState();
return <Context.Provider value={state}>{children}</Context.Provider>;
}
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
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 */}
</>
);
}
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} />;
}
✅ 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)