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)