728x90

 

들어가며

웹페이지를 어떻게 하면 인터렉티브하게 꾸밀 수 있을까 고민하면서 여러 페이지를 참고해봅니다. 그러다 토스 채용의 팀문화페이지를 보게 되었고 스크롤 애니메이션 효과를 따라해보게 되었습니다. 토스는 react-framer라이브러리를 쓴다고 들어서 동일한 라이브러리를 활용하여 학습하고, 유사하게 꾸며 보았습니다.

 

https://toss.im/career/culture

 

토스 채용

지금 바로, 토스커뮤니티에 합류하세요.

toss.im

 

 

Framer-motion이란?

framer-motion은 은 React를 위한 모션 라이브러리로 react에서 모션을 사용할 때는 아래의 명령어를 통해 설치하여 사용할 수 있습니다

npm i framer-motion

 

motion라이브러리는 그들 자신을 하이브리드 엔진을 탑재한 유일한 라이브러리라고 소개합니다.

 

motion 컴퍼넌트를 통해서  간단하게 애니메이션 효과를 구현할 수 있습니다.

import { motion } from "framer-motion";


<motion.div animate={{ opacity: 1 }} />

 

라이브러리는 다양한 애니메이션 효과와 훅을 지원하고 있어서, 이는 추후에 정리해보고 우선 토스 페이지 구현에 필요한 기능들 위주로 언급하려고 합니다.

 

구현할 내용(토스채용 팀문화 페이지는 어떻게 구현되어 있는가?)

뭔가 얼굴이 나오면 안될거 같아서.. 이렇게 캡쳐 했습니다.

 

우선 "당신은 깊게 ~~~" 문구가 나타나면서 사진이 보여집니다. 이후 스크롤을 하면 사진이 확대되면서 사진의 명도가 바뀌 내용이 변경됩니다.  그러면서  투명한 글자가 한문구씩 위로 올라가면서 나타납니다.

이후에 스크롤을 더하면 사진과 글자들이 투명하게 사라지면서, 다른 내용들이 나타납니다.

 

즉 요약하면 크게 5가지 기능으로 보입니다. 

1. 이미지와 관련 컴퍼넌트를 띄우기

2. 이후 스크롤을 끝까지 하면 이미지와 관련된 컴퍼넌트가 다 투명하게 사라지기

3. 스크롤을 통해서 사진 확대 제어 및 사진 명도제어

4. 스크롤을 통해서 글자 나타나고 사라지게 하기

5. 글자가 나타나고 사라질 때  애니메이션 효과주기

 

 

 구현하기

1.  이미지와 관련된 컴퍼넌트를 먼저 띄울려고 합니다. 

그전에 간단하게 레이아웃만 잡았습니다. 

위 이미지를 보면 투명해지고 나서 위에 여백이 있는 것을 확인할 수 있습니다. 따라서 특정 위치까지는 투명한 높이를 가진 컴퍼넌트가 존재함을 알 수 있습니다. 따라서 레이아웃은 아래와 같이 구성하였습니다.

import AnotherText from "./components/AnotherText";
import Block from "./components/Block";
import FloatingCard from "./components/FloatingCard";

const Tosslike = () => {
  return (
    <div
      style={{
        width: "100%",
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
      }}
    >
      <Block height={"5000px"} />
      <FloatingCard />
      <AnotherText />
    </div>
  );
};

export default Tosslike;

 

각각의 컴퍼넌트에 대한 설명은 아래와 같습니다

style이 적용된 div : 간단한 정렬 레이아웃만 적용하였습니다. 

Block  : 높이를 잡아줄 컴퍼넌트. props로 높이를 받아서 컴퍼넌트의 최종 높이를 결정합니다.

FloatingCard :  이미지와 텍스트등 띄워진 컴퍼넌트입니다. 스크롤 애니메이션이 적용될 부분들입니다.

AnotherText :  이미지 관련 컴퍼넌트들이 투명해지고 나서 보여질 내용들입니다. 임의의 간단한 텍스트만 적용했습니다.

 

이제 저희가 중점을 둘 FloatingCard 컴퍼넌트에 집중하겠습니다.

 

기본적으로 특정 높이까지 화면에 띄워진 css를 적용하기 위해서  position : fixed 옵션을 적용했습니다.

import { motion, useScroll, useTransform } from "framer-motion";

const FloatingCard = () => {
  const { scrollY } = useScroll();
  const opacity = useTransform(scrollY, [3500, 4300], [0.7, 0]);

  return (
    <motion.div
      style={{
        width: "100%",
        height: "100vh",
        opacity,
        position: "fixed",
        top: "0px",
        display: "flex",
        justifyContent: "center",
        alignItems: "center",
        flexDirection: "column",
        zIndex: 2,
        pointerEvents: "none",
        overflow: "hidden",
      }}
    >
    	<다른 컴퍼넌트들/>
    </motion.div>
  );
};

export default FloatingCard;

 

이때 pointerEvents를 none으로 준 이유는 이미지가 상하로 100%의 크기를 차지하기 때문에 하위에 있는 컴퍼넌트들과 상호작용을 할 수 없어집니다. 

따라서 "스크롤 이후 컴퍼넌트를 언마운트 한다" 혹은  "애초에 클릭 이벤트가 먹지 않는다" 중에서 저는 후자를 선택하였습니다.
실제로 토스에서도 이미지 관련 컴퍼넌트들이 클릭되지 않는 것을 보면 동일한 옵션일 것이라고 생각합니다.

 

2.  스크롤을 끝까지 하면 이미지와 관련된 컴퍼넌트가 다 투명하게 사라지기

 

위의 코드에서  framer-motino 관련된 훅이 보입니다.

 

useScroll : 스크롤 관련된 정보를 받을 수 있습니다. 스크롤 진행사항, 스크롤 위치.  scrollY 는 세로방향 스크롤 높이를 반환해줍니다.

 

useTransform :  하나 이상의 motion value의 값을 변환하여 새로운 motion value를 생성합니다.  

  const opacity = useTransform(scrollY, [3500, 4300], [0.7, 0]);

이를 활용하여서 y축 스크롤 높이를 활용하여서 scrollY값이 2000에서 34000일 때 동일한 비율로 0.7~0으로 변환하는 opacity라는 값을 만듭니다.

이를 통해서 스크롤이 2000px에서 3400px로 이동하면 이미지관련 컴퍼넌트들을 투명하게 만들어줍니다.

 

3. 스크롤을 통해서 사진 확대 제어 및 사진 명도제어

이제 이미지와 관련된 컴퍼넌트를 만들겠습니다. 

import { motion, useScroll, useTransform } from "framer-motion";
import FloatingCardText from "./FloatingCardText";
import FloatingImg from "./FloatingImg";

const FloatingCard = () => {
  const { scrollY } = useScroll();
  const opacity = useTransform(scrollY, [3500, 4300], [0.7, 0]);

  return (
    <motion.div
      style={{
        width: "100%",
        height: "100vh",
        opacity,
        position: "fixed",
        top: "0px",
        display: "flex",
        justifyContent: "center",
        alignItems: "center",
        flexDirection: "column",
        zIndex: 2,
        pointerEvents: "none",
        overflow: "hidden",
      }}
    >
      <FloatingImg scrollY={scrollY} />
      <motion.div
        style={{
          zIndex: 3,
          color: "red",
          fontSize: "4rem",
          textAlign: "center",
        }}
      >
        <FloatingCardText />
      </motion.div>
    </motion.div>
  );
};

export default FloatingCard;

FloatingCard에는 사라지고 나타나는 Text 컴퍼넌트까지 미리 적용시켜놓겠습니다.

 

import { motion, useTransform, MotionValue } from "framer-motion";
import React from "react";

interface FloatingImgProps {
  scrollY: MotionValue<number>;
}

const FloatingImg: React.FC<FloatingImgProps> = ({ scrollY }) => {
  const scale = useTransform(scrollY, [400, 2000], [1, 1.3]);
  const blackOpacity = useTransform(scrollY, [0, 1500], [0.2, 0.6]);

  return (
    <>
      <motion.img
        src="/partenon.jpg"
        alt="Floating img"
        width="100%"
        height="100%"
        style={{ scale, position: "absolute", top: "0px" }}
      />
      <motion.div
        style={{
          position: "absolute",
          width: "100%",
          height: "100%",
          backgroundColor: "black",
          opacity: blackOpacity,
        }}
      />
    </>
  );
};

export default FloatingImg;

 

이미지가 일정 시점부터 확대되기 때문에 scale이라는 변수를 useTransform 훅으로 만들었습니다.

그리고 toss페이지에서는 이미지 자체가 진해지는 게 아니라, 앞에 가림막의 투명도가 조절되는 느낌이라 blackOpacity라는 변수를 만들어서 검은색 배경의 막의 투명도를 조절하였습니다.

 

4. 스크롤을 통해서 글자 나타나고 사라지게 하기

이제 처음에 나오는 메인 텍스트를 사라지게 하는 효과를 주겠습니다.

import { useScroll, useTransform, motion } from "framer-motion";
import FloatingLineText from "./FloatingLineText";

const FloatingCardText = () => {
  const { scrollY } = useScroll();
  const mainTextOpacity = useTransform(scrollY, [1200, 1300], [1, 0]);

  const floatingTexts = [
    { start: 1400, text: "첫 번째 줄이 올라옵니다" },
    { start: 2100, text: "두 번째 줄이 올라옵니다" },
    { start: 2700, text: "세 번째 줄이 올라옵니다" },
  ];

  return (
    <div
      style={{
        position: "absolute",
        top: "50%",
        left: "50%",
        transform: "translate(-50%, -50%)",
        textAlign: "center",
        color: "white",
        fontSize: "2rem",
        zIndex: 3,
      }}
    >
      <motion.div style={{ opacity: mainTextOpacity }}>
        당신도 깊게 몰입했던 <br /> 무언가가 있나요?
      </motion.div>

      {floatingTexts.map(({ start, text }) => (
        <FloatingLineText
          key={start}
          scrollY={scrollY}
          start={start}
          text={text}
        />
      ))}
    </div>
  );
};

export default FloatingCardText;

 

텍스트들이 화면 중앙에 위치하도록 absolute를 통해서  위치를 잡았습니다.

그리고 마찬가지로 1200~1300 위치에서 사라지도록 opacity를 조절하였습니다.

 

이때 사라지고 나타나는 효과를 animate 훅을 통해서도 줄 수 있습니다. 다만 토스의 경우에는 스크롤 위치 기반으로 구현하였기에 해당 스타일을 주었고, animate효과로 적용할 경우 스크롤이 빠르면 글자들이 겹치는 문제가 발생합니다.

 

 

5. 글자가 나타나고 사라질 때  애니메이션 효과주기

import { motion, useTransform, MotionValue } from "framer-motion";

interface FloatingLineTextProps {
  scrollY: MotionValue<number>;
  start: number;
  text: string;
}

const FloatingLineText = ({ scrollY, start, text }: FloatingLineTextProps) => {
  const opacity = useTransform(scrollY, [start, start + 100], [0, 1]);
  const y = useTransform(scrollY, [start, start + 100], [30, 0]);

  return (
    <motion.div style={{ opacity, y, marginTop: "1.5rem" }}>{text}</motion.div>
  );
};

export default FloatingLineText;

 

마지막으로 다른 줄들은 map을 통해  스크롤 위치와 작성할 text를 props로 받도록 하였습니다.

그리고 y는  motion에서 translateY 역할을 하는 style 속성입니다. 이를 통해서 나타날 떄는 아래에서 위로 올라오도록, 사라질 때는 내려가도록 하였습니다.

 

 

 

참고 페이지 및 문서

https://toss.im/career/culture

 

토스 채용

지금 바로, 토스커뮤니티에 합류하세요.

toss.im

https://www.npmjs.com/package/framer-motion

 

framer-motion

A simple and powerful JavaScript animation library. Latest version: 12.6.5, last published: a day ago. Start using framer-motion in your project by running `npm i framer-motion`. There are 6000 other projects in the npm registry using framer-motion.

www.npmjs.com

 

728x90

들어가며...

useNavigation훅을 통해서 상태를 저장할 수 있습니다. 해당 상태는 uri에 직접적으로 반영이 되지 않는 상태입니다.
따라서  새로고침 시 막연하게 초기화 될 거라고 생각을 했었습니다. 하지만 실제 테스트 해 본 결과 상태가 초기화가 되지 않았습니다. 심지어 ChagGPT 조차 상태가 초기화 된다고 하더군요..



import { useNavigate } from 'react-router';

const Home = () => {
  const navigate = useNavigate();

  return (
    <div>
      <h1>Home</h1>
      <button
        onClick={() => navigate('/test', { state: { message: '저장됨' } })}
      >
        Test 페이지로 이동
      </button>
    </div>
  );
};

export default Home;

 

message에 저장됨이라는 상태를 여전히 가지고 있었습니다. useNavigate훅은 결국 History API 기반의 라이브러리 이기에 혹시 HTML 파일에서도 확인해보았고 여전히 초기화되지 않음을 확인하였습니다.

 

따라서 History API의 state의 스펙에 대해서 알아보기로 생각을 하였습니다.

결론은 브라우저에서는 Session History에  상태를 기록을 하는데, 해당 상태는 복원이 가능해야 합니다. 따라서 메모리에 기록이 되어야 하기 때문에, 새로고침을 하여도 다른 탭을 열거나 기존 탭을 닫는 방식이 아니면 상태가 유지가 됩니다.


History에 대해서

시작점은 History였습니다. 당연히 MDN에는 상태의 생명주기에 대한 언급이 있지 않을까라는 막연한 기대감에 접근하게 되었습니다.

History API는 history 전역 객체를 통해 브라우저 세션 히스토리(웹 익스텐션 히스토리와 혼동해서는 안 됩니다.)에 대한 접근을 제공합니다. 사용자의 방문 기록을 앞뒤로 탐색하고, 방문 기록 스택의 내용을 조작할 수 있는 유용한 메서드와 속성을 노출합니다.

 

해당 문서에서 History State에  대해 찾아보니 "History.state 속성은 현 history에 해당하는 state값을 나타냅니다." 라는 내용이 끝이더군요.

따라서 더 깊게 파고들기 위해서는 HTML의 History 스펙을 확인할 필요가 있었습니다.

history.state : Returns the classic history API state of the active session history entry, deserialized into a JavaScript value.

 

이제 Session history에 대해서 뭔저 알 필요가 생겼네요.

 

세션 히스토리 엔트리는 여러 가지 데이터를 포함하는 구조체(struct)로, 다음과 같은 요소를 가집니다.

  • step: 방문 순서를 나타내는 0 이상의 정수 또는 "pending"(초기값 "pending").
  • URL: 해당 히스토리 엔트리에 저장된 페이지의 URL.
  • document state: 해당 페이지의 문서 상태.
  • classic history API state: window.history.pushState()나 window.history.replaceState()를 통해 저장된 직렬화된 상태 데이터. 기본적으로 StructuredSerializeForStorage(null)로 초기화됨.
  • navigation API state: 네비게이션 API 관련 직렬화된 상태 데이터. 초기값은 StructuredSerializeForStorage(undefined).
  • navigation API key: 네비게이션 API에서 사용하는 UUID(고유 식별자).
  • navigation API ID: 개별 네비게이션 요청을 구분하는 또 다른 UUID(고유 식별자).
  • scroll restoration mode: 스크롤 복원 방식 ("auto"가 기본값).
  • scroll position data: 문서에서 스크롤이 복원될 위치 데이터.
  • persisted user state: 사용자가 입력한 데이터 등 브라우저가 유지할 상태 (기본값 null).

history의 state의 경우에 Session history에 보관되고 있습니다. 이때 직렬화를 거치는데 직렬화를 2가지 목적으로 사용됩니다.

 

1. 미리 처리된(preparsed) 상태를 URL에 저장하는 것

  • 간단한 경우에는, URL에 상태를 저장하면 개발자가 따로 파싱(parsing) 하지 않아도 된다.
  • 다만, URL이 사용자들 사이에서 공유될 수도 있으므로, 어떤 경우든 결국에는 파싱이 필요하다.
  • 하지만 URL에 저장된 상태를 빠르게 가져올 수 있으므로, 약간의 최적화 효과가 있다.

2. URL에 저장하지 않고, 현재 문서(Document)에서만 필요한 상태를 저장하는 것

  • 특정 상태는 새로운 문서가 열리면 다시 생성해야 하므로 URL에 저장하기 적절하지 않다.
  • 이런 경우, 직렬화된 상태를 이용해 브라우저 히스토리에서 해당 상태를 보존하고 복원할 수 있다.

 

