[Next.js] Page Router - 스타일링, 레이아웃

Yeonju·2025년 1월 20일

Next.js

목록 보기
5/10
post-thumbnail

Next.js에서 CSS를 사용해서 스타일링을 적용해보자

🍀 스타일링

인라인 스타일 설정

export default function Home() {
  return <h1 style={{ color: "blue" }}>index</h1>;
}

인라인으로 스타일 설정을 할 수도 있지만 아무래도 코드가 길어질수록 가독성이 좋지 않다

CSS ?

그렇다면 css 파일을 import 하면?

import "./index.css";

export default function Home() {
  return <h1>index</h1>;
}

Global CSS cannot be imported from files other than your Custom <App>.이라는 에러가 뜬다.

Next.js에서는 _app.tsx가 아닌 별도의 페이지 파일에서 css 파일을 import 할 수 없다. (클래스 네임 충돌 방지)

CSS module

대신 CSS 파일을 모듈처럼 사용할 수 있도록 해주는 CSS module을 사용하면 된다.

import style from "./index.module.css";

export default function Home() {
  return (
    <>
      <h1 className={style.h1}>index</h1>
      <h2 className={style.h2}>test - h2</h2>
    </>
  );
}
.h1 {
  color: blue;
}
.h2 {
  color: aqua;
}

다음과 같이, 클래스 네임이 중복되지 않도록 자동으로 유니크한 이름으로 변환을 시켜준다.


🍀 글로벌 레이아웃

_app.tsx 파일의 코드가 글로벌 레이아웃으로 적용된다.
그렇다면 글로벌 레이아웃을 변경하려면 _app.tsx 파일의 코드를 변경하면 될까?

import "@/styles/globals.css";
import type { AppProps } from "next/app";

export default function App({ Component, pageProps }: AppProps) {
  return (
    <div>
      <header>헤더</header>
      <main>
        <Component {...pageProps} />
      </main>
      <footer>푸터</footer>
    </div>
  );
}

이런 식으로 _app.tsx 파일에 코드를 모두 작성하게 되면 가독성이 뭔가 별로다!

파일 분리하기

대신 src/components/global-layout.tsx 파일을 만들어서 아래와 같이 분리해준다.

_app.tsx

import GlobalLayout from "@/components/global-layout";
import "@/styles/globals.css";
import type { AppProps } from "next/app";

export default function App({ Component, pageProps }: AppProps) {
  return (
    <GlobalLayout>
      <Component {...pageProps} />
    </GlobalLayout>
  );
}

global-layout.tsx

import { ReactNode } from "react";

export default function GlobalLayout({ children }: { children: ReactNode }) {
  return (
    <div>
      <header>헤더</header>
      <main>{children}</main>
      <footer>푸터</footer>
    </div>
  );
}

🙆‍ 이제 CSS를 적용해보자

모든 페이지에 적용되는 CSS 파일은, _app.tsx에 import되는 global.css 파일이다.

global.css

html,
body {
  margin: 0px;
  padding: 0px;
  background-color: rgb(250, 250, 250);
}

margin,padding을 0px로 해서 여백이 없게 하고,
background-color를 연한 회색으로 설정해준다.

그리고 global-layout.tsx에 import할 css 모듈 파일을 만들어준다.

global-layout.module.css

.container {
  background-color: white;
  max-width: 600px;
  min-height: 100vh;
  margin: 0 auto;

  box-shadow: rgba(100, 100, 100, 0.2) 0px 0px 29px 0px;
  padding: 0px 15px; /* 상하, 좌우 */
}

auto : 자동 크기 조정

vh : Viewport Height, 뷰포트의 높이(브라우저 창의 높이)를 기준으로 한 단위
min-height: 100vh : 화면 높이의 100%를 차지

box-shadow : 요소에 그림자 효과를 추가하는 속성
box-shadow: rgba(100, 100, 100, 0.2) 0px 0px 29px 0px

  • rgba(100, 100, 100, 0.2) : 어두운 회색(100,100,100) 투명도 20%
  • X-offset: 0px (가로 방향 이동 없음) Y-offset: 0px (세로 방향 이동 없음)
  • Blur Radius: 29px (흐림 정도) Spread Radius: 0px (그림자의 크기 변화 없음)
.header {
  height: 60px;
  font-weight: bold;
  font-size: 18px;
  line-height: 60px;
}

.header > a {
  color: black;
  text-decoration: none;
}

.main {
  padding-top: 10px;
}

.footer {
  padding: 100px 0px; /* 상하, 좌우 */
  color: gray;
}

line-height : line-box의 높이를 설정 (줄 간격을 늘이거나 줄일 수 있다)
.header > a : .header 내부의 직계 자식인 a 태그만 선택

