DEV Community

Cover image for Learning NextJS and React Full Stack Features
Junsu Park
Junsu Park

Posted on

Learning NextJS and React Full Stack Features

Currently there are many full stack frameworks or metaframeworks that build on top of a UI library. NextJS and Remix for React, SvelteKit for Svelte, Nuxt for Vue, and SolidStart for Solid. There's even a framework called T3 that builds on top of NextJS - just frameworks on top of frameworks 🤯, but I'm getting ahead of myself. These frameworks vary in terms of opinionated they are and usually provide the following:

  • 💻 Enhanced developer experience via default tooling and configurations (Eslint, TypeScript, compiler, optimizations, etc)
  • 🚏 File-based routing and nested layouts
  • 💪 Backend server that provides server-side rendering, API endpoints

NextJS is extremely popular due to how robust it is and is the recommended way to build React apps by the React devs. The NextJS team and React team work closely together to bring the next evolution of web technology. The current aim is to reduce the amount of client side Javascript and pre-render as much as possible in order to improve SEO and speed for users. This also helps reduce risk of exposing private APIs and improves developer experience. I spent the past week tinkering with NextJS to learn about these features and here's some of what I learned. I'll try to be concise and bring up only salient points.

NextJS features

No more installing 3rd party libraries and losing hours trying to configure them all together to make them work. Batteries are included here!

Brief background about me: I'm became a full stack engineer last year and have been working at Wells Fargo since January 2023. I love learning about new web technologies, design patterns, and building things.

1. Server Side Rendering

All components by default are pre-rendered on the server. This means the HTML sent to the client will have contents of the app. Previously, client side rendering was the default, in which a barebones HTML and JS is sent to the client after which JS takes over and renders the app. This was not good for two reason:

  • Bad for SEO - web crawlers 🤖 would not see the actual content since the HTML is empty
  • Slow for users if JS bundle is big 🐌 - users would see a blank site until the JS code was downloaded. Worse if data fetching then needed to be done afterwards to update UI - resulting in big waterfall and blocked UI. And slow initial load speed can lose customers.

With SSR, both of these problems are solved. Static content and data are present in the HTML. Only dynamic data (ie. requires hydration) is not present.

'use client'
import { useEffect, useState } from "react"

export default function Page() {
  const [text, setText] = useState("This will also be in source page")

  useEffect(() => {
    setText("But this won't since it can't be pre-rendered")
  }, [])

  return <div>
    <div>This will be in source page</div>
    <div>{text}</div>
  </div>
}
Enter fullscreen mode Exit fullscreen mode

Turned throttling on to show the initial HTML sent to client

Image description

After JS is downloaded and takes over (hydration), the UI is updated.

Image description

Source page shows static and initial state are pre-rendered and present in the HTML.

However, server-side fetched data IS pre-rendered.

async function getData() 
  const res = await fetch("https://swapi.dev/api/people/1");
  const data = await res.json();
  return data;
}

async function Luke() {
  const data = await getData();

  return (
    <div>
      <div>Name: {data.name}</div>
      <div>Gender: {data.gender}</div>
      <div>Height: {data.height}</div>
    </div>
  );
}

export default async function Page() {
  return <Luke />;
}
Enter fullscreen mode Exit fullscreen mode

Image description

It's at the bottom of the source page because it was streamed in later.

2. Server and Client Components

So how do you do server-side data fetching? 🤔 By default, all components are server components, ie. their code is executed on the server. So just fetch the data, simple. This also has the added benefit of not sending private API endpoints and secrets to the client.
However, code that require interactivity such as event handlers and useState can't be executed on the server since they're meant for the browser, ie. the client.

