Target frontend architecture for Lingx. Progressive FSD Hybrid combining Next.js App Router with Feature-Sliced Design layers...
Progressive FSD Hybrid: Next.js App Router + FSD layers when complexity grows.
| Challenge | Solution |
|---|---|
| Simple pages become complex | Migrate to widgets/features when needed |
| Shared components scattered | Organize by business domain (entities) |
| Feature coupling | Strict layer imports (shared ā entities ā features ā widgets) |
| App Router integration | Keep pages thin, compose FSD components |
apps/web/src/
āāā app/ # Next.js App Router (thin pages)
ā āāā (auth)/ # Auth route group
ā āāā (dashboard)/ # Dashboard route group
ā āāā (project)/ # Project route group
ā āāā workbench/ # Translation editor
āāā widgets/ # Complex UI blocks (when needed)
ā āāā translation-editor/ # Real-time collaborative editor
āāā features/ # User actions (when needed)
ā āāā ai-translate/ # AI translation feature
āāā entities/ # Business entities (when needed)
ā āāā project/ # Project card, list item
āāā shared/ # Always use
ā āāā ui/ # shadcn/ui components
ā āāā api/ # API client, React Query hooks
ā āāā lib/ # Utilities (cn, formatters)
ā āāā hooks/ # Shared hooks
āāā components/ # Legacy (migrate over time)
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā app/ ā
ā Pages compose widgets and features, fetch data on server ā
āāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā imports
āāāāāāāāāāāāāāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā widgets/ ā
ā Complex UI blocks with internal state and multiple features ā
āāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā imports
āāāāāāāāāāāāāāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā features/ ā
ā User actions: forms, modals, buttons with side effects ā
āāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā imports
āāāāāāāāāāāāāāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā entities/ ā
ā Business objects: cards, list items, viewers ā
āāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā imports
āāāāāāāāāāāāāāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā shared/ ā
ā UI primitives, utilities, API client - no business logic ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Import Rule: Lower layers cannot import from higher layers.
| Complexity | Location | Example |
|---|---|---|
| Simple page-specific | app/[route]/_components/ |
Dashboard stats card |
| Shared UI primitive | shared/ui/ |
Button, Input, Card |
| Business entity display | entities/ |
ProjectCard, KeyRow |
| User action | features/ |
AITranslate, BulkEdit |
| Complex widget | widgets/ |
TranslationEditor |
| Document | Purpose |
|---|---|
| fsd-overview.md | FSD layers explained |
| migration-rules.md | When to adopt FSD layers |
| widgets.md | Widget patterns |
| features.md | Feature patterns |
| entities.md | Entity patterns |
| server-components.md | RSC patterns |
| hooks.md | Custom hooks |
| data-fetching.md | Server vs client fetching |
// app/(project)/projects/[id]/page.tsx
import { getProject } from '@/shared/api/projects';
import { ProjectHeader } from '@/entities/project';
import { TranslationEditor } from '@/widgets/translation-editor';
export default async function ProjectPage({ params }: Props) {
const project = await getProject(params.id);
return (
<div className="space-y-6">
<ProjectHeader project={project} />
<TranslationEditor projectId={project.id} />
</div>
);
}
// widgets/translation-editor/ui/translation-editor.tsx
'use client';
import { KeyList } from './key-list';
import { PresenceBar } from './presence-bar';
import { useRealtimeSync } from '../model/use-realtime-sync';
import { usePresence } from '../model/use-presence';
export function TranslationEditor({ projectId }: Props) {
const { keys, updateKey } = useRealtimeSync(projectId);
const { users, focusKey } = usePresence(projectId);
return (
<div className="island">
<PresenceBar users={users} />
<KeyList keys={keys} onUpdate={updateKey} onFocus={focusKey} />
</div>
);
}
// features/ai-translate/ui/ai-translate-button.tsx
'use client';
import { Button } from '@/shared/ui/button';
import { useAITranslate } from '../model/use-ai-translate';
export function AITranslateButton({ keyId, targetLanguages }: Props) {
const { translate, isPending } = useAITranslate();
return (
<Button onClick={() => translate({ keyId, targetLanguages })} disabled={isPending}>
{isPending ? 'Translating...' : 'AI Translate'}
</Button>
);
}
// entities/project/ui/project-card.tsx
import Link from 'next/link';
import type { Project } from '@lingx/shared';
interface ProjectCardProps {
project: Project;
}
export function ProjectCard({ project }: ProjectCardProps) {
return (
<Link href={`/projects/${project.id}`} className="island p-4">
<h3 className="font-medium">{project.name}</h3>
<p className="text-muted-foreground text-sm">{project.slug}</p>
</Link>
);
}
Should I use FSD layer?
Is it a simple page-specific component?
āā YES ā Keep in app/[route]/_components/
Is it used across 3+ pages?
āā YES ā Consider entities/ or features/
Does it have complex internal state?
āā YES ā Consider widgets/
Is it a reusable UI primitive?
āā YES ā Put in shared/ui/
Is it a user action with side effects?
āā YES ā Put in features/
Otherwise ā Start in _components/, migrate later
widgets/translation-editor/
āāā index.ts # Public API
āāā ui/
ā āāā translation-editor.tsx # Main widget
ā āāā presence-bar.tsx # Who's online
ā āāā key-list.tsx # Keys with translations
ā āāā key-row.tsx # Single key
ā āāā conflict-dialog.tsx # Conflict resolution
āāā model/
ā āāā use-realtime-sync.ts # WebSocket sync
ā āāā use-presence.ts # Presence state
ā āāā use-optimistic-update.ts
ā āāā types.ts
āāā lib/
āāā conflict-resolver.ts # OT/CRDT logic
// BAD - features cannot import widgets
import { TranslationEditor } from '@/widgets/translation-editor';
export function AITranslateFeature() {
return <TranslationEditor />; // ā
}
// GOOD - widgets compose features
import { AITranslateButton } from '@/features/ai-translate';
export function TranslationEditor() {
return (
<div>
<AITranslateButton keyId={keyId} /> {/* ā
*/}
</div>
);
}
// BAD - entities shouldn't have mutations
export function ProjectCard({ project }) {
const { mutate } = useDeleteProject(); // ā
return (
<Card>
<button onClick={() => mutate(project.id)}>Delete</button>
</Card>
);
}
// GOOD - features handle mutations
// features/delete-project/ui/delete-button.tsx
export function DeleteProjectButton({ projectId }) {
const { mutate, isPending } = useDeleteProject();
return <Button onClick={() => mutate(projectId)}>Delete</Button>;
}
// entities/project/ui/project-card.tsx
// Pass render prop or slot for actions
export function ProjectCard({ project, actions }) {
return (
<Card>
<h3>{project.name}</h3>
{actions}
</Card>
);
}
_components/Sources: