Client Patterns

Calling Abilities From a Next.js App

⏱ 14 min

With the authentication decision made, Application Passwords by default from the previous lesson, this lesson writes the actual client code. The one rule that matters more than any specific syntax: the Application Password never reaches the browser. It lives in a server-only environment variable, is read only inside a Next.js Route Handler or Server Component that executes on the server, and the browser only ever receives the already-fetched result, never the credential itself.

What you'll learn in this lesson
Where the Application Password lives in a Next.js project
A server-only environment variable, never a NEXT_PUBLIC_ one.
Calling an ability from a Route Handler
A real fetch() with Basic Auth, proxied to the browser as a plain JSON response.
Calling an ability from a Server Component
The same pattern, without a separate API route in between.
Why this must never run client-side
The specific mistake that leaks a credential into a shipped JS bundle.
Prerequisites

A Next.js 13+ project using the App Router, an Application Password for a dedicated WordPress account (Course 4 and Lesson 2), and a public ability exposed via the MCP Adapter or a custom REST route (Course 2).

Step 1: The environment variable, server-only

Next.js only exposes environment variables to the browser if their name starts with NEXT_PUBLIC_. Everything else stays server-side by default, which is exactly the property you need here:

# File: .env.local
WP_APP_USERNAME=headless-integration
WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
WP_BASE_URL=https://your-site.example
Never prefix this with NEXT_PUBLIC_

NEXT_PUBLIC_WP_APP_PASSWORD would be bundled directly into client-side JavaScript and shipped to every visitor’s browser, fully readable in browser devtools. That is not a hypothetical, it’s exactly how Application Passwords leak in real headless projects. The variable names above, with no NEXT_PUBLIC_ prefix, are what keep this server-only.

Step 2: Calling an ability from a Route Handler

A Route Handler runs exclusively on the server, making it a natural home for the authenticated call:

// File: app/api/latest-post-title/route.ts
import { NextResponse } from 'next/server';

export async function GET() {
  const auth = Buffer.from(
    `${process.env.WP_APP_USERNAME}:${process.env.WP_APP_PASSWORD}`
  ).toString('base64');

  const res = await fetch(
    `${process.env.WP_BASE_URL}/wp-json/mcp/v1/my-plugin/get-latest-post-title`,
    {
      method: 'POST',
      headers: {
        Authorization: `Basic ${auth}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({}),
      cache: 'no-store',
    }
  );

  if (!res.ok) {
    return NextResponse.json(
      { error: 'Ability call failed' },
      { status: res.status }
    );
  }

  const data = await res.json();
  return NextResponse.json(data);
}

The browser calls /api/latest-post-title on your own Next.js domain. It never talks to WordPress directly and never sees the Application Password, it only receives whatever plain JSON this Route Handler decides to return.

Step 3: Calling an ability from a Server Component

For data needed during rendering rather than a separate client-triggered request, a Server Component can call WordPress directly, no Route Handler in between, since Server Components also execute only on the server:

// File: app/dashboard/page.tsx
async function getLatestPostTitle() {
  const auth = Buffer.from(
    `${process.env.WP_APP_USERNAME}:${process.env.WP_APP_PASSWORD}`
  ).toString('base64');

  const res = await fetch(
    `${process.env.WP_BASE_URL}/wp-json/mcp/v1/my-plugin/get-latest-post-title`,
    {
      method: 'POST',
      headers: {
        Authorization: `Basic ${auth}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({}),
      cache: 'no-store',
    }
  );

  if (!res.ok) {
    throw new Error(`Ability call failed with status ${res.status}`);
  }

  return res.json() as Promise<{ title: string }>;
}

export default async function DashboardPage() {
  const { title } = await getLatestPostTitle();
  return <p>Latest post: {title}</p>;
}

Because this function only ever runs during server rendering, WP_APP_PASSWORD is read on the server and the rendered HTML sent to the browser contains only the resulting title string, never the credential used to fetch it.

The 'use client' mistake

If this same fetch call ends up inside a component marked 'use client', Next.js will try to run it in the browser, where process.env.WP_APP_PASSWORD is simply undefined (good, it fails loudly) unless someone “fixes” that by renaming it with a NEXT_PUBLIC_ prefix (bad, now it’s shipped to every visitor). If you ever see that rename in a pull request touching WordPress credentials, treat it as a security bug, not a build fix.

Step 4: Handling errors and permission denials explicitly

An ability’s permission_callback can reject a request, and the MCP Adapter or REST API will return a non-2xx status with error details. Surface that distinctly from a network failure rather than swallowing both into a generic error:

// File: app/api/latest-post-title/route.ts (excerpt)
if (res.status === 401 || res.status === 403) {
  return NextResponse.json(
    { error: 'The configured WordPress account cannot access this ability' },
    { status: res.status }
  );
}

This distinction matters operationally: a 401/403 usually means the dedicated service account’s capabilities changed on the WordPress side, not that your Next.js code is broken.

Test it: confirm the credential never reaches the client

Run your Next.js dev server, load the page or hit the API route from a browser, then open DevTools’ Network tab and inspect the response body and headers for the request your own frontend made:

npm run dev

You should see only the plain JSON result (for example {"title":"Hello world"}), with no Authorization header and no Application Password anywhere in the browser’s network activity. The Basic Auth header should only ever appear in your terminal or server logs, from the server-side fetch to WordPress, never in the browser.

Recap

The pattern is the same however you fetch: read the Application Password from a server-only environment variable, send it as Basic Auth in a fetch call that executes inside a Route Handler or Server Component, and return only the resulting data to the browser. Whether you proxy through your own API route or call directly from a Server Component, the credential itself never crosses into client-executed code. The next lesson builds a small typed client so this fetch-and-auth boilerplate doesn’t get repeated for every single ability you call.

Resources & further reading

← Authenticating External Apps: Application Passwords vs Third-Party JWT Building a Typed JS Client for Your Abilities →