Noindex Is Not Auth: Protecting Private Pages in Next.js
How to separate link-only pages from truly private content in Next.js App Router using robots metadata, middleware, server actions, and HttpOnly cookies.
Noindex Is Not Auth: Protecting Private Pages in Next.js#
noindex tells search engines what you prefer. It does not stop a person with the URL from opening the page. If private content matters, the content should never be rendered until the server has verified access.
That distinction sounds obvious until a "private" page ships as static HTML with a password prompt hiding content in the browser. Anyone who can view source, inspect the bundle, or fetch the route can read it.
Decide which privacy level you need#
There are three common patterns:
| Pattern | Use it when | Risk |
| --- | --- | --- |
| Unlinked page | You only want it out of navigation | Anyone with the URL can open it |
| noindex page | You do not want search results | Crawlers usually comply, users are not blocked |
| Server-gated content | The content is actually private | You need password handling, cookies, and deployment secrets |
Do not use the first two as substitutes for the third.
Keep private text off the client#
This is the rule that matters most. Do not put the secret note, draft, token, or sensitive document in a client component and toggle visibility after a password check.
Weak pattern:
'use client' const secret = 'private content' export function PrivateNote() { const [unlocked, setUnlocked] = useState(false) return unlocked ? <p>{secret}</p> : <PasswordForm /> }
The text is still shipped to the browser.
Better pattern:
export default function PrivateNotePage() { const hasAccess = hasValidSignedCookie() if (!hasAccess) { return <PasswordForm /> } return <PrivateContentFromServer /> }
If access is missing, the server renders only the form. The private content never enters the response.
Use a server action for the password#
In App Router, a server action keeps the password check on the server:
'use server' import { cookies } from 'next/headers' import { redirect } from 'next/navigation' export async function grantPrivatePageAccess(formData: FormData) { const password = String(formData.get('password') || '') if (password !== process.env.PRIVATE_PAGE_PASSWORD) { redirect('/private?state=wrong') } cookies().set('private_page_access', createSignedToken(), { httpOnly: true, sameSite: 'strict', secure: process.env.NODE_ENV === 'production', path: '/', maxAge: 60 * 60 * 24 * 7, }) redirect('/private') }
For production, sign the cookie value with HMAC. A plain boolean cookie is easy to forge.
Add robots controls, but treat them as hygiene#
For private or semi-private pages, use all three:
- Page metadata with
robots.index = false X-Robots-Tag: noindex, nofollowrobots.txtdisallow rules
These controls reduce accidental discovery. They do not replace server-side authorization.
Watch your caching layer#
Private pages should be dynamic. If a route reads cookies, next/headers, or server-only environment values, Next.js will usually treat it as dynamic. Still, verify the final behavior:
- Open the private route without a cookie and confirm the secret text is absent from HTML.
- Open it with a valid cookie and confirm the text appears.
- Check response headers in production, not only local dev.
- Make sure the page is not listed in sitemap output.
The rule of thumb#
If everyone with the link may read it, use an unlinked noindex page. If only one person should read it, make the server decide before rendering the content. Privacy starts before React hydrates.
What do you think?
React to show your appreciation