NextJS 13 / Nextjs 14

Router

Static Routes

https://example.com/hello

Directory Structure:

  • app/hello/page.tsx

Moving to another page

  1. Next Link
<Link href={'/about'}> Hello</Link>
  1. useRouter Programatic change of route
'use client';

import { useRouter } from 'next/router';

export default function Home() {
  const router = useRouter();

  const someEvent = () => {
    router.push('/someOtherPage');
    router.back();
    router.reload();
  }

  return <div></div>
}

[Dynamic] Routes

https://example.com/hello/:id

Directory Structure with [param_name]:

  • app/hello/[id]/page.tsx

Access Route Parameters

  • In server components it is available in the params prop
interface Props {
  params: {
    id: string;
  }
}

export default async function Home({ params: { id }}) {
  const book = await fetch(`https://.../api/book/${id}`);
  return {
    <h1>{book}</h1>
  }
}
  • In client components it is available using useParams:
'use client';
import { useParams } from 'next/navigation';

export default function Home() {
  const { id } = useParams();
  return {
    <h1>{id}</h1>
  }
}

IMPORTANT: A route can have as many params as you need!

https://example.com/hello/:id/:name/:genre

Directory Structure with [param_one]/[param_two] etc.:

  • app/hello/[id]/[name]/[genre]/page.tsx
interface Props {
  params: {
    id: string;
    name: string;
    genre: string;
  }
}
export default async function Home({ params: { id, name, genre }}) {
  // ...
}

Similarly for client usage.

Dynamic [...CatchAll] Route

https://example.com/hello/:id/:id/:id

Directory Structure with [...param_name]:

  • app/hello/[...id]/page.tsx

Route (group)

Directory Structure with (group_name):

  • app/(marketing)/about/page.tsx
    • https://example.com/about
  • app/(marketing)/blog/page.tsx
    • https://example.com/blog
  • app/(shop)/account/page.tsx
    • https://example.com/account

@Parallel Route

Creates a named slot that can be accessed in the parent layout

(..)Intercepting Route

This pattern renders the default page for the server side rendering, but then uses an entirely different page for the client side rendering.

Directory structure to show modal if on feed:

  • [feed]/(..)photo/[id]/page.tsx
  • [feed]/layout.tsx
  • photo/[id]/page.tsx
  • photo/[id]/layout.tsx

Route handlers

If you want to use an endpoint as API, just create the directory structure and add a route.tsx instad of a page.tsx

// GET POST PUT PATCH DELETE HEAD OPTIONS
export async function GET() {
  return new Response('Hello!');
}

export async function POST(request: Request) {
  const data = await request.json();
  // treat
  return new Response('Updated');
}

NOTE: They are always server side.

Request / Response vs NextRequest / NextResponse

// by default run in nodejs runtim
// export const runtime = 'nodejs' by default
// can be changed to different things
export const runtime = 'edge';

import { NextRequest, NextResponse } from 'next/server';

export async function PATCH(request: NextRequest) {
  const url = request.nextUrl;
  return NextResponse.json({ message: 'I want to send JSON!' });
}

Layouts

Layouts wrap pages.

IMPORTANT: layout UI and state persist accross page changes. So if that's not what you want, you can reinitialize the layout component for every navigation using template.tsx instead of layout.tsx.

Root Layout

By default the application has a RootLayout that defines the entire UI for the entire applicaiton.

interface ComponentProps {
  children: React.ReactNode;
}

export default function RootLayout({ children }: ComponentProps) {
  return (
    <html lang="en" data-mode="light">
      <body>
        <main className="relative">
          {children}
        </main>
      </body>
    </html>
  );
}

Nested Layout

You can nest layouts, by for example having a route with the main navigation from the RootLayout, and an aditional sidebar for a dashboard.

Directory structure:

  • app/layout.tsx
  • app/dashbaord/page.tsx
  • app/dashbaord/layout.tsx
  • app/dashbaord/navbar.tsx
interface ComponentProps {
  children: React.ReactNode;
}

