
An illustration of the topic, rather than an actual product screenshot or exact architecture diagram.
Moving a validation SPA into a Next.js service involved more than rebuilding its screens. It needed SSR, SEO, multiple languages, form validation, and admin pages. Similar pages and forms would follow, and I did not want to repeat the same instructions to Claude Code each time.
Before documenting implementation patterns, I wrote down how to read a Jira task, find relevant code, and review the result. Recurring corrections went into that skill later. Using it showed me that a concise rule also needed to explain where it did not apply.
Find an existing screen before building another
The skill described the sequence from planning to committing.
Jira task와 acceptance criteria 확인
-> 관련 route, API, 기존 pattern 탐색
-> 변경 파일과 검증 방법을 plan으로 작성
-> 작은 단위로 구현
-> typecheck, lint, build, route smoke test
-> diff 기반 code review
-> 지적 반영 후 commitThe plan had to identify the route and the closest existing implementation. It also described loading, failure, and empty states. The reference gave the agent an existing form or drawer to follow before building another.
Review covered hydration, accessibility, missing translations, and changes outside the task. Commits were limited to verified units of work. Separate skills held examples and pitfalls for recurring forms and queries, leaving each plan to explain how its task differed from those examples.
Narrow the rule against passing props
One early rule said, “do not pass a value as a prop if a hook can provide it.” It cuts out unrelated components forwarding locale without using it. Applied literally, though, the rule could bind otherwise independent UI to context.
Props also communicate a component's inputs and carry values computed by its parent. I narrowed the rule to forwarding values already supplied by a shared provider.
Do not pass values already owned by common providers, such as router, locale, or theme, through unrelated intermediate components. Pass parent-computed screen data and explicit component inputs as props.
// 관계없는 component 두 개가 locale을 전달만 한다.
function Page() {
const locale = useLocale();
return <Section locale={locale} />;
}
function Section({ locale }: { locale: Locale }) {
return <ProductCard locale={locale} />;
}
// 실제 소비 지점에서 app context를 읽는다.
function ProductCard() {
const locale = useLocale();
return <ProductName locale={locale} />;
}
// ProductName은 명시적인 입력을 받는 순수 UI로 남긴다.
function ProductName({ locale }: { locale: Locale }) {
// ...
}Here ProductCard reads locale, while ProductName renders from the locale it receives. Section no longer forwards the value, but ProductName still has an explicit input.
I made a similar distinction for viewport changes. CSS media queries handle changes in layout or visibility. Client logic is reserved for cases where the viewport changes fetching or behavior.
Moving code to useMounted left the first paint empty
The existing code included a component that hid its children until mounting finished.
function ClientOnly({ children }: { children: React.ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted ? children : null;
}When I asked for its removal, the agent moved the same logic into useMounted. The component disappeared, but the reason for the empty first paint remained. Changing a name or file location had not made the change I wanted.
Having Suspense in the layout does not justify removing every mount gate either. Suspense supplies fallback UI for suspended work; it does not resolve window access or viewport-based hydration mismatches.
I rewrote the guidance around those causes. A hook such as useSearchParams, which can switch static rendering to client rendering, belongs in a small Suspense-wrapped subtree. Browser APIs are read in handlers or effects so the first server and client renders match. CSS handles presentation differences, while genuinely non-SSR components such as external widgets retain a client-only boundary.
function SearchSection() {
return (
<Suspense fallback={<SearchSkeleton />}>
<SearchResults />
</Suspense>
);
}Read submission results from the mutation
Some code moved submission to useMutation but still copied success status and the last submitted values into local state and a ref. The mutation already held what the screen needed.
const mutation = useMutation({ mutationFn: submitForm });
const submittedValues = mutation.variables;
const isComplete = mutation.isSuccess;If submissions run one at a time and the screen shows only the latest result, it can read variables and isSuccess directly. There is no second state to update on success and reconcile on reset.
Separate state is appropriate when submissions can overlap or submitted values must survive a mutation reset. A snapshot retained long after success also has a different lifetime. The rule became avoiding copies only when the mutation already retains the value for as long as the screen needs it.
Keep recurring corrections in the skill
After migrating routes, I read their changes together. Awaiting asynchronous page props, distinguishing SEO queries from streamed queries, and handling late form defaults were likely to recur. I also added guidance for accessible icon-button names and tooltips, and checking message keys across locales.
A correction specific to one screen stayed in that code. Applying a rule with many exceptions to every task could produce the same kind of mistake as removing useful props.
I kept task completion separate from code generation. Changed routes needed typecheck, lint, and a production build, followed by desktop and mobile screen checks, server HTML, metadata, and translation keys. Forms also needed validation, submission, and failure checks, with unrelated changes removed in the final diff review. Adding more guidance to the skill did not replace those checks.
References: Next.js useSearchParams and Suspense, React Suspense


