Next.js 13 Nested Layouts, Streaming SSR with Realtime GraphQL
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!