export default function Layout({ children }: ComponentProps) {
  return (
    <div id="dashbaord">
      <NavMenu /> // does not need to rerender
      {children}
    </div>
  );
}

Template Layouts

template.tsx instead of layout.tsx forces a rerender of the layout for every navigation.

Rendering and SEO

The mental model is very simple now.

By default you want to make every page.tsx a server component.

What is a bit confusing is that in that page, you might want to use client side features in a server component like useEffect and that won't compile for a server component.

  useEffect(() => { // client-side react : FAIL on server side components
    console.log('Component mounted');
  }, []);

  return (
    <div id="dashbaord">
      {children}
    </div>
  );

The solution is to move the interactive code into its own client component file (with "use client").

Move to Client Component

You move the interactive-code from a **server-side ** component into it's own client-component.

Server modules:

// page.tsx
import { ServerComponent } from '...';

export default function Page() {
  return <ServerComponent />;
}

// ServerComponent.tsx
import { ClientComponent } from '...';

export default function ServerComponent() {
  return <ClientComponent />;
}

Client modules:

// ClientComponent.tsx
"use client"

import { MyButton } from '...';

export default function ClientComponent() {
  return <MyButton onClick="..." />;
}

// MyButton.tsx
"use client"

export default function MyButton({ clickHandler }) {
  return <button onClick={clickHandler} />;
}

You want to move the client components to the leaves of the tree. This allows your top level pages to be server rendered and SEO optimized the most parts.

page.tsx settings

Caching mechanism

  • Dynamic Data Fetching: data fetched on each request

    export const dynamic = 'force-dynamic' // similar to SSR's getServerSideProps in prev versions
    
    export default function Page() {
      return (
        <main>
    
        </main>
      );
    }
    
    • WHEN ? Use this setup when you have data that changes very often
  • Hybrid Data Fetching: nextjs tries to cache the page to minimize load time, but still tries to figure out if data has changed to bust the cache

    export const dynamic = 'auto' // by default
    
    export default function Page() {
      return (
        <main>
    
        </main>
      );
    }
    
  • Static Data Fetching: nextjs caches the page to optimize load time, never busts cache

    export const dynamic = 'force-static' // cache indefinitely
    
    export default function Page() {
      return (
        <main>
    
        </main>
      );
    }
    
  • You can also revalidate a page after a certain timelapse:

    export const revalidate = 6900 // seconds to cache busting (liek ISR, in old nextjs)
    

SEO metadata

If you export a metadata object in your page.tsx or layout.tsx you can optimize for SEO

export const metadata = {
  title: 'Hello!',
  description: "I'm so goofy"
}

// or as a function

export async function generateMetadata({ params }: any) {
  return {
    title: '...get from somewhere',
  }
}

export default function Page() {
  return (
    <main>

    </main>
  );
}

Data fetching

In nextjs 14, every page.tsx and layout.tsx are server components by default, so they can access:

  • env variables
  • databases
  • etc.

And they can do so, without the need to pass props to the component (old style) with get[ServerSide/Static]Props() // not needed anymore.

Deduping

Components can also be async so you can use fetch or any other library to fetch stuff.

But nextjs overrides fetch to track any duplicate requests, and prevents fetching the same data multiple times.

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const a = await fetch('thing');

  return (
    <html lang="en">
      <body>
        {children}
      </body>
    </html>
  );
}

export default async function Home() {
  const a = await fetch('thing'); // not fetched twice by default
  const a = await fetch('thing', {
    cache: 'force-cache', // cached
  });
  const a = await fetch('thing', {
    cache: 'no-store', // highly dynamic data, never cache
  });
  const a = await fetch('thing', {
    next: { revalidate: 234, } // refetch if seconds have passed
  });

  return (
    <main>
    </main>
  );
}

Page Rendering

Steps

  1. Fetch data from the server (nodejs)
  2. Render server component into html on the server
  3. Send html to browser
  4. Render html and css as non interactive
  5. javascript is executed and react is loaded to hydrate the page

Streaming

Break the application in smaller chunks to load them progressively. So each chunk goes into it's own:

  1. Fetching data from server
  2. Rendering HTML on the server
  3. Loading code on the client
  4. Hydrating