async function Luke() 
  const data = await getData();

  return (
    <div>
      <div>Name: {data.name}</div>
      <div>Gender: {data.gender}</div>
      <div>Height: {data.height}</div>
      {/* error: interactivity cannot be added to server components */}
      <button onClick={() => console.log("Something")}>Console log something</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Therefore the 'use client' directive must be written at top of the file to declare a boundary between server code and client code. All other modules imported into a client file is now part of the client bundle.
In order to reduce client-side JS and keep its size cacheable and predictable, client components should be smallest as possible and at the leaves of the component tree. Note though client components are still pre-rendered as shown above.

3. Data Fetching

By default, all data fetching done with the fetch() API are cached. Thus if identical fetch requests are made (same URL, methods, body, headers), then it will only be executed once and the data will be shared.

In order to avoid stale data, revalidation or no-caching can be set. Revalidation can be time-based or on-demand (imperatively). Revalidation will cause the server to pre-render a new HTML.

4. Server Actions

This is an ⚠️ experimental feature ⚠️ that allows interactivity in server components. It allows to send requests to server and update the UI with revalidation. The <form> element has an action prop which takes an async function with the 'use server' directive at the top of the function definition. Wrap it around a text or element. Here I wrapped it around a button.

let likes = 0
let dislikes = 0;

async function incrementLikes() {
  "use server";
  likes++;
  revalidatePath("/");
}
async function incrementDislikes() {
  "use server";
  dislikes++;
  revalidatePath("/");
}

async function Count() {
  return (
    <div>
      <div>Likes: {likes}</div>
      <form action={incrementLikes}>
        <button className="bg-blue-300 p-1 rounded-md">Increment Likes</button>
      </form>
      <div>Dislikes: {dislikes}</div>
      <form action={incrementDislikes}>
        <button className="bg-blue-300 p-1 rounded-md">
          Increment Dislikes
        </button>
      </form>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Image description
Clicking on these buttons will mutate the server data. The revalidatePath function will cause the server to re-render the page and send the HTML to the client.

Image description
I think the most practical application of this would be to send some form data to the server since form data can be accessed without state or refs. This feature is still experimental and will likely change in the future but it's a cool way to run server-side code on the client without exposing the code.

let email = ""

async function submitEmail(formData: FormData) {
  'use server'
  email = formData.get('email')?.toString()!;
  revalidatePath('/')
}

async function EmailForm() {
  return <div>
    <div>Submit your email</div>
    <form action={submitEmail}>
      <input className="bg-gray-300 p-2 rounded-md" name="email" type="email" />
    </form>
    <div>Your email: {email}</div>
  </div>
}
Enter fullscreen mode Exit fullscreen mode

Image description

Before submitting

Image description

After submitting and revalidation.

5. Suspense

What happens if a server component is fetching data but it takes a long time? It'll block the UI. I mentioned streaming HTML earlier and this is something we can do with React Suspense so the UI isn't blocked by a slow server component.

The code here has a DelayedComponent that takes 3 seconds to render. By wrapping it React Suspense and providing the fallback prop with a JSX content to be rendered while it waits for DelayedComponent to be rendered, our UI is no longer blocked and is progressively loaded.

async function DelayedComponent() 
  await new Promise((resolve) => setTimeout(resolve, 3000));
  return <div>Delayed Component</div>;
}

export default async function Page() {
  return (
    <div>
      <div>This is visible immediately</div>
      <Suspense fallback={<div>Loading Delayed Component...</div>}>
        <DelayedComponent />
      </Suspense>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Image description

Without React Suspense, I'd see a blank page for 3 seconds

Image description

You can see the fallback content in the source page at the top then the actual content streamed in at the bottom.

6. lazy

If there is a client component or library that takes a long time to load and is not critical to your UI, you can defer it from the initial client bundle with React lazy, aka code splitting.

"use client"
import { lazy, useState } from "react";

const LazyComponent = lazy(() => import("@/components/LazyComponent"));

export default function Page() {
  const [isVisible, setIsVisible] = useState(false);
  return (
    <div>
      <button onClick={() => setIsVisible(true)} className="p-2 bg-blue-300 rounded-md">Load Lazy Component</button>
      {isVisible && <LazyComponent />}
    </div>
  );
};

// separate file
export default function LazyComponent()
  return <div>Lazy Component</div>
} 
Enter fullscreen mode Exit fullscreen mode

Image description

Before clicking the button

Image description

Click the button downloads the bundle for LazyComponent on demand and renders it.

If the bundle takes a long time to download, it can block the UI. To prevent this, wrap it in Suspense. All lazily loaded components are pre-rendered by default (though in this example it's not since the initial state is not to show the component).

7. NextJS Dynamic

I highly recommend reading the doc linked since it gives a very good explanation. Dynamic is composite of Suspense and lazy. There is no need to wrap the lazy component in Suspense, and to give it a fallback UI, it's passed in as an option parameter. Pre-rendering can also de disabled by setting ssr to false..

ComponentA below will be loaded in a separate client bundle but will not be streamed in.

import dynamic from "next/dynamic";

const ComponentA = dynamic(() => import("../components/A"), 
  loading: () => <div>Loading A...</div>,
  ssr: false,
});
Enter fullscreen mode Exit fullscreen mode

Conclusion

All these features help create a more robust front-end by reducing client bundle sizes, improving initial page load, reducing waterfall and blocking of UI. By leveraging the server more, less burden is placed on the client.

If you liked what you read, follow me on LinkedIn or DEV as I continue along my journey towards building more robust web applications. My next article will be comparing the full stack frameworks I mentioned at the start of this article. I want to understand the different approaches and mental models these frameworks have developed.

Top comments (0)