# Use Mountain Labs with Next.js

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.

## Before you start

You need:

- **Node.js 20.9 or later**, which Next.js 16 requires.
- **At least one published, public page.** A new page is a private draft, and an anonymous request can read a page only once it is published and its `public.read` is on. [Make a page public](/docs/pages/publishing#make-a-page-public) shows both steps. New to Mountain Labs? Start with [Hello, World](/docs/guides/hello-world).
- **Your API endpoint.** It is unique to your account, in the console's settings under GraphQL API Domain, and looks like `https://graph.[username].mountain-labs.io`.

## Create the app

Create a Next.js app, and add a Markdown renderer for your text sections:

```bash
npx create-next-app@latest my-site --yes
cd my-site
npm 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`:

```bash title=".env.local"
MOUNTAIN_LABS_ENDPOINT=https://graph.[username].mountain-labs.io
```

Reading published, public pages needs no access token, so this is the only setting you need for now.

## Fetch your content

Your site needs two queries. Save each in its own file in a `lib` folder.

`lib/page.graphql` fetches one page with its sections:

```graphql title="lib/page.graphql"
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:

```graphql title="lib/pages.graphql"
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:

```ts title="graphql.d.ts"
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.

```ts title="lib/mountain-labs.ts"
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.
- **Follow `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](#keep-content-fresh).

## Render sections

Create `components/sections.tsx`. It renders a text section's Markdown, and a group section's children in turn:

```tsx title="components/sections.tsx"
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.

## Add a page route

Create `app/[...slug]/page.tsx`. The catch-all segment matches any path, so `/blog/hello` renders the page with the slug `/blog/hello`:

```tsx title="app/[...slug]/page.tsx"
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.
- **A page published after the build** is still served: Next.js renders it on its first visit and caches it from then on.
- **`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`.

## Keep content fresh

**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`:

```ts title="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:

```bash
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.

## Preview drafts

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](/docs/users/authentication#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](/docs/pages/publishing#applications). Then add its credentials, and a secret for the preview link, to `.env.local`:

```bash title=".env.local"
MOUNTAIN_LABS_CLIENT_ID=...
MOUNTAIN_LABS_CLIENT_SECRET=...
DRAFT_SECRET=a-long-random-string
```

**2. Read as your application in Draft Mode.** In `lib/mountain-labs.ts`, add a function that gets a token with the [client credentials grant](/docs/users/authentication#backend-service), and send it from `query` whenever Draft Mode is on:

```ts title="lib/mountain-labs.ts"
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;
}
```

```ts title="lib/mountain-labs.ts"
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`:

```ts title="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.

## Next steps

- [Pages](/docs/pages/overview): how pages, directories and sections fit together
- [Pages API](/docs/api/pages/pages): every field you can query on a page
- [Sections API](/docs/api/pages/sections): `TextSection`, `GroupSection` and how sections nest
- [Directories API](/docs/api/pages/directories): read the content tree, for example to build navigation
- [Publishing and access](/docs/pages/publishing): who can read a page, and when

---

Previous: [Hello World](/docs/guides/hello-world) · Next: [Pages API](/docs/api/pages/pages)