This lets partial content fetching with loading state on the front end.

loading.tsx

For any page.tsx / layout.tsx you can create a loading.tsx component and nextjs will render the loading component.

// loading.tsx
export default function Loading() {
  return <div>Loading...</div>;
}

Under the hood, this works with React.Suspense. You can also use <Suspense> directly into any component like so to get more granualr control:

// page.tsx
import { Suspense } from 'react';

export default async function Home() {
  return (
    <>
      <Suspense fallback={<p>Loading posts...</p>}>
        <Posts />
      </Suspense>
      <Suspense fallback={<p>Loading people...</p>}>
        <People />
      </Suspense>
    <>
  );
}

Suspense wraps an asynchronous component and renders the fallback as long as the promise has not resolved.

SSR (Server Side Rendering)

This is a server side renedered page: nextjs creats a cashed version (dynamic = 'auto'), and determines when to refetch the data.

// app/blog/[slug]/page.tsx
interface Post {
  title: string;
  content: string;
  slug: string;
}

interface Props {
  params: { slug: string; };
}

export default async function BlogPostPage({ params }: Props) {
  const posts: Post[] = await fetch('http://localhost:3000/api/content');
  const post = posts.find(post => post.slug === params.slug)!;
  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </div>
  );
}

We could alter the caching mechanism and make it dynamic, by exporting const dynamic = 'force-dynamic' to make it always fetch the latest data on every request.

Static Pages

The most basic type of page that we can create is a Static Page. Which is basically just compiled html that is sent to the client.

// app/about/page.tsx
// if we want to be very explicit we would use:
export const dynamic = 'force-static';

import { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'About us!',
  description: "Best company ever"
}

export default function AboutPage() {
  return (
    <main>
      <h1>About</h1>
      <p>we don't sell anything</p>
    </main>
  )
}

This would render within the layout and be compiled at compile time.

Dynamic API Route

The most basic routing primitive in nextjs is the route.ts

Let's add a api/content/route.ts

import { NextResponse } from 'next/server';

// some content to be returned
const posts = [
  {
    title: "hello",
    content: "hey",
    slug: "hello",
  },
  {
    title: "hello 2",
    content: "hey 2",
    slug: "hello-2",
  },
  {
    title: "hello 3",
    content: "hey 3",
    slug: "hello-3",
  },
  // ...
]

export default async function GET() {
  return NextResponse.json(posts);
}

// visiting https://example.com/content would return the json blog posts

Remote Images

To use remote images with <Next.Image src="https://server-different-than-next-host.com/image.png" />, we need to adapt the next.config.js:

/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'avatars.githubusercontent.com',
        port: '',
        pathname: '/u/**',
      },
    ]
  }
}

module.exports = nextConfig;

Authentication

Protected Routes

Whenever a user is not logged in, you might want to prevent then from accessing certain routes. You have different options:

  1. Return some message stating "You are not allowed"

    import { getServerSession } from 'next-auth';
    
    export default async function Home() {
      const session = await getServerSession();
    
      if (!session) {
        return <p>Please login</p>;
      }
    
      return {
        <h1>{session.user?.email?}</h1>
      }
    }
    
  2. Redirect to a different route (like sign in)

    import { getServerSession } from 'next-auth';
    import { redirect } from 'next/navigation';
    
    export default async function Home() {
      const session = await getServerSession();
    
      if (!session) {
        return redirect('/api/auth/signin'); // magic rout from next-auth
      }
    
      return {
        <h1>{session.}</h1>
      }
    }
    

Data Fetching

Server Component Fetching

You don't need to call the api, you can directly access the data from the server within the component.

// app/user/[id]/page.tsx
import FollowButton from '@/components/FollowButton/FollowButton';
import { prisma } from '@/lib/prisma';
import { Metadata } from 'next';

interface Props {
  params: {
    id: string;
  };
}

// can use the params from the route
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const user = await prisma.user.findUnique({ where: { id: params.id } });
  return { title: `User profile of ${user?.name}` };
}

