env-ribbon
v0.1.0
Published
A corner ribbon that shows whether a Next.js app is running LOCAL or PREVIEW. Never renders in production.
Maintainers
Readme
env-ribbon
A corner ribbon that tells you which deployment you are looking at — LOCAL or PREVIEW. It never renders in production.
A Vercel preview deployment looks exactly like production apart from the URL, which is how people end up seeding test data into the wrong place. One line in your root layout removes the ambiguity.
- No
nextdependency. The package only reads environment variables, soreactis the single peer dependency. - No CSS framework. Inline styles, no Tailwind, no stylesheet to import, no build config to change.
- Production is a hard gate, not a convention — see Detection rules.
Install
npm install env-ribbon
# pnpm add env-ribbon / yarn add env-ribbonQuick start
Next.js App Router, inside <body> of your root layout:
// app/layout.tsx
import { EnvironmentRibbon } from 'env-ribbon';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<EnvironmentRibbon />
</body>
</html>
);
}That is the whole setup. Running next dev you get a green LOCAL ribbon; on a Vercel preview deployment a yellow PREVIEW one; in production, nothing.
Mount it as a Server Component.
EnvironmentRibbonreadsprocess.envon the server and passes only the resolved variant string to the client, soVERCEL_ENVnever has to be exposed through aNEXT_PUBLIC_*variable. Imported into a client component tree it cannot read the environment and will not work.
Tip: apps with more than one root layout
If your app has several layouts that render <html>/<body> — one per route group, for example — mount the ribbon in every one. Miss one and the ribbon silently disappears on those URLs, which is the exact moment you needed it.
Detection rules
Evaluated in order, first match wins:
| # | Condition | Result |
| - | --- | --- |
| 1 | VERCEL_ENV === 'production' | nothing — hard gate |
| 2 | NODE_ENV === 'test' | nothing — keeps E2E runs clean |
| 3 | VERCEL_ENV === 'preview' | PREVIEW (yellow) |
| 4 | (VERCEL_ENV === 'development' or unset) and NODE_ENV !== 'production' | LOCAL (green) |
| 5 | anything else | nothing — fail-safe |
Two details worth knowing:
- Vercel preview builds run with
NODE_ENV === 'production'.VERCEL_ENVis the only signal that separates preview from production, which is why rule 3 ignoresNODE_ENV. - A self-hosted production build has no
VERCEL_ENVandNODE_ENV === 'production'. That is what theNODE_ENVguard in rule 4 is for: without it such a build would proudly announce itself as LOCAL.
When the environment cannot be identified, nothing is rendered.
Non-Vercel hosts
On Netlify, Cloudflare, or your own servers there is no VERCEL_ENV, so auto-detection only ever yields LOCAL during development. Pass variant to render a ribbon explicitly:
<EnvironmentRibbon variant="STAGING" />variant bypasses detection completely — the production gate and the NODE_ENV=test guard included. The ribbon renders wherever that code runs, so the responsibility for keeping it out of production becomes yours:
{process.env.DEPLOY_ENV === 'staging' && <EnvironmentRibbon variant="STAGING" />}Props
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| variant | string | auto-detected | Render this label unconditionally, bypassing detection. Used verbatim — no casing applied. |
| colors | Record<string, string> | { LOCAL: '#22c55e', PREVIEW: '#facc15' } | Variant to background color, merged over the defaults. Anything unmapped gets #6b7280. |
| position | 'top-right' \| 'top-left' | 'top-right' | Which corner to pin the ribbon to. |
| zIndex | number | 60 | Raise it if the ribbon ends up behind your header or modals. |
The label is always white — weak contrast on yellow, but it is the established convention for deploy-preview ribbons.
Dismissal is always available: click, tap, Enter or Space hides the ribbon. The state lives in React memory only and is deliberately not persisted, so a reload brings the ribbon back.
env-ribbon/detect
The detection logic is a pure function with no dependencies and no environment access of its own — it takes the signals as arguments, so it runs anywhere:
import { detectEnvironmentVariant } from 'env-ribbon/detect';
// => 'LOCAL' | 'PREVIEW' | null
detectEnvironmentVariant({
vercelEnv: process.env.VERCEL_ENV,
nodeEnv: process.env.NODE_ENV,
});Useful if you want the same rules behind your own UI, a log line, or a feature flag.
Testing
data-testid="environment-ribbon"is a public contract. It is what your E2E tests should select, and changing it is a semver-major change here.- Under
NODE_ENV === 'test'the ribbon hides itself, so it will not sit on top of the element your Playwright test is trying to click.
await expect(page.getByTestId('environment-ribbon')).toHaveText('PREVIEW');Known limitations
- Strict CSP. Environments that forbid
unsafe-inlineinstyle-src/style-src-attrwill block the inline styles and the small<style>tag this component renders. Since the ribbon only ever shows up in development and preview, the practical impact is limited to those environments.
Scope
Built for Next.js / React with Server Components. React Native is out of scope; a separate package is planned for it, reusing env-ribbon/detect as-is.
License
MIT © k0kishima
