Loading Editor...
h3
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
This guide builds a Next.js site that renders your Mountain Labs pages. By the end, every published page is served at its own slug, prerendered at build time and refreshed as you edit, and your team can preview drafts before they go live.
It uses the App Router and Next.js 16, with Cache Components, which new apps have on by default. Every page is fetched on the server, so your site ships no GraphQL client to the browser.
You need:
public.read is on. Make a page public shows both steps. New to Mountain Labs? Start with Hello, World.https://graph.[username].mountain-labs.io.Create a Next.js app, and add a Markdown renderer for your text sections:
npx create-next-app@latest my-site --yescd my-sitenpm install react-markdown remark-gfm
remark-gfm adds GitHub-flavoured Markdown, such as tables and strikethrough, which text sections can contain.
Put your endpoint in .env.local:
MOUNTAIN_LABS_ENDPOINT=https://graph.[username].mountain-labs.ioReading published, public pages needs no access token, so this is the only setting you need for now.
Your site needs two queries. Save each in its own file in a lib folder.
lib/page.graphql fetches one page with its sections:
fragment SectionFields on Section { __typename slug ... on TextSection { markdown }}query Page($slug: String!) { page(ref: { slug: $slug }) { slug title children { ...SectionFields ... on GroupSection { children { ...SectionFields } } } }}lib/pages.graphql lists every page, one page of results at a time:
query Pages($directory: String!, $cursor: String) { pages(directory: $directory, connection: { cursor: $cursor }) { pageInfo { hasNextPage endCursor } edges { node { slug } } }}Let TypeScript import .graphql files as text by adding graphql.d.ts at the root of your project:
declare module "*.graphql" { const query: string; export default query;}Then create lib/mountain-labs.ts. It sends the queries to your endpoint, and has the two functions your pages use: getPage fetches one page, and getAllSlugs lists every page.
import { cacheLife, cacheTag } from "next/cache";import PAGE_QUERY from "./page.graphql" with { type: "text" };import PAGES_QUERY from "./pages.graphql" with { type: "text" };export type Section = | { __typename: "TextSection"; slug: string; markdown: string } | { __typename: "GroupSection"; slug: string; children: Section[] };export type Page = { slug: string; title: string | null; children: Section[];};async function query<T>( query: string, variables: Record<string, unknown> = {},): Promise<T> { const response = await fetch(process.env.MOUNTAIN_LABS_ENDPOINT!, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }), }); if (!response.ok) { throw new Error(`Mountain Labs responded with ${response.status}`); } const { data, errors } = await response.json(); if (errors?.length) { throw new Error(errors[0].message); } return data;}export async function getPage(slug: string) { "use cache"; cacheLife("hours"); cacheTag("mountain-labs"); const data = await query<{ page: Page | null }>(PAGE_QUERY, { slug }); return data.page;}type PagesResult = { pages: { pageInfo: { hasNextPage: boolean; endCursor: string | null }; edges: { node: { slug: string } }[]; };};export async function getAllSlugs(directory = "/") { "use cache"; cacheLife("hours"); cacheTag("mountain-labs"); const slugs: string[] = []; let cursor: string | null = null; do { const { pages }: PagesResult = await query<PagesResult>(PAGES_QUERY, { directory, cursor, }); slugs.push(...pages.edges.map((edge) => edge.node.slug)); cursor = pages.pageInfo.hasNextPage ? pages.pageInfo.endCursor : null; } while (cursor); return slugs;}A few things to know:
with { type: "text" } imports a file's contents as a string. Turbopack, the default bundler in Next.js 16, supports it with no configuration.page returns null for a page you can't read, whether it doesn't exist or isn't public yet. Your route turns that into a 404.children is the page's top-level sections. A GroupSection holds more sections, so the query asks for one level of nesting. Repeat the ... on GroupSection block for each extra level your pages use.pages(directory: "/") lists every page, at any depth. Pass a directory such as /blog to list only the pages under it.endCursor until hasNextPage is false. Pages of results can come back with fewer entries than you asked for, or none, so never stop early because one looks short."use cache" with cacheLife("hours") keeps each result for up to an hour, and the mountain-labs tag lets you refresh everything at once. See Keep content fresh.Create components/sections.tsx. It renders a text section's Markdown, and a group section's children in turn:
import Markdown from "react-markdown";import remarkGfm from "remark-gfm";import type { Section } from "@/lib/mountain-labs";export function Sections({ sections }: { sections: Section[] }) { return sections.map((section) => { switch (section.__typename) { case "TextSection": return ( <section key={section.slug} id={section.slug}> <Markdown remarkPlugins={[remarkGfm]}>{section.markdown}</Markdown> </section> ); case "GroupSection": return ( <section key={section.slug} id={section.slug}> <Sections sections={section.children} /> </section> ); default: return null; } });}Each section's slug becomes its id, so a link like /about#team scrolls to the section with the slug team. Section types your component doesn't know yet render nothing, rather than breaking the page.
Create app/[...slug]/page.tsx. The catch-all segment matches any path, so /blog/hello renders the page with the slug /blog/hello:
import type { Metadata } from "next";import { notFound } from "next/navigation";import { Sections } from "@/components/sections";import { getAllSlugs, getPage } from "@/lib/mountain-labs";type Props = { params: Promise<{ slug: string[] }> };export async function generateStaticParams() { const slugs = await getAllSlugs(); return slugs.map((slug) => ({ slug: slug.split("/").filter(Boolean) }));}export async function generateMetadata({ params }: Props): Promise<Metadata> { const { slug } = await params; const page = await getPage(`/${slug.join("/")}`); return { title: page?.title ?? undefined };}export default async function Page({ params }: Props) { const { slug } = await params; const page = await getPage(`/${slug.join("/")}`); if (!page) { notFound(); } return ( <article> <Sections sections={page.children} /> </article> );}generateStaticParams prerenders every page at build time. With Cache Components it must return at least one page, so publish a page before your first build.generateMetadata uses the page's title as the browser tab title. getPage is cached, so the page and its metadata share one request.Run npm run dev and open a page's slug, for example http://localhost:3000/blog/hello.
Edits appear within the hour. cacheLife("hours") serves a cached page for up to an hour, then refreshes it in the background. Choose a profile that suits how often you publish, such as "minutes" or "days".
To publish straight away, add a route that clears the cache on request. Create app/api/revalidate/route.ts:
import { revalidateTag } from "next/cache";export async function POST(request: Request) { if ( request.headers.get("authorization") !== `Bearer ${process.env.REVALIDATE_SECRET}` ) { return new Response("Unauthorized", { status: 401 }); } revalidateTag("mountain-labs", "max"); return Response.json({ revalidated: true });}Set REVALIDATE_SECRET to a long random string in your environment, then call the route after you publish:
curl -X POST https://your-site.example.com/api/revalidate \ -H "Authorization: Bearer $REVALIDATE_SECRET"
Visitors keep seeing the old version until the new one is ready, so nobody waits on a slow render.
Drafts aren't public, so your site needs its own access token to read them. With Next.js Draft Mode, editors see drafts while everyone else gets the cached, published site.
1. Give your site access to drafts. Register an application of type Backend service. An application starts with only public access, so also add a permission rule in the console that lets it read the pages you want to preview, drafts included. See Applications. Then add its credentials, and a secret for the preview link, to .env.local:
MOUNTAIN_LABS_CLIENT_ID=...MOUNTAIN_LABS_CLIENT_SECRET=...DRAFT_SECRET=a-long-random-string2. Read as your application in Draft Mode. In lib/mountain-labs.ts, add a function that gets a token with the client credentials grant, and send it from query whenever Draft Mode is on:
import { draftMode } from "next/headers";async function getAccessToken() { const credentials = Buffer.from( `${process.env.MOUNTAIN_LABS_CLIENT_ID}:${process.env.MOUNTAIN_LABS_CLIENT_SECRET}`, ).toString("base64"); const response = await fetch("https://auth.mountain-labs.io/oauth2/token", { method: "POST", headers: { Authorization: `Basic ${credentials}`, "Content-Type": "application/x-www-form-urlencoded", }, body: "grant_type=client_credentials", }); if (!response.ok) { throw new Error(`Token request failed with ${response.status}`); } const { access_token } = await response.json(); return access_token as string;}async function query<T>( query: string, variables: Record<string, unknown> = {},): Promise<T> { const headers: Record<string, string> = { "Content-Type": "application/json", }; // In Draft Mode, read as your application so drafts are visible too. if ((await draftMode()).isEnabled) { headers.Authorization = `Bearer ${await getAccessToken()}`; } const response = await fetch(process.env.MOUNTAIN_LABS_ENDPOINT!, { method: "POST", headers, body: JSON.stringify({ query, variables }), }); // ...the rest is unchanged}Draft Mode skips the cache, so editors always see the latest version, and the token never reaches the browser.
3. Turn Draft Mode on from a link. Create app/api/draft/route.ts:
import { draftMode } from "next/headers";import { redirect } from "next/navigation";import { getPage } from "@/lib/mountain-labs";export async function GET(request: Request) { const { searchParams } = new URL(request.url); const slug = searchParams.get("slug"); if (searchParams.get("secret") !== process.env.DRAFT_SECRET || !slug) { return new Response("Invalid token", { status: 401 }); } const draft = await draftMode(); draft.enable(); // Redirect to the page's own slug, not the one in the URL. const page = await getPage(slug); if (!page) { draft.disable(); return new Response("Page not found", { status: 404 }); } redirect(page.slug);}To preview a page, open /api/draft?secret=<DRAFT_SECRET>&slug=/blog/hello. Draft Mode stays on for that browser until it is turned off with (await draftMode()).disable(), for example from an "Exit preview" button.
TextSection, GroupSection and how sections nest