export default async function UserProfile({ params }: Props) {
  const user = await prisma.user.findUnique({ where: { id: params.id } });
  const { name, bio, image, id } = user ?? {};

  return (
    <div>
      <h1>{name}</h1>

      <img
        width={300}
        src={image ?? '/mememan.webp'}
        alt={`${name}'s profile`}
      />

      <h3>Bio</h3>
      <p>{bio}</p>

    </div>
  );
}

error.tsx

Error components let you display something wheever there is an error. They receive the error. IMPORTANT: must be a client component.

// app/user/[id]/error.tsx

'use client'; // Error components must be Client components

import { useEffect } from 'react';

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  useEffect(() => {
    console.error(error);
  }, [error]);

  return (
    <div>
      <h2>Something went wrong!</h2>
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}

Data Mutations

We want to create a user dashbord to allow the user to change his data.

app/dashboard/page.tsx

import { getServerSession } from 'next-auth';
export default async function Dashboard() {
  const session = await getServerSession();

  if (!session) {
    return redirect('/api/auth/signin'); // magic rout from next-auth
  }

  const currentUserEmail = session?.user?.email!;

  const user = await prisma.user.findUnique({
    where: {
      email: currentUserEmail,
    }
  });

  return {
    <h1>Dashboard</h1>
    <ProfileForm user={user} /> // defined below
  }
}

Form Submission

Let's create a form (client side) that will let them modify their data.

On submit will make a request to /api/user, that api endpoint does not exist yet, but we'll create it later.

'use client';

export function ProfileForm({ user }: any) {

  const updateUser = async (e: React.FormEvent<HTMLFormElement>) => {

    e.preventDefault(); // make sure not to refresh the page

    const formData = new FormData(e.currentTarget);

    const body = {
      name: formData.get('name'),
      bio: formData.get('bio'),
      age: formData.get('age'),
      image: formData.get('image'),
    };

    const res = await fetch('/api/user', {
      method: 'PUT',
      body: JSON.stringify(body),
      headers: {
        'Content-Type': 'application/json',
      },
    });

    await res.json();
  };

  return (
    <div>
      <h2>Edit Your Profile</h2>
      <form onSubmit={updateUser}>
        <label htmlFor="name">Name</label>
        <input type="text" name="name" defaultValue={user?.name ?? ''} />
        <label htmlFor="bio">Bio</label>
        <textarea
          name="bio"
          cols={30}
          rows={10}
          defaultValue={user?.bio ?? ''}
        ></textarea>
        <label htmlFor="age">Age</label>
        <input type="text" name="age" defaultValue={user?.age ?? 0} />
        <label htmlFor="image">Profile Image URL</label>
        <input type="text" name="image" defaultValue={user?.image ?? ''} />

        <button type="submit">Save</button>
      </form>
    </div>
  );
}

API Mutation

Let's create tha API endpoint to modify the user data.

IMPORTANT: determine the USER FROM THE SERVER SIDE SESSION. Modify only the authenticated user, and not from provided form.

// app/api/user/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { prisma } from '@/lib/prisma';

export async function POST(req: Request) {
  const session = await getServerSession();
  const currentUserEmail = session?.user?.email!;
  const { targetUserId } = await req.json();

  const currentUserId = await prisma.user
    .findUnique({ where: { email: currentUserEmail } })
    .then((user) => user?.id!);

  const record = await prisma.follows.create({
    data: {
      followerId: currentUserId,
      followingId: targetUserId,
    },
  });

  return NextResponse.json(record);
}

export async function DELETE(req: NextRequest) {
  const session = await getServerSession();
  const currentUserEmail = session?.user?.email!;
  const targetUserId = req.nextUrl.searchParams.get('targetUserId');

  const currentUserId = await prisma.user
    .findUnique({ where: { email: currentUserEmail } })
    .then((user) => user?.id!);

  const record = await prisma.follows.delete({
    where: {
      followerId_followingId: {
        followerId: currentUserId,
        followingId: targetUserId!,
      },
    },
  });

  return NextResponse.json(record);
}

