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.
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
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.
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
- Next.js Route Handlers, nextjs.org
- Next.js environment variables, nextjs.org
- Application Passwords integration guide, make.wordpress.org