tags

Next.js 13 Nested Layouts, Streaming SSR with Realtime GraphQL

22 November, 2022 | 6 min read

Next.js, the open-source React framework by Vercel, has released version 13, laying the foundations to be dynamic without limits. Please read their release blog post for an excellent overview.

In this post, we will build a mini e-commerce store where you can view the products on sale, order one, and see a real-time feed of orders. We are covering new Next.js features such as:

  • Nested Layouts
  • Data Fetching
  • Streaming and Suspense
  • Client and Server Components

We also see how to integrate with Hasura GraphQL for realtime features such as Streaming Subscriptions.

Let’s dive in with the e-commerce schema from the Hasura Super App. See our Data Hub for instructions on how to import the schema into Hasura.

The source code is available on GitHub.

Next.js Setup

Create a Next.js TypeScript application. As of this blog post, we will use the experimental version because that allows us to use beta features.

npx create-next-app@latest --experimental-app

Install the dependencies

npm install @graphql-codegen/cli @graphql-codegen/client-preset @graphql-typed-document-node/core dotenv graphql graphql-request graphql-ws

Replace the contents of next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    appDir: true,
    runtime: "experimental-edge",
  },
  images: {
    remotePatterns: [
      {
        protocol: "http",
        hostname: "img6a.flixcart.com",
      },
      {
        protocol: "http",
        hostname: "img5a.flixcart.com",
      },
    ],
  },
};

module.exports = nextConfig;

The experimental-edge runtime lets us use Vercel's edge runtime. The term “Edge” refers to the orientation toward instant serverless compute environments and not a specific set of locations. The image section configures Next.js's image component to use pictures from our e-commerce store.

Our last step is adding two entries to .env.local

NEXT_PUBLIC_HASURA_GRAPHQL_URL=<Hasura GraphQL endpoint>
NEXT_PUBLIC_HASURA_GRAPHQL_WS_URL=<Hasura websocket endpoint>

If your Hasura instance has an admin secret, you can also set HASURA_GRAPHQL_ADMIN_SECRET for use in the code generator.

GraphQL Code Generator Setup

When making TypeScript applications with GraphQL we highly recommend using GraphQL Code Generator. Combined with the graphql-request library, we have fully typed queries and mutations by the magic of code generation!

Create a file codegen.ts

import type { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {
  overwrite: true,
  schema: [
    {
      [process.env.NEXT_PUBLIC_HASURA_GRAPHQL_URL!]: {
        headers: {
          // If you have an admin secret set
          "x-hasura-admin-secret": process.env.HASURA_GRAPHQL_ADMIN_SECRET!,
        },
      },
    },
  ],
  config: {
    skipTypename: true,
    enumsAsTypes: true,
    scalars: {
      numeric: "number",
    },
  },
  documents: "lib/service/queries.graphql",
  generates: {
    "lib/gql/": {
      preset: "client",
      config: {},
      plugins: [],
    },
  },
};

export default config;

This config connects to our Hasura GraphQL instance and generates typings based on the client preset.

In package.json add a script to load environment variables and run the code generator "codegen": "graphql-codegen --require dotenv/config --config codegen.ts dotenv_config_path=./.env.local"

Next, define our queries/mutations in lib/service/queries.graphql

query GetProducts {
  product(limit: 10) {
    id
    name
  }
}

query GetProduct($id: Int!) {
  product_by_pk(id: $id) {
    id
    description
    name
    price
    image_urls(path: "$[0]")
  }
}

mutation PlaceOrder($products: order_product_arr_rel_insert_input!) {
  insert_order_one(
    object: {
      products: $products
      billing_address_id: 222
      user_id: 225
      shipping_address_id: 222
    }
  ) {
    id
  }
}

Finally, run the generator:

npm run codegen

graphql-request Setup

Create a graphql-request client in lib/service/client.ts

import { GraphQLClient } from "graphql-request";

export const gqlClient = new GraphQLClient(
  process.env.NEXT_PUBLIC_HASURA_GRAPHQL_URL!,
  { fetch }
);

We pass in the environment’s global fetch, so our Next.js will work anywhere, regular Node.js or an edge runtime.

Nested Layouts

One of the new Next.js features is nested layouts. They allow us to have a shared parent component, and the router can render in child routes.

To demonstrate this, we will display ten products on our home page. When you click on one, it will navigate the right-hand side to the product details page.

Fetch Home Page Data

Next.js 13 uses React’s new way of fetching data. Read more about the RFC on Github. We will use it to get a list of products.

In app/layout.tsx, we add an async function to get data from Hasura:

import Link from "next/link";
import { GetProductsDocument } from "../lib/gql/graphql";
import { gqlClient } from "../lib/service/client";
import "./globals.css";

async function getProducts() {
  const { product: products } = await gqlClient.request(
    GetProductsDocument,
    {}
  );
  return products;
}

We call it in our component using async/await, then create an unordered list of product links:

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const products = await getProducts();
  return (
    <html lang="en">
      <head />
      <body>
        <main style={{ display: "flex" }}>
          <section style={{ minWidth: "400px", width: "400px" }}>
            <ul>
              {products.map((product) => (
                <li key={product.id}>
                  <Link href={`/${product.id}`}>{product.name}</Link>
                </li>
              ))}
            </ul>
          </section>
          <section style={{ flexGrow: 1 }}>{children}</section>
        </main>
      </body>
    </html>
  );
}