Server Pre-Hydration

Follow Button

We want to create a follow button. Whenever the user, visits another user's profile, then see a "Follow/Unfollow" button depending on their current relationship.

To determine whether the user is already following them we will first create a Server component that returns a pre-hydrated Client component

// app/components/FollowButton/FollowButton.tsx

import { getServerSession } from 'next-auth';
import FollowClient from './FollowClient';
import { prisma } from '@/lib/prisma';

interface Props {
  targetUserId: string;
}

export default async function FollowButton({ targetUserId }: Props) {
  const session = await getServerSession();

  const currentUserId = await prisma.user
    .findUnique({ where: { email: session?.user?.email! } })
    .then((user) => user?.id!);

  const isFollowing = await prisma.follows.findFirst({
    where: { followerId: currentUserId, followingId: targetUserId },
  });

  return (
    <FollowClient targetUserId={targetUserId} isFollowing={!!isFollowing} />
  );
}

And then we create the Client component that receives the props from the server component.

// components/FollowButton/FollowClient.tsx
"use client"
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';

interface Props {
    targetUserId: string;
    isFollowing: boolean;
}

export default function FollowClient({ targetUserId, isFollowing }: Props) {
    const router = useRouter();
    const [isPending, startTransition] = useTransition();
    const [isFetching, setIsFetching] = useState(false);
    const isMutating = isFetching || isPending;

    const follow = async () => {
        setIsFetching(true);

        const res = await fetch('/api/follow', {
            method: 'POST',
            body: JSON.stringify({ targetUserId }),
            headers: {
                'Content-Type': 'application/json'
            }
        });

        setIsFetching(false);

        console.log(res)

        startTransition(() => {
          // Refresh the current route:
          // - Makes a new request to the server for the route
          // - Re-fetches data requests and re-renders Server Components
          // - Sends the updated React Server Component payload to the client
          // - The client merges the payload without losing unaffected
          //   client-side React state or browser state
          router.refresh();
        });
    }

    const unfollow = async () => {
        setIsFetching(true);

        const res = await fetch(`/api/follow?targetUserId=${targetUserId}`, {
            method: 'DELETE',
        });

        setIsFetching(false);
        startTransition(() =>  router.refresh() );
    }

    if (isFollowing) {
        return (
            <button onClick={unfollow}>
                {!isMutating ? 'Unfollow' : '...'}
            </button>
        )

    } else {
        return (
            <button onClick={follow}>
                {!isMutating ? 'Follow' : '...'}
            </button>
        )
    }
}

IMPORTANT: for demonstration purposes, because this should actually all be client component to prevent full route rerendering after an update.

Server Actions

The are useful to update user data.

Imagine a Dynamic Route app/user/[id] (displays user profile) and another Dynamic Route app/user/[id]/edit (allows user to edit profile)

Before Server Actions, you would

  1. Create an API route app/api/user that uses a POST / PUT request update the data
  2. Create a client component "use client" that uses fetch to make a request to that endpoint
  3. To update the changes in the page, you would import useTransition from react
  4. Finally we tell the client side router to refretch the page: router.refresh() to reflect changes

Now with Server Actions you:

NOTE: First enable server actions:

const nextCOnfig = {
  experimental: {
    serverActions: true,
  },
};

module.exports = nextConfig;

Then we create the server side edit page that returns a special form.

  1. It is pre-hydrated with the user data
  2. The form has a special property action={upDog} instead of the usual client side onSubmit
  3. upDog is special because it contains the "use server" directive. Nextjs will turn it into a server-side endpoint
    • When button is clicked a POST request is made to dogs/[id]/edit which writes the data
  4. The page hasn't changed yet, so we call revalidatePath from "next/cache", which tells next to revalidate the page and update the server components (without a full page reload).
  5. We add a loading.tsx to display a skeleton
// dogs/[id]/edit/page.tsx
import kv from "@vercel/kv";
import { revalidatePath } from "next/cache";

interface Dog {
  name: string;
  image: string;
  breed: string;
}

