Next.js Modular Architecture Skill
This skill enforces the project architecture for all Next.js code.
Core Guidelines
1. Technology Stack
- Package Manager:
pnpmonly. - Authentication: AuthJS.
- UI: Shadcn UI + Tailwind CSS.
- Validation: Zod for schemas, forms, server actions, API responses, and DTO validation.
- State Management: Zustand when client-side state is needed.
- Data Security Pattern: Next.js Data Access Layer (DAL) using
_internal.
2. Global Directory Structure
Global shared folders MUST be prefixed with _:
_components/
_enums/
_fonts/
_hooks/
_interfaces/
_internal/
_providers/
_utils/
Folder responsibilities
_components/: Shared reusable UI components._hooks/: Shared client hooks._interfaces/: Shared TypeScript interfaces/types that are safe to import from client or server._schemas/: Shared Zod schemas only when truly global._utils/: Generic utilities that are safe for client and server._internal/: Server-only Data Access Layer, API clients, authorization helpers, mappers, and private business logic.
3. Mandatory Data Access Layer Convention
3.1 _internal is the official DAL
The _internal folder is the only allowed place for:
- External backend API calls.
- Direct access to
process.env, except publicNEXT_PUBLIC_*config when unavoidable. - Authenticated fetch wrappers.
- Authorization checks.
- Backend
snake_caseto frontendcamelCasemapping. - DTO creation.
- Server-only business rules.
- Private backend response types.
Every file inside _internal that accesses private data MUST include:
import "server-only";
Example:
import "server-only";
import { auth } from "@/auth";
export async function getAccessTokenOrThrow() {
const session = await auth();
if (!session?.accessToken) {
throw new Error("Unauthorized");
}
return session.accessToken;
}
3.2 Client Components MUST NOT import _internal
Never import _internal from:
- Client Components.
- Zustand stores.
- Browser hooks.
- Shared UI components using
'use client'.
Bad:
"use client";
import { getUsers } from "../_internal/users.dal";
Good:
// Server Component
import { getUsersDTO } from "./_internal/users.dal";
import { UsersTable } from "./_components/users-table";
export default async function Page() {
const users = await getUsersDTO();
return <UsersTable users={users} />;
}
4. Modular Domain Architecture
Modules live inside app/.
Each module folder MUST contain:
app/module-name/
_actions/
_components/
_internal/
_schemas/
layout.tsx
page.tsx
error.tsx
4.1 Module folder responsibilities
_actions/
Server Actions only.
Rules:
- Must include
'use server'. - Must validate all input with Zod.
- Must not contain raw fetch calls.
- Must not contain large business logic.
- Must not return raw backend responses.
- Must delegate data access and mutations to
_internal.
Example:
"use server";
import "server-only";
import { revalidatePath } from "next/cache";
import { createUserSchema } from "../_schemas/create-user.schema";
import { createUser } from "../_internal/users.dal";
export async function createUserAction(input: unknown) {
const payload = createUserSchema.parse(input);
await createUser(payload);
revalidatePath("/users");
return {
success: true,
};
}
_internal/
Module-specific DAL.
Rules:
- Must include
import 'server-only'. - Performs auth and authorization checks.
- Calls backend APIs.
- Maps backend responses to safe DTOs.
- Returns only the fields needed by UI.
- Never returns secrets, tokens, raw backend records, or unnecessary fields.
Example structure:
_internal/
users.dal.ts
users.mapper.ts
users.api-types.ts
users.permissions.ts
_schemas/
Zod schemas for:
- Forms.
- Server Action input.
- Search params.
- Backend API responses.
- DTOs when needed.
_components/
Private module UI components.
Rules:
- Components receive safe DTOs only.
- Client components must receive minimal props.
- Client components must not receive raw backend models.
5. External Go API Integration
The Go backend is accessed only through the DAL.
5.1 Base URL
const API_URL = process.env.NEXT_PUBLIC_API_URL;
5.2 Mandatory headers
Every authenticated backend request MUST include:
headers: {
Authorization: `Bearer ${session.accessToken}`,
'Content-Type': 'application/json',
}
5.3 Central fetch wrapper
Create a server-only backend fetch helper.
Example:
import "server-only";
import { auth } from "@/auth";
type ApiFetchOptions = Omit<RequestInit, "headers"> & {
headers?: HeadersInit;
};
export async function apiFetch(path: string, options: ApiFetchOptions = {}) {
const session = await auth();
if (!session?.accessToken) {
throw new Error("Unauthorized");
}
const baseUrl = process.env.NEXT_PUBLIC_API_URL;
if (!baseUrl) {
throw new Error("Missing NEXT_PUBLIC_API_URL");
}
return fetch(`${baseUrl}${path}`, {
...options,
headers: {
Authorization: `Bearer ${session.accessToken}`,
"Content-Type": "application/json",
...options.headers,
},
});
}
5.4 Backend response validation
Every backend response MUST be validated with Zod before being used.
Example:
import "server-only";
import { z } from "zod";
import { apiFetch } from "@/app/_internal/api-fetch";
const BackendUserSchema = z.object({
id: z.string(),
first_name: z.string(),
last_name: z.string(),
email: z.string().email(),
created_at: z.string(),
});
const UsersResponseSchema = z.array(BackendUserSchema);
export async function getUsersDTO() {
const response = await apiFetch("/users");
if (!response.ok) {
throw new Error("Failed to fetch users");
}
const raw = await response.json();
const users = UsersResponseSchema.parse(raw);
return users.map((user) => ({
id: user.id,
firstName: user.first_name,
lastName: user.last_name,
email: user.email,
}));
}
6. DTO Rules
6.1 Never expose raw backend models
Do not pass raw backend responses directly to components.
Bad:
const user = await getRawUser();
return <UserProfile user={user} />;
Good:
const user = await getUserProfileDTO();
return <UserProfile user={user} />;
6.2 DTOs must be minimal
Only return fields the UI needs.
Bad:
return {
id: user.id,
email: user.email,
passwordHash: user.password_hash,
accessToken: user.access_token,
role: user.role,
createdAt: user.created_at,
};
Good:
return {
id: user.id,
email: user.email,
role: user.role,
};
6.3 Always map snake_case to camelCase
Backend:
created_at;
first_name;
last_name;
Frontend DTO:
createdAt;
firstName;
lastName;
7. Authentication and Authorization
7.1 Auth checks belong inside the DAL
Every DAL function that reads or mutates private data MUST verify authentication.
Example:
const session = await auth();
if (!session?.user || !session.accessToken) {
throw new Error("Unauthorized");
}
7.2 Authorization must be resource-specific
Never rely only on page-level auth.
Bad:
export default async function Page() {
const session = await auth()
if (!session?.user) {
redirect('/login')
}
return <AdminPanel />
}
Good:
export async function deleteUser(userId: string) {
const session = await auth();
if (!session?.user) {
throw new Error("Unauthorized");
}
if (!session.user.permissions.includes("users.delete")) {
throw new Error("Forbidden");
}
await apiFetch(`/users/${userId}`, {
method: "DELETE",
});
}
Server Actions are reachable as server endpoints, so every action and mutation path must re-check authentication and authorization through the DAL.
8. Server Actions Rules
Server Actions are thin controllers.
They may:
- Validate input with Zod.
- Call DAL functions.
- Return safe action states.
- Revalidate paths/tags.
- Redirect when needed.
They must not:
- Call the Go API directly.
- Access
process.envdirectly. - Return raw backend objects.
- Trust client input.
- Contain authorization logic duplicated from DAL.
- Expose secrets or private fields.
Example:
"use server";
import "server-only";
import { updateUserSchema } from "../_schemas/update-user.schema";
import { updateUser } from "../_internal/users.dal";
export async function updateUserAction(input: unknown) {
const payload = updateUserSchema.parse(input);
await updateUser(payload);
return {
success: true,
};
}
9. Server Components Rules
Server Components may call DAL functions directly.
Example:
import { getUsersDTO } from "./_internal/users.dal";
import { UsersTable } from "./_components/users-table";
export default async function UsersPage() {
const users = await getUsersDTO();
return <UsersTable users={users} />;
}
Rules:
- Server Components may import
_internal. - Server Components must pass only safe DTOs to Client Components.
- Server Components must not pass session, access token, backend raw records, or private data to Client Components.
10. Client Components Rules
Client Components must be explicit:
"use client";
Client Components may:
- Render UI.
- Manage local UI state.
- Use Zustand stores.
- Call Server Actions.
- Receive safe DTOs.
Client Components must not:
- Import
_internal. - Access
process.envprivate variables. - Call the Go API directly with access tokens.
- Receive raw backend records.
- Receive session tokens.
11. Zustand Persistence
Use Zustand for client-side state management when needed.
11.1 Persist middleware
Stores requiring browser persistence MUST use persist.
Example:
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
type Store = {
count: number;
inc: () => void;
};
export const useCounterStore = create<Store>()(
persist(
(set) => ({
count: 1,
inc: () => set((state) => ({ count: state.count + 1 })),
}),
{
name: "counter-store",
},
),
);
11.2 SSR hydration safety
Components using persisted stores must avoid hydration mismatch.
Use a hydration hook:
"use client";
import { useEffect, useState } from "react";
export function useHasHydrated() {
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => {
setHasHydrated(true);
}, []);
return hasHydrated;
}
Usage:
"use client";
import { useHasHydrated } from "@/app/_hooks/use-has-hydrated";
import { useCounterStore } from "../_stores/counter.store";
export function Counter() {
const hasHydrated = useHasHydrated();
const { count, inc } = useCounterStore();
if (!hasHydrated) {
return null;
}
return (
<div>
<span>{count}</span>
<button up</button>
</div>
);
}
12. Error Handling
12.1 DAL errors
DAL functions should throw safe, generic errors.
Bad:
throw new Error(`Backend failed with token ${session.accessToken}`);
Good:
throw new Error("Failed to fetch users");
12.2 UI errors
Each module must include:
error.tsx
Use it to render safe user-facing errors.
13. Caching Rules
Use caching carefully.
Rules:
- Do not cache private user-specific data globally.
- Do not cache responses that depend on session unless using a private/request-safe strategy.
- Prefer request-level memoization using React
cache()for repeated server-side reads. - Revalidate paths/tags after mutations.
- Never cache access tokens.
Example:
import "server-only";
import { cache } from "react";
import { auth } from "@/auth";
export const getCurrentSession = cache(async () => {
const session = await auth();
if (!session?.user || !session.accessToken) {
throw new Error("Unauthorized");
}
return session;
});
14. Security Rules
Mandatory:
- Treat all client input as untrusted.
- Validate
formData, JSON bodies, URL params, route params, andsearchParamswith Zod. - Re-check auth inside DAL mutations.
- Return only safe DTOs.
- Never expose access tokens to Client Components.
- Never expose raw backend responses to Client Components.
- Never import server-only code into Client Components.
- Never access secrets outside
_internal. - Prefer
import 'server-only'for every DAL file. - For expensive mutations, consider rate limiting.
15. Recommended Module Example
app/users/
_actions/
create-user.action.ts
update-user.action.ts
delete-user.action.ts
_components/
users-table.tsx
user-form.tsx
_internal/
users.dal.ts
users.mapper.ts
users.api-types.ts
users.permissions.ts
_schemas/
create-user.schema.ts
update-user.schema.ts
user-response.schema.ts
layout.tsx
page.tsx
error.tsx
16. Architecture Decision Rules
When creating new code:
- If code accesses backend data, put it in
_internal. - If code mutates data from UI, create a Server Action in
_actionsthat calls_internal. - If code validates input or response shape, use Zod in
_schemas. - If code renders UI shared only by the module, put it in module
_components. - If code renders UI shared across modules, put it in global
_components. - If code uses browser APIs, it must be a Client Component or client hook.
- If code needs localStorage persistence, use Zustand with
persist. - If data crosses from server to client, it must be a safe DTO.
- If backend uses
snake_case, frontend receivescamelCase. - If unsure whether a function is server-only, place it in
_internaland addimport 'server-only'.
17. Absolute Prohibitions
Do not:
- Use npm, yarn, or bun.
- Call backend APIs directly from Client Components.
- Import
_internalinto Client Components. - Pass raw backend records to Client Components.
- Return raw backend responses from Server Actions.
- Skip Zod validation for client input.
- Skip Zod validation for backend responses.
- Access private environment variables outside
_internal. - Duplicate authorization logic across UI components.
- Trust page-level auth as sufficient protection for mutations.