이후 공식스펙에는 7.4.6.5 Persisted history entry state 이 존재하는데, 유저의 편의를 위해 히스토리를 복원할 수 있는 방법이 존재해야 합니다.

 

공식스펙을 확인했지만,, 아직까지 왜 새로고침 시에도 값이 유지되는지에 대해서는 명확하지 않습니다.

이를 위해서 크롬에서는 세션 히스토리를 어떻게 사용하는지에 대해서 확인해보았습니다.

 

크롬의 History Session

브라우저의 세션 히스토리는 각 탭에서 발생한 탐색을 추적하여 뒤로 가기/앞으로 가기 탐색과 세션 복원을 지원한다. 이는 chrome://history 같은 기록(history)과는 다른데, 기록은 프로필의 수명 동안 사용자가 방문한 주요 프레임의 URL을 모든 탭에 걸쳐 저장한다.

 

위에서 스펙에서 확인한대로 복원 기능을 가지고 있네요.

 

그리고 복원 기능에 대해서 확인하면 아래와 같습니다.

탭의 공동 세션 히스토리는 유지되므로, Chromium을 다시 시작하거나, 탭을 닫은 후, 또는 다른 기기에서 탭을 복원할 수 있다. 이를 위해 각 NavigationEntry와 그 내부 FrameNavigationEntries 트리의 상태를 PageState 객체 및 기타 메타데이터를 사용하여 직렬화해야 한다. 새로운 값을 안전하게 저장하고 복원하는 방법에 대해서는 Modifying Session History Serialization을 참고하라.

 

마지막으로  Modifying Session History Serialization 에서 직렬화 방법을 변경시 디스크에 남아있는 데이터에 의한 호완 문제가 생길 수 있다는 점을 경고하고 있는데, 이를 통해서 크롬에서 disk에 Session History가 저장되어있음을 알 수 있습니다. 따라서 새로고침시에도 History.state가 유지가 됩니다. 

Note that changing the serialization format is high risk and should be approached carefully. Mistakes or missed steps can cause backwards compatibility problems, because the effects can continue to live on disk between different versions of Chromium

 

 

느낀점

사실 처음에는 History.state의 생명주기 라고 검색하면 금방 해결할 수 있는 지식이라 생각했습니다. 하지만 관련 키워드로는 검색했을 때 나오지 않더군요. 공식 스펙을 보는 것도 제로초님이 영상에서 보시는 걸 본적은 있지만 직접 본 적은 없어서 많이 어렵게 느껴졌네요. 해당 스펙이 존재한다는 것이 명시되었을 뿐 어떻게 구현할 지는 브라우저마다 다를 수 있다는 사실도 어려웠습니다. 영어를 번역하면서 열심히 읽었는데, 혹시 오역이 있었을까에 대한 막연한 걱정도 조금 남아있네요. 혹시 잘못된 부분이 있으면 지적부탁드립니다 ㅠ

 

그래도 덕분에 history가 트리구조가 아니라 리스트 구조가 되었다는 등 많은 정보도 확인할 수 있었네요..
또한 ChatGpt 를 사용할 떄 좀 더 유의할 필요성을 느꼈습니다.  사실 새로고침 시 history.state가 초기화 되는지 여부 정보는 고차원적인 지식은 아니라 생각했는데 ChatGpt가 잘못 알려주더군요.
그리고 확실하게 새로고침에서 history.state가 사라지진 않는다는 지식을 얻어가네요.

참고링크

.https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/modifying_session_history_serialization.md

 

Chromium Docs - Modifying Session History Serialization

Modifying Session History Serialization Note: Please expand these steps as needed. See also NavigationEntryImpl comments for how to save and restore values outside of PageState, which is less common. Overview The following (non-exhaustive) steps are requir

chromium.googlesource.com

https://html.spec.whatwg.org/multipage/nav-history-apis.html#the-history-interface

 

HTML Standard

 

html.spec.whatwg.org

https://developer.mozilla.org/ko/docs/Web/API/History

 

History - Web API | MDN

History 인터페이스는 브라우저의 세션 기록, 즉 현재 페이지를 불러온 탭 또는 프레임의 방문 기록을 조작할 수 있는 방법을 제공합니다.

developer.mozilla.org

https://chromium.googlesource.com/chromium/src/+/master/docs/session_history.md#persistencehttps://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/modifying_session_history_serialization.md

 