With nested layouts and a layout.tsx file, the URL will control the component output in {children}.

Product Details Page

We will now add a Next.js page that gets the product ID from the URL and fetches/displays the details.

Create app/[id]/page.tsx. The [id] means we will pass down data from the URL as a prop named id:

import Image from "next/image";
import { GetProductDocument } from "../../lib/gql/graphql";
import { gqlClient } from "../../lib/service/client";

async function getProduct(id: number) {
  const { product_by_pk } = await gqlClient.request(GetProductDocument, { id });
  return product_by_pk!;
}

export default async function Page({
  params: { id },
}: {
  params: { id: string };
}) {
  const product = await getProduct(Number(id));
  return (
    <>
      <h1>{product.name}</h1>
      <Image
        src={product.image_urls}
        alt={`${product.name} Picture`}
        width={200}
        height={200}
      ></Image>
      <div>{`$${product.price.toFixed(2)}`}</div>
      <p>{product.description}</p>
    </>
  );
}

Now when you click a link on the left, you should see the product details page appear on the right!

Streaming and Suspense

Streaming and Suspense are new React features that allow us to instantly display a loading UI, then stream in UI once data fetching is done.

As an example of this, create app/loading.tsx:

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

Now when you try navigating between products you should see the loading UI before the details are loaded.

Client Components

All the stuff we've built so far uses Server Components which means they render on the server and don't need to ship JS to the browser.

However, sometimes we need to create Client Components that have logic on the browser. For our example, we will have a button that orders a product and a Hasura Streaming Subscription that shows us a real-time list of orders.

For our real-time subscription, we'll use the graphql-ws library. For the mutation we will continue to use graphql-request.

We create a new client component in app/components/orders.tsx using the syntax we are used to in React. The only new thing part is the "use client"; at the top which tells Next.js this is a client component.

"use client";

import { useEffect, useState } from "react";
import { PlaceOrderDocument } from "../../lib/gql/graphql";
import { createClient } from "graphql-ws";
import { gqlClient } from "../../lib/service/client";

const wsClient = createClient({
  url: process.env.NEXT_PUBLIC_HASURA_GRAPHQL_WS_URL!,
});

export default function Orders({ id }: { id: number }) {
  const [orders, setOrders] = useState<
    Array<{ id: number; created_at: string; quantity: number }>
  >([]);

  useEffect(() => {
    const unsubscribe = wsClient.subscribe(
      {
        query: `
          subscription ProductOrders($id: Int!) {
            order_product_stream(
              batch_size: 10
              cursor: { initial_value: { created_at: "2021-02-22T18:16:12.95499+00:00" } }
              where: { product_id: { _eq: $id } }
            ) {
              id
              quantity
              created_at
            }
          }`,
        variables: {
          id,
        },
      },
      {
        next: ({ data }) => {
          setOrders((orders) => [
            ...orders,
            ...(data?.order_product_stream as any),
          ]);
        },
        error: (error) => {
          console.log(error);
        },
        complete: () => {
          console.log("complete");
        },
      }
    );
    return () => {
      unsubscribe();
    };
  }, [id]);

  return (
    <>
      <button
        onClick={async () =>
          await gqlClient.request(PlaceOrderDocument, {
            products: { data: [{ product_id: id, quantity: 1 }] },
          })
        }
      >
        Order
      </button>
      <li>
        {orders.map((order) => (
          <li key={order.id}>
            {`Order ${order.id} created at ${new Date(order.created_at)}`}
          </li>
        ))}
      </li>
    </>
  );
}

When a new order comes from the subscription we update useState and render out the list of orders. When we click on the button, we trigger a GraphQL mutation to add a new order.

Now back in app/[id]/page.tsx we import the component and add it to the end of the page

import Orders from "../components/orders";

export default async function Page({
  params: { id },
}: {
  params: { id: string };
}) {
  const product = await getProduct(Number(id));
  return (
    <>
      ...
      <Orders id={Number(id)}></Orders>
    </>
  );
}

When we navigate to the product detail page you should now be able to order a product and see new orders pop up in real-time!

Conclusion

Next.js is packed with new features and deep integration with cutting-edge React techniques. Combined with Hasura, we can easily make great applications for our users!


Subscribe IlluSubscribe Illu

Monthly product updates in your inbox. No spam.

Loading...