export default async function DogEditPage({
  params,
}: {
  params: { id: string };
}) {

  // Fetch data
  const key = `dogs:${params.id}`;
  const dog = await kv.get<Dog>(key);

  // OPTION 1. save and stay
  async function upDog(formData: FormData) {
    "use server";

    // Mutate data
    await kv.set(key, {
      name: formData.get("title"),
      image: formData.get("image"),
      breed: formData.get("breed"),
    });

    // Revalidate
    revalidatePath(`/dogs/${params.id}/edit`);
  }

  // OPTION 2. save and change page
  async function upDogDeuce(formData: FormData) {
    "use server";

    // Mutate data
    await kv.set(key, {
      name: formData.get("title"),
      image: formData.get("image"),
      breed: formData.get("breed"),
    });

    // Revalidate
    redirect(`/dogs/${params.id}`);
  }

  return (
      <div className={styles.cardBody}>
        <h2>Edit {dog?.name}</h2>

        <form action={myAction}>
          <label>Name</label>
          <input name="title" type="text" defaultValue={dog?.name} />
          <label>Image</label>
          <input name="image" type="text" defaultValue={dog?.image} />
          <label>Breed</label>
          <input name="breed" type="text" defaultValue={dog?.breed} />
          <button type="submit">Save and Continue</button>

          <button formAction={upDogDeuce}>Save and Quit</button>

        </form>
      </div>
  );
}

IMPORTANT: server actions only work on forms and things inside forms.

Trigger Server Actions From Client-Components

app/dogs/[id]/actions.ts:

// app/dogs/[id]/actions.ts
"use server"

import kv from "@vercel/kv";
import { revalidatePath } from "next/cache";

export async function like(id: string) {
  await kv.incr(`likes:${id}`);
  revalidatePath(`/dogs/${id}`); // bust cache
}

export async function dilike(id: string) {
  await kv.decr(`likes:${id}`);
  revalidatePath(`/dogs/${id}`); // bust cache
}

Non-Blocking State Updates: useTransition

Now have a client-component that imports the server actions like, dislike and also useTransition from react.

useTransition is essential because it allows to update the state without blocking the UI i.e.:

  • executes the server action
  • next router will reload the server component and update the state

all in a single round trip

"use client"

import { like, dislike } from './actions';
import { useTransition } from 'react';

export default function Likes({ id }: { id: string; }) {
  let [isPending, startTransition] = useTransition(); // non blocking state update

  return (
    <div>
      <button onClick={
        // the wrapping with startTransition is essential to update the UI
        () => startTransition(() => like(id))
      }>Like</button>
      <button onClick={() => startTransition(() => dislike(id))}>Dislike</button>
    </div>
  )
}

A click will:

  • call the server action (mutation),
  • refetch the parent server-component's like count from the server

NOTE: server components void the need to use useEffect in most of your components

Non-Blocking Optimistic State Updates: useOptimistic

It is a combination of useTransition and useReducer, and lets you update the client component to whatever you think the server is going to return.

And when the server returns the true value, the state (context) will sync up.

If it does fail, there should be some kind of error displayed when the state is rolled back.

"use client"

import { experimental_useOptmistic as useOptimistic } from "react";

import { like, dislike } from "./actions";

export default function Likes({ likeCount, id }: any) {
  const [optimisticLikes, addOptimisticLike] = updateOptimistic(
    { likeCount, sending: false },
    (state, newLikeCount) => ({
      ...state,
      likeCount: newLikeCount,
      sending: true,
    })
  );

  return (
    <div>
      <div>
        Optimistic Likes: {optimisticLikes.likeCount}
        {optimisitcLikes.sending} ? " Sending..." : ""
      </div>
      <button onClick={
        // We locally update without caring for what the outcome of await like(id) is
        async () => {
          addOptimisticLike(optimisticLikes.likeCount + 1);
          await like(id);
        };
      }>Like</button>
      <button onClick={
        // We locally update without caring for what the outcome of await like(id) is
        async () => {
          addOptimisticLike(optimisticLikes.likeCount - 1);
          await dislike(id);
        };
      }>Dislike</button>
    </div>
  );
}