Chromium Docs - Modifying Session History Serialization

Modifying Session History Serialization Note: Please expand these steps as needed. See also NavigationEntryImpl comments for how to save and restore values outside of PageState, which is less common. Overview The following (non-exhaustive) steps are requir

chromium.googlesource.com

 

728x90

과거에 next.js의 튜토리얼을 진행하면서 얕게 streaming에 대해 학습한 적이 있습니다. 그 당시에 깊게 이해하지 못한 부분에 대해서 다시 한번 정리하고자 포스팅을 작성하게 되었습니다.

 

전통적인 SSR의 단점

SSR은 서버에서 완성된 HTML을 클라이언트에 전송해줍니다. SSR이 진행되는 과정은 아래와 같습니다.

1. 먼저, 특정 페이지에 필요한 모든 데이터가 서버에서 가져와집니다.
2. 그런 다음, 서버에서 해당 페이지의 HTML을 렌더링합니다.
3. 이후, 페이지의 HTML, CSS, 그리고 JavaScript가 클라이언트로 전송됩니다.
4. 전송된 HTML과 CSS를 사용하여 비상호작용 UI가 화면에 표시됩니다.
5. 마지막으로, React가 UI를 하이드레이션(hydration) 하여 상호작용할 수 있도록 만듭니다.

그렇기에 한 페이지 내에서도 모든 구역이 동시에 작업이 완료되진 않습니다. 하지만 우린 완성된 HTML을 받기 때문에, 하나라도 오래 걸리는 작업이 생기면 HTML을 받지 못하는 문제점을 겪게 됩니다.

출처 : https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming#what-is-streaming

 

 

이를 보완해주는 것이 Next.js의 Streaming 기능입니다.

 

Streaming이란?

Next.js에서는 Streaming을 아래와 같이 정의합니다

Streaming 데이터를 전송하는 기술로, 하나의 라우트를 더 작은 "청크(chunks)"로 분할하여 서버에서 클라이언트로 준비되는 대로 점진적으로 스트리밍할 수 있도록 합니다.
Streaming is a data transfer technique that allows you to break down a route into smaller "chunks" and progressively stream them from the server to the client as they become ready.

 

 

그 결과  Time To First Byte (TTFB) , First Contentful Paint (FCP) ,Time to Interactive (TTI) 를 향상 시킬 수 있습니다.

 

상호작용 시작 시간  |  Lighthouse  |  Chrome for Developers

Lighthouse의 상호작용 시작 시간 측정항목과 이 측정항목을 측정하고 최적화하는 방법을 알아보세요.

developer.chrome.com

 

Streaming 사용방법

Streaming은 크게 2가지의 경우로 사용법이 구분됩니다.

1. 페이지단위로 사용하기

2. 컴퍼넌트단위로 사용하기

 

1. 페이지 단위로 사용하기

export default function Loading() {
  // You can add any UI inside Loading, including a Skeleton.
  return <LoadingSkeleton />
}

 

Lodaing.tsx 파일은 React Suspense를 기반으로 하는 Next.js의 특별한 파일로, 페이지 콘텐츠가 로드되는 동안 대신 표시할 Fallback UI를 생성할 수 있도록 해줍니다. 보통 Layout.tsx에 위치합니다.

 

2. 컴퍼넌트 단위로 사용하기

import { Suspense } from 'react'
import { PostFeed, Weather } from './Components'
 
export default function Posts() {
  return (
    <section>
      <Suspense fallback={<p>Loading feed...</p>}>
        <PostFeed />
      </Suspense>
      <Suspense fallback={<p>Loading weather...</p>}>
        <Weather />
      </Suspense>
    </section>
  )
}

 

이 경우에는 react에서 제공하는 Suspense를 활용하여 Streaming을 사용할 수 있습니다.

 

 

 

참고 링크

 

https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming#what-is-streaming

 

Routing: Loading UI and Streaming | Next.js

Built on top of Suspense, Loading UI allows you to create a fallback for specific route segments, and automatically stream content as it becomes ready.

nextjs.org

https://nextjs.org/learn/dashboard-app/streaming

 

App Router: Streaming | Next.js

Improve your application's loading experience with streaming and loading skeletons.

nextjs.org

 

 

 

 

+ Recent posts