Expo Router Forum App — **Scaffold v2 (layout-first, `@folder:*` aliases, f/*)
Expo Router Forum App — Scaffold v2 (layout-first, @folder:* aliases, f/*)
요구 반영 사항
- 레이아웃 우선 패턴: 각 화면 파일에서는
<Stack.Screen>을 쓰지 않고, **해당 서브트리의_layout.tsx**에서<Stack.Screen name="…">으로 스크린 옵션/타이틀을 중앙 관리합니다. - 케밥 케이스: 폴더/파일은 모두
kebab-case, 컴포넌트 내부 이름만 PascalCase.
1) Directory Tree (라우팅)
app/
├─ _layout.tsx
├─ +html.tsx
├─ +not-found.tsx
│
├─ (auth)/
│ ├─ _layout.tsx
│ ├─ sign-in.tsx
│ ├─ sign-up/
│ │ ├─ _layout.tsx
│ │ ├─ index.tsx
│ │ ├─ enter-info.tsx
│ │ └─ complete.tsx
│ └─ find-credentials/
│ ├─ _layout.tsx
│ ├─ index.tsx
│ └─ reset-password.tsx
│
├─ (shell)/ # Tabs: home / f / search / inbox / my
│ ├─ _layout.tsx
│ ├─ index.tsx
│ ├─ f/
│ │ ├─ _layout.tsx
│ │ ├─ index.tsx
│ │ └─ [sub]/
│ │ ├─ _layout.tsx # ← (중요) 하위 스크린 옵션을 여기에서 일괄 지정
│ │ ├─ index.tsx
│ │ ├─ about.tsx
│ │ ├─ rules.tsx
│ │ ├─ wiki/
│ │ │ ├─ index.tsx
│ │ │ └─ [page].tsx
│ │ └─ post/
│ │ └─ (.)[post-id].tsx # 인터셉트 미리보기(탭 유지 모달)
│ ├─ search/
│ │ ├─ _layout.tsx
│ │ ├─ index.tsx
│ │ ├─ users.tsx
│ │ ├─ communities.tsx
│ │ └─ posts.tsx
│ ├─ inbox/
│ │ ├─ _layout.tsx
│ │ └─ index.tsx
│ └─ my/
│ ├─ _layout.tsx
│ ├─ index.tsx
│ ├─ my-posts.tsx
│ ├─ my-comments.tsx
│ ├─ saved.tsx
│ └─ drafts.tsx
│
├─ (stack)/ # 탭 제거 풀스크린
│ ├─ _layout.tsx
│ ├─ f/
│ │ └─ [sub]/
│ │ ├─ _layout.tsx # ← (중요) 정식 상세/작성 등 옵션 일괄 지정
│ │ ├─ submit/
│ │ │ ├─ index.tsx
│ │ │ └─ preview.tsx
│ │ ├─ comments/
│ │ │ └─ [post-id]/
│ │ │ ├─ index.tsx
│ │ │ ├─ edit.tsx
│ │ │ └─ comment/
│ │ │ └─ [comment-id].tsx
│ │ └─ mod/
│ │ ├─ _layout.tsx
│ │ ├─ queue.tsx
│ │ ├─ reports.tsx
│ │ └─ settings.tsx
│ ├─ u/
│ │ └─ [username]/
│ │ ├─ _layout.tsx
│ │ ├─ index.tsx
│ │ ├─ posts.tsx
│ │ ├─ comments.tsx
│ │ └─ relations/
│ │ ├─ followers.tsx
│ │ └─ following.tsx
│ └─ settings/
│ ├─ _layout.tsx
│ ├─ index.tsx
│ ├─ profile.tsx
│ ├─ account.tsx
│ ├─ privacy.tsx
│ ├─ notifications.tsx
│ ├─ appearance.tsx
│ └─ data/
│ ├─ export.tsx
│ └─ delete.tsx
│
├─ (modal)/
│ ├─ _layout.tsx
│ ├─ image-picker.tsx
│ ├─ share.tsx
│ ├─ report.tsx
│ └─ user-preview/[username].tsx
│
└─ p/
└─ [short-id].tsx
2) 루트 & 공통
app/_layout.tsx
import { Stack } from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context";
import getDefaultStackScreenOptions from "@folder:template/config-screen";
const LayoutRoot = () => {
return (
<SafeAreaProvider>
<Stack screenOptions={getDefaultStackScreenOptions()}>
<Stack.Screen name="(shell)" options={{ headerShown: false }} />
<Stack.Screen name="(stack)" options={{ headerShown: false }} />
<Stack.Screen name="(modal)" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
</Stack>
</SafeAreaProvider>
);
};
export default LayoutRoot;
app/+html.tsx
import React from "react";
import { ScrollViewStyleReset } from "expo-router/html";
import "../style/unistyles"; // configure는 index.ts에서도 1회 호출됨(중복 호출 금지)
const HTML = ({ children }: React.PropsWithChildren) => (
<html>
<head><ScrollViewStyleReset /></head>
<body>{children}</body>
</html>
);
export default HTML;
app/+not-found.tsx
import { Link } from "expo-router";
import { View, Text } from "react-native";
const NotFound = () => (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
<Text>페이지를 찾을 수 없습니다.</Text>
<Link href="/(shell)" style={{ marginTop: 12 }}>홈으로</Link>
</View>
);
export default NotFound;
3) (auth)
app/(auth)/_layout.tsx
import { Stack } from "expo-router";
import getDefaultStackScreenOptions from "@folder:template/config-screen";
const LayoutAuth = () => (
<Stack screenOptions={getDefaultStackScreenOptions()}>
<Stack.Screen name="sign-in" options={{ title: "로그인" }} />
<Stack.Screen name="sign-up" options={{ headerShown: false }} />
<Stack.Screen name="find-credentials" options={{ headerShown: false }} />
</Stack>
);
export default LayoutAuth;
app/(auth)/sign-in.tsx
import ScreenSignIn from "@folder:screen/sign-in/screen-sign-in";
const RouteSignIn = () => <ScreenSignIn />;
export default RouteSignIn;
app/(auth)/sign-up/_layout.tsx
import { Stack } from "expo-router";
import getDefaultStackScreenOptions from "@folder:template/config-screen";
const LayoutSignUp = () => (
<Stack screenOptions={getDefaultStackScreenOptions()}>
<Stack.Screen name="index" options={{ title: "약관 동의" }} />
<Stack.Screen name="enter-info" options={{ title: "정보 입력" }} />
<Stack.Screen name="complete" options={{ title: "가입 완료" }} />
</Stack>
);
export default LayoutSignUp;
app/(auth)/sign-up/index.tsx
import ScreenSignUpTerms from "@folder:screen/sign-up/screen-sign-up-terms";
const RouteSignUpTerms = () => <ScreenSignUpTerms />;
export default RouteSignUpTerms;
app/(auth)/sign-up/enter-info.tsx
import ScreenSignUpEnterInfo from "@folder:screen/sign-up/screen-sign-up-enter-info";
const RouteSignUpEnterInfo = () => <ScreenSignUpEnterInfo />;
export default RouteSignUpEnterInfo;
app/(auth)/sign-up/complete.tsx
import ScreenSignUpComplete from "@folder:screen/sign-up/screen-sign-up-complete";
const RouteSignUpComplete = () => <ScreenSignUpComplete />;
export default RouteSignUpComplete;
app/(auth)/find-credentials/_layout.tsx
import { Stack } from "expo-router";
import getDefaultStackScreenOptions from "@folder:template/config-screen";
const LayoutFindCreds = () => (
<Stack screenOptions={getDefaultStackScreenOptions()}>
<Stack.Screen name="index" options={{ title: "계정 찾기" }} />
<Stack.Screen name="reset-password" options={{ title: "비밀번호 재설정" }} />
</Stack>
);
export default LayoutFindCreds;
app/(auth)/find-credentials/index.tsx
import ScreenFindCreds from "@folder:screen/find-credentials/screen-find-credentials";
const RouteFindCreds = () => <ScreenFindCreds />;
export default RouteFindCreds;
app/(auth)/find-credentials/reset-password.tsx
import ScreenResetPassword from "@folder:screen/find-credentials/screen-reset-password";
const RouteResetPassword = () => <ScreenResetPassword />;
export default RouteResetPassword;
4) (shell) — Tabs & f/*
app/(shell)/_layout.tsx
import { Tabs } from "expo-router";
import Ionicons from "@expo/vector-icons/Ionicons";
const LayoutShell = () => (
<Tabs screenOptions={{ headerShown: false }}>
<Tabs.Screen name="index" options={{ title: "Home", tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? "home" : "home-outline"} color={color} size={22} />
)}} />
<Tabs.Screen name="f" options={{ title: "Forums", tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? "albums" : "albums-outline"} color={color} size={22} />
)}} />
<Tabs.Screen name="search" options={{ title: "Search", tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? "search" : "search-outline"} color={color} size={22} />
)}} />
<Tabs.Screen name="inbox" options={{ title: "Inbox", tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? "mail" : "mail-outline"} color={color} size={22} />
)}} />
<Tabs.Screen name="my" options={{ title: "My", tabBarIcon: ({ color, focused }) => (
<Ionicons name={focused ? "person" : "person-outline"} color={color} size={22} />
)}} />
</Tabs>
);
export default LayoutShell;
app/(shell)/index.tsx
import ScreenHome from "@folder:screen/home/home-screen";
const RouteHome = () => <ScreenHome />;
export default RouteHome;
app/(shell)/f/_layout.tsx
import { Stack } from "expo-router";
import getDefaultStackScreenOptions from "@folder:template/config-screen";
const LayoutF = () => (
<Stack screenOptions={getDefaultStackScreenOptions()}>
<Stack.Screen name="index" options={{ title: "포럼 탐색" }} />
<Stack.Screen name="[sub]" options={{ headerShown: false }} />
</Stack>
);
export default LayoutF;
app/(shell)/f/index.tsx
import ScreenForumsExplore from "@folder:screen/forums/screen-forums-explore";
const RouteForumsExplore = () => <ScreenForumsExplore />;
export default RouteForumsExplore;
app/(shell)/f/[sub]/_layout.tsx
import { Stack } from "expo-router";
import getDefaultStackScreenOptions from "@folder:template/config-screen";
const LayoutSub = () => (
<Stack screenOptions={getDefaultStackScreenOptions()}>
<Stack.Screen name="index" options={{ title: "포럼" }} />
<Stack.Screen name="about" options={{ title: "정보" }} />
<Stack.Screen name="rules" options={{ title: "규칙" }} />
<Stack.Screen name="wiki/index" options={{ title: "위키" }} />
<Stack.Screen name="wiki/[page]" options={{ title: "위키" }} />
<Stack.Screen name="post/(.)[post-id]" options={{ title: "미리보기", presentation: "modal" }} />
</Stack>
);
export default LayoutSub;
app/(shell)/f/[sub]/index.tsx
import ScreenSubFeed from "@folder:screen/forums/screen-sub-feed";
const RouteSubFeed = () => <ScreenSubFeed />;
export default RouteSubFeed;
app/(shell)/f/[sub]/about.tsx
import ScreenSubAbout from "@folder:screen/forums/screen-sub-about";
const RouteSubAbout = () => <ScreenSubAbout />;
export default RouteSubAbout;
app/(shell)/f/[sub]/rules.tsx
import ScreenSubRules from "@folder:screen/forums/screen-sub-rules";
const RouteSubRules = () => <ScreenSubRules />;
export default RouteSubRules;
app/(shell)/f/[sub]/wiki/index.tsx
import ScreenSubWiki from "@folder:screen/forums/screen-sub-wiki";
const RouteSubWiki = () => <ScreenSubWiki />;
export default RouteSubWiki;
app/(shell)/f/[sub]/wiki/[page].tsx
import ScreenSubWikiPage from "@folder:screen/forums/screen-sub-wiki-page";
const RouteSubWikiPage = () => <ScreenSubWikiPage />;
export default RouteSubWikiPage;
app/(shell)/f/[sub]/post/(.)[post-id].tsx
import ScreenPostPreview from "@folder:screen/post/screen-post-preview";
const RoutePostPreview = () => <ScreenPostPreview />;
export default RoutePostPreview;
app/(shell)/search/_layout.tsx
import { Stack } from "expo-router";
import getDefaultStackScreenOptions from "@folder:template/config-screen";
const LayoutSearch = () => (
<Stack screenOptions={getDefaultStackScreenOptions()}>
<Stack.Screen name="index" options={{ title: "통합 검색" }} />
<Stack.Screen name="users" options={{ title: "사용자 검색" }} />
<Stack.Screen name="communities" options={{ title: "포럼 검색" }} />
<Stack.Screen name="posts" options={{ title: "게시글 검색" }} />
</Stack>
);
export default LayoutSearch;
검색 페이지
// app/(shell)/search/index.tsx
import ScreenSearch from "@folder:screen/search/screen-search";
export default () => <ScreenSearch />;
// app/(shell)/search/users.tsx
import ScreenSearchUsers from "@folder:screen/search/screen-search-users";
export default () => <ScreenSearchUsers />;
// app/(shell)/search/communities.tsx
import ScreenSearchCommunities from "@folder:screen/search/screen-search-communities";
export default () => <ScreenSearchCommunities />;
// app/(shell)/search/posts.tsx
import ScreenSearchPosts from "@folder:screen/search/screen-search-posts";
export default () => <ScreenSearchPosts />;
app/(shell)/inbox/_layout.tsx
import { Stack } from "expo-router";
import getDefaultStackScreenOptions from "@folder:template/config-screen";
const LayoutInbox = () => (
<Stack screenOptions={getDefaultStackScreenOptions()}>
<Stack.Screen name="index" options={{ title: "Inbox" }} />
</Stack>
);
export default LayoutInbox;
app/(shell)/inbox/index.tsx
import ScreenInbox from "@folder:screen/inbox/screen-inbox";
export default () => <ScreenInbox />;
app/(shell)/my/_layout.tsx
import { Stack } from "expo-router";
import getDefaultStackScreenOptions from "@folder:template/config-screen";
const LayoutMy = () => (
<Stack screenOptions={getDefaultStackScreenOptions()}>
<Stack.Screen name="index" options={{ title: "마이 페이지" }} />
<Stack.Screen name="my-posts" options={{ title: "내 게시글" }} />
<Stack.Screen name="my-comments" options={{ title: "내 댓글" }} />
<Stack.Screen name="saved" options={{ title: "저장함" }} />
<Stack.Screen name="drafts" options={{ title: "임시글" }} />
</Stack>
);
export default LayoutMy;
마이 페이지
// app/(shell)/my/index.tsx
import ScreenMy from "@folder:screen/my/screen-my";
export default () => <ScreenMy />;
// app/(shell)/my/my-posts.tsx
import ScreenMyPosts from "@folder:screen/my/screen-my-posts";
export default () => <ScreenMyPosts />;
// app/(shell)/my/my-comments.tsx
import ScreenMyComments from "@folder:screen/my/screen-my-comments";
export default () => <ScreenMyComments />;
// app/(shell)/my/saved.tsx
import ScreenSaved from "@folder:screen/my/screen-saved";
export default () => <ScreenSaved />;
// app/(shell)/my/drafts.tsx
import ScreenDrafts from "@folder:screen/my/screen-drafts";
export default () => <ScreenDrafts />;
5) (stack) — 풀스크린(정식 상세/작성/프로필/설정)
app/(stack)/_layout.tsx
import { Stack } from "expo-router";
const LayoutStackRoot = () => <Stack screenOptions={{ headerShown: false }} />;
export default LayoutStackRoot;
app/(stack)/f/[sub]/_layout.tsx
import { Stack } from "expo-router";
import getDefaultStackScreenOptions from "@folder:template/config-screen";
const LayoutStackSub = () => (
<Stack screenOptions={getDefaultStackScreenOptions()}>
<Stack.Screen name="submit/index" options={{ title: "글쓰기" }} />
<Stack.Screen name="submit/preview" options={{ title: "미리보기" }} />
<Stack.Screen name="comments/[post-id]/index" options={{ title: "게시글" }} />
<Stack.Screen name="comments/[post-id]/edit" options={{ title: "글 수정" }} />
<Stack.Screen name="comments/[post-id]/comment/[comment-id]" options={{ title: "댓글" }} />
<Stack.Screen name="mod" options={{ headerShown: false }} />
</Stack>
);
export default LayoutStackSub;
작성/상세 라우트
// app/(stack)/f/[sub]/submit/index.tsx
import ScreenSubmit from "@folder:screen/post/screen-submit";
export default () => <ScreenSubmit />;
// app/(stack)/f/[sub]/submit/preview.tsx
import ScreenSubmitPreview from "@folder:screen/post/screen-submit-preview";
export default () => <ScreenSubmitPreview />;
// app/(stack)/f/[sub]/comments/[post-id]/index.tsx
import ScreenPostDetail from "@folder:screen/post/screen-post-detail";
export default () => <ScreenPostDetail />;
// app/(stack)/f/[sub]/comments/[post-id]/edit.tsx
import ScreenPostEdit from "@folder:screen/post/screen-post-edit";
export default () => <ScreenPostEdit />;
// app/(stack)/f/[sub]/comments/[post-id]/comment/[comment-id].tsx
import ScreenCommentPermalink from "@folder:screen/post/screen-comment-permalink";
export default () => <ScreenCommentPermalink />;
app/(stack)/f/[sub]/mod/_layout.tsx
import { Stack } from "expo-router";
import getDefaultStackScreenOptions from "@folder:template/config-screen";
const LayoutMod = () => (
<Stack screenOptions={getDefaultStackScreenOptions()}>
<Stack.Screen name="queue" options={{ title: "신고 큐" }} />
<Stack.Screen name="reports" options={{ title: "레포트" }} />
<Stack.Screen name="settings" options={{ title: "포럼 설정" }} />
</Stack>
);
export default LayoutMod;
모더레이션 라우트
// app/(stack)/f/[sub]/mod/queue.tsx
import ScreenModQueue from "@folder:screen/mod/screen-mod-queue";
export default () => <ScreenModQueue />;
// app/(stack)/f/[sub]/mod/reports.tsx
import ScreenModReports from "@folder:screen/mod/screen-mod-reports";
export default () => <ScreenModReports />;
// app/(stack)/f/[sub]/mod/settings.tsx
import ScreenModSettings from "@folder:screen/mod/screen-mod-settings";
export default () => <ScreenModSettings />;
app/(stack)/u/[username]/_layout.tsx
import { Stack } from "expo-router";
import getDefaultStackScreenOptions from "@folder:template/config-screen";
const LayoutUser = () => (
<Stack screenOptions={getDefaultStackScreenOptions()}>
<Stack.Screen name="index" options={{ title: "프로필" }} />
<Stack.Screen name="posts" options={{ title: "작성 글" }} />
<Stack.Screen name="comments" options={{ title: "작성 댓글" }} />
<Stack.Screen name="relations/followers" options={{ title: "팔로워" }} />
<Stack.Screen name="relations/following" options={{ title: "팔로잉" }} />
</Stack>
);
export default LayoutUser;
유저 라우트
// app/(stack)/u/[username]/index.tsx
import ScreenUserProfile from "@folder:screen/user/screen-user-profile";
export default () => <ScreenUserProfile />;
// app/(stack)/u/[username]/posts.tsx
import ScreenUserPosts from "@folder:screen/user/screen-user-posts";
export default () => <ScreenUserPosts />;
// app/(stack)/u/[username]/comments.tsx
import ScreenUserComments from "@folder:screen/user/screen-user-comments";
export default () => <ScreenUserComments />;
// app/(stack)/u/[username]/relations/followers.tsx
import ScreenFollowers from "@folder:screen/user/screen-followers";
export default () => <ScreenFollowers />;
// app/(stack)/u/[username]/relations/following.tsx
import ScreenFollowing from "@folder:screen/user/screen-following";
export default () => <ScreenFollowing />;
app/(stack)/settings/_layout.tsx
import { Stack } from "expo-router";
import getDefaultStackScreenOptions from "@folder:template/config-screen";
const LayoutSettings = () => (
<Stack screenOptions={getDefaultStackScreenOptions()}>
<Stack.Screen name="index" options={{ title: "설정" }} />
<Stack.Screen name="profile" options={{ title: "프로필" }} />
<Stack.Screen name="account" options={{ title: "계정" }} />
<Stack.Screen name="privacy" options={{ title: "프라이버시" }} />
<Stack.Screen name="notifications" options={{ title: "알림" }} />
<Stack.Screen name="appearance" options={{ title: "외양" }} />
<Stack.Screen name="data/export" options={{ title: "데이터 내보내기" }} />
<Stack.Screen name="data/delete" options={{ title: "데이터 삭제" }} />
</Stack>
);
export default LayoutSettings;
설정 라우트
// app/(stack)/settings/index.tsx
import ScreenSettings from "@folder:screen/settings/screen-settings";
export default () => <ScreenSettings />;
// app/(stack)/settings/profile.tsx
import ScreenSettingsProfile from "@folder:screen/settings/screen-settings-profile";
export default () => <ScreenSettingsProfile />;
// app/(stack)/settings/account.tsx
import ScreenSettingsAccount from "@folder:screen/settings/screen-settings-account";
export default () => <ScreenSettingsAccount />;
// app/(stack)/settings/privacy.tsx
import ScreenSettingsPrivacy from "@folder:screen/settings/screen-settings-privacy";
export default () => <ScreenSettingsPrivacy />;
// app/(stack)/settings/notifications.tsx
import ScreenSettingsNotifications from "@folder:screen/settings/screen-settings-notifications";
export default () => <ScreenSettingsNotifications />;
// app/(stack)/settings/appearance.tsx
import ScreenSettingsAppearance from "@folder:screen/settings/screen-settings-appearance";
export default () => <ScreenSettingsAppearance />;
// app/(stack)/settings/data/export.tsx
import ScreenDataExport from "@folder:screen/settings/screen-data-export";
export default () => <ScreenDataExport />;
// app/(stack)/settings/data/delete.tsx
import ScreenDataDelete from "@folder:screen/settings/screen-data-delete";
export default () => <ScreenDataDelete />;
6) (modal)
app/(modal)/_layout.tsx
import { Stack } from "expo-router";
import getDefaultStackScreenOptions from "@folder:template/config-screen";
const LayoutModal = () => (
<Stack screenOptions={{ ...getDefaultStackScreenOptions(), presentation: "modal" }}>
<Stack.Screen name="image-picker" options={{ title: "이미지 선택" }} />
<Stack.Screen name="share" options={{ title: "공유" }} />
<Stack.Screen name="report" options={{ title: "신고" }} />
<Stack.Screen name="user-preview/[username]" options={{ title: "사용자 정보" }} />
</Stack>
);
export default LayoutModal;
모달 라우트
// app/(modal)/image-picker.tsx
import ScreenImagePicker from "@folder:screen/modal/screen-image-picker";
export default () => <ScreenImagePicker />;
// app/(modal)/share.tsx
import ScreenShare from "@folder:screen/modal/screen-share";
export default () => <ScreenShare />;
// app/(modal)/report.tsx
import ScreenReport from "@folder:screen/modal/screen-report";
export default () => <ScreenReport />;
// app/(modal)/user-preview/[username].tsx
import ScreenUserPreview from "@folder:screen/modal/screen-user-preview";
export default () => <ScreenUserPreview />;
7) 단축 링크
app/p/[short-id].tsx
import { useEffect } from "react";
import { View, Text } from "react-native";
import { router, useLocalSearchParams } from "expo-router";
const ShortLink = () => {
const { ["short-id"]: shortId } = useLocalSearchParams<{ "short-id": string }>();
useEffect(() => {
// TODO: shortId → 정식 경로 조회 후 교체
router.replace("/(shell)");
}, [shortId]);
return (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
<Text>링크 이동 중…</Text>
</View>
);
};
export default ShortLink;
8) 페이지 파일은 “화면 컴포넌트만” 렌더
- 각 route 파일은
@folder:screen/...에서 가져온 화면 컴포넌트만 리턴. - 헤더 타이틀/옵션/프레젠테이션은 전부 **해당
_layout.tsx**에서 정의. - UI 프리미티브는
@folder:ui/*, 템플릿/공통 옵션은@folder:template/*에서 import.
이 구조로 교체하면, 라우팅 옵션의 중앙집중/예측성이 확보되고, 화면 파일은 순수 UI에 집중할 수 있다.