global-layout.tsx

import Link from "next/link";
import { ReactNode } from "react";
import style from "./global-layout.module.css";

export default function GlobalLayout({ children }: { children: ReactNode }) {
  return (
    <div className={style.container}>
      <header className={style.header}>
        <Link href={"/"}>📚 ONEBITE BOOKS</Link>
      </header>
      <main className={style.main}>{children}</main>
      <footer className={style.footer}>제작 @hohobooks</footer>
    </div>
  );
}

글로벌 레이아웃 작업이 완료된 모습


🍀 페이지별 레이아웃

검색 기능을 담당하는 search를 만든다면 어떻게 할까?
search바는 home, search 결과 페이지에만 존재해야 하고, book 상세보기 페이지에서는 없어야 한다.

이렇게 일부 페이지에만 존재하는 요소를 만들려면 어떻게 해야 할까?

1. 레이아웃 파일 생성

src/components 디렉터리에
searchable-layout.tsx 파일을 만든다.

import { ReactNode } from "react";

export default function SearchableLayout({
  children,
}: {
  children: ReactNode;
}) {
  return (
    <div>
      <div>서치바가 들어갈 자리</div>
      {children}
    </div>
  );
}

상단에 적용될 서치바와 home, search 페이지를 props로 받아서 넣을 children을 넣어준다.

2. 레이아웃이 적용될 페이지에 메서드 추가

서치바가 존재하는 페이지에서 export default로 내보낸 함수 객체에 메서드를 추가해준다.

자바스크립트 함수는 객체이기 때문에 메서드를 추가할 수 있다. (참고 : 객체 자료형 by 한 입 북스)

import SearchableLayout from "@/components/searchable-layout";
import { ReactNode } from "react";

...

Page.getLayout = (page: ReactNode) => {
  return <SearchableLayout>{page}</SearchableLayout>;
};

getLayout 메서드는 ReactNode 타입인 page를 인자로 받아, page를 children으로 전달한 SearchableLayout 컴포넌트를 리턴한다.

3. _app.tsx 파일에서 메서드 호출

_app.tsx

import GlobalLayout from "@/components/global-layout";
import "@/styles/globals.css";
import { NextPage } from "next";
import type { AppProps } from "next/app";
import { ReactNode } from "react";

type NextPageWithLayout = NextPage & {
  getLayout?: (page: ReactNode) => ReactNode;
};

export default function App({
  Component,
  pageProps,
}: AppProps & { Component: NextPageWithLayout }) {
  const getLayout = Component.getLayout ?? ((page: ReactNode) => page);

  return <GlobalLayout>{getLayout(<Component {...pageProps} />)}</GlobalLayout>;
}

한 줄씩 알아보자!

(1) NextPageWithLayout 타입 정의

type NextPageWithLayout = NextPage & {
  getLayout?: (page: ReactNode) => ReactNode;
};

NextPage 타입을 확장한 타입으로, 페이지 컴포넌트가 getLayout 메서드를 선택적으로 가진다.

(2) App 함수의 인자

export default function App({
  Component,
  pageProps,
}: AppProps & { Component: NextPageWithLayout }) {...

Component가 NextPageWithLayout이라는 추가적인 속성을 가질 수 있게 한다.
AppProps는 App 컴포넌트에 기본적으로 전달되는 props 타입을 정의하며, Component와 pageProps를 포함한다.

(3) App 함수 내부

const getLayout = Component.getLayout ?? ((page: ReactNode) => page);

현재 페이지의 getLayout 메서드를 찾아서 저장한다.
만약 현재 페이지가 getLayout을 제공하지 않으면, 기본적으로 page를 그대로 반환한다.

??는 널 병합 연산자로 a ?? b는 a가 null || undefined가 아니라면 a가, 맞다면 b가 반환된다.

(4) App 함수의 리턴값

return <GlobalLayout>{getLayout(<Component {...pageProps} />)}</GlobalLayout>;

getLayout 함수를 호출한 뒤, 인자로 현재 페이지 컴포넌트 ( <Component {...pageProps} /> )를 전달한다.

요약

  1. 현재 페이지가 NextPageWithLayout 타입의 component로서 App 함수의 인자로 들어온다.
  2. App 함수 내부에 getLayout 함수를 정의한다. 현재 페이지가 getLayout 메서드를 가졌다면 getLayout 메서드를 저장하고, 아니라면 page를 그대로 반환하는 함수를 저장한다.
  3. getLayout 함수를 호출하며 인자로 현재 페이지를 전달해서 받은 리턴값(컴포넌트)를 최종적으로 리턴한다.

4. Search바 기능 구현

✨ 검색어를 state에 저장하기

import { useState } from "react";

export default function SearchableLayout(...) {
  
  const [search, setSearch] = useState("");
  
  const onChangeSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
    setSearch(e.target.value);
  };

  return (
    <div>
      <div className={style.searchbar_container}>
        <input
          value={search}
          onChange={onChangeSearch}
          placeholder="검색어를 입력하세요 ..."
        />
        <button>검색</button>
      </div>
      {children}
    </div>
  );
}

사용자가 입력중인 검색어를 search state에 저장하자.

  • input에 입력된 값이 변할 때 onChange 이벤트 핸들링을 위한 함수를 정의한다.
  • onChangeSearch 함수의 인자인 이벤트 객체 eReact.ChangeEvent<HTMLInputElement> 타입으로 정의한다.
  • e.target.valuesetState의 인자로 전달해서 state를 갱신한다.

✨ submit 기능

import { useRouter } from "next/router";

export default function SearchableLayout(...) {
  
  const router = useRouter();
  
  const onSubmit = () => {
    if (!search) return;
    router.push(`search?q=${search}`);
  };
  
  return (
    <div>
      ...
        <button onClick={onSubmit}>검색</button>
      ...
  );
}

사용자가 검색어를 입력하고 검색 버튼을 눌렀을 때, search 결과 페이지로 이동시키자.

  • 검색 버튼을 눌렀을 때 onSubmit 함수를 실행한다.
  • onSubmit 함수에서는 검색어가 없으면( !search ) 그냥 리턴시키고, 검색어가 있다면
    쿼리에 담아 페이지를 이동시킨다.

✨ 엔터키로 submit 가능하게 하기

export default function SearchableLayout(...) {
  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Enter") {
      onSubmit();
    }
  };
  
  return (
    	...
        <input
          value={search}
		  onKeyDown={onKeyDown}
          onChange={onChangeSearch}
          placeholder="검색어를 입력하세요 ..."
        />
        ...
  );
}

검색 버튼을 눌렀을 때 뿐만 아니라, 엔터키를 눌렀을 때에도 submit이 동작하게 하자.

  • 이벤트 핸들링을 위해 onKeyDown 함수를 정의한다. 인자인 이벤트 객체 eReact.KeyboardEvent<HTMLInputElement> 타입으로 정의한다.
  • e.keyEnter라면 onSubmit 함수를 실행한다.

✨ 새로고침해도 state 유지하기

import { useState, useEffect } from "react";

export default function SearchableLayout(...) {
  const q = router.query.q as string;

  useEffect(() => {
    setSearch(q || "");
  }, [q]);
  
  ...
}

새로고침시 페이지는 그대로지만, 검색창의 검색어가 사라지는 문제를 해결하자.

  • router.query.q는 현재 URL의 쿼리 파라미터 q의 값을 가져온다.
  • useEffect를 사용해서 q의 값이 변할 때마다 setSearch를 호출한다.
  • 쿼리 파라미터 q가 있다면 q로, 없다면 빈 문자열""search 상태를 설정한다.

router.query.q는 기본적으로 string | string[] | undefined 타입이다. 하지만 setState의 인자로는 string만 전달할 수 있어서 에러가 난다. 이 문제는 타입 단언 as string으로 해결할 수 있다.

✨ 검색어 변경이 없는 경우 페이지 이동 X

const onSubmit = () => {
  if (!search || q === search) return;
  router.push(`search?q=${search}`);
};

q === search : 만약 URL의 쿼리 파라미터 q와 현재 search 상태가 같다면, 페이지를 새로고침하거나 이동할 필요가 없으므로 리턴한다.

5. Search바 CSS 작업

searchable-layout.module.css

.searchbar_container {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}

display: flex : 내부 요소들이 수평으로 나열
gap : 내부 요소들 간의 간격

.searchbar_container > input {
  flex: 1;
  padding: 15px;
  border-radius: 5px;
  border: 1px solid rgb(220, 220, 220);
}

.searchbar_container > input : searchbar_container 내부의 직계 자식인 input 태그만 선택

flex: 1 : 가능한 공간을 모두 차지한다
border-radius : 모서리가 둥근 정도

.searchbar_container > button {
  width: 80px;
  border-radius: 5px;
  border: none;
  background-color: rgb(37, 147, 255);
  color: white;
  cursor: pointer;
}
return (
    <div>
      <div className={style.searchbar_container}>
        ...
      </div>
      {children}
    </div>
  );

페이지별 레이아웃 작업이 완료된 모습


강의 : 한 입 크기로 잘라먹는 Next.js

2.8강 다시 듣기! 라고 메모해두려고 했는데 페이지 라우터였구나..😂

profile
햄스터와 개발을 좋아합니다.

0개의 댓글