Dev.to WebDev 🛠 Dev 👁 0 📖 7 min read

Our example env file documents 19 variables, the code reads 15 of them, and its longest comment describes a design we deleted

.env.example is the first file a new contributor opens and the last file anybody updates. PubTrivia has two of them, one for the Next.js app that serves the marketing site and the dashboard and one for the WebSocket serv

.env.example is the first file a new contributor opens and the last file anybody updates. PubTrivia has two of them, one for the Next.js app that serves the marketing site and the dashboard and one for the WebSocket server that runs the live half of a quiz night, and I went through both against every process.env read in the repository.

The root file documents 19 variables. Fifteen of them are read by the app's own source. Here is the gap, in both directions.

Four documented, never read

$ comm -23 documented.txt read.txt
GOOGLE_OAUTH_CLIENT_SECRET
NEXTJS_INTERNAL_URL
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
SENTRY_AUTH_TOKEN

Four variables, four different reasons, and only one of them is a mistake.

GOOGLE_OAUTH_CLIENT_SECRET is never read because this app does not perform the OAuth exchange. Supabase does, and the secret is pasted into Supabase's dashboard. It is in our example file so that somebody setting the project up knows they need one, which is a reasonable thing for an example file to do and a slightly odd thing for an example file to do, since nothing will ever read the value they put there.

NEXTJS_INTERNAL_URL is read by the other package. It is in the root file with a note saying exactly that, in capital letters:

# NOTE: this variable is READ BY THE WS SERVER, not by Next.js itself.
#       Set it in ws-server/.env (or ws-server/.env.local) alongside WS_INTERNAL_SECRET.
#       It is documented here to clarify the distinction from the other WS variables above.

That comment exists because three of the four variables with WS in the name are read by Next.js and this one is not, which is confusing enough to have been written down twice.

SENTRY_AUTH_TOKEN is read during the build by the Sentry webpack plugin, not by any line of our code. A variable can be essential and still never appear in your source.

NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is the mistake. It is a leftover from an earlier approach where Stripe's hosted pricing table rendered the plans. That element is gone, the key is read by nothing, and the script tag that would have used it is still in the root layout:

<Script src="https://js.stripe.com/v3/pricing-table.js" strategy="lazyOnload" />

I wrote a correction post about that script a while back after measuring it on the live site, and it is still there, still loading on every page, still defining a custom element that no page uses. The example file is the second artifact keeping a deleted design alive.

Two read, never documented

$ comm -13 documented.txt read.txt
NEXT_RUNTIME
NODE_ENV

Both are provided by the framework. NODE_ENV gates whether lib/site.ts treats the deployment as the production site, which decides whether pages are indexable and whether the Organization JSON-LD is emitted. NEXT_RUNTIME is read twice in instrumentation.ts to decide which Sentry config to load. Neither belongs in an example file, because writing them there would invite somebody to set them by hand.

A credential used as a boolean

The one variable whose read surprised me is GOOGLE_OAUTH_CLIENT_ID. It appears in exactly one file, and it is never passed to anything:

const isGoogleEnabled = process.env.GOOGLE_OAUTH_CLIENT_ID ? true : false

Its presence decides whether the "Continue with Google" button renders. Its value is unused, because, as above, the exchange happens in Supabase. So an OAuth client id, the single least secret credential in any OAuth setup, is functioning here as a feature flag, and the feature it flags is configured in a different product's dashboard. It works, and I half want to replace it with GOOGLE_SIGNIN_ENABLED=true so the next person does not spend a minute wondering where the id goes.

Four of the 19 names carry the NEXT_PUBLIC_ prefix, which in Next.js means "inline this into the JavaScript the browser downloads". Three of them really are in the client bundle: the Supabase project URL and its anon key, which are designed to be public and protected by row level security, and the WebSocket URL a phone has to connect to. The fourth, the Stripe publishable key, is public by design too and is not in the bundle because nothing reads it. The thing worth checking in your own project is not whether the prefix is there, it is whether anything without the prefix has quietly been handed to a client component.

The longest comment in the file is out of date

This is the finding I actually care about. The entry for MANAGED_PAYMENTS_ENABLED carries a twenty-line comment, the longest in the file, and the last third of it describes a design that no longer exists:

# Because our line items use a pre-created Price id, the accompanying tax
# settings live on the Stripe objects and must also be set there: tax_code
# txcd_10103000 on each Product, tax_behavior "exclusive" on each Price.

There are no Products. There are no Prices. We deleted them, and every Checkout line item is now built inline from one table of local amounts, which is the whole reason the figure on the pricing page is the figure on the card. The code that the comment is about says so itself:

/**
 * `price_data` (there are no Products or Prices in the Stripe Dashboard). Two
 * things ride on that:
 *   ...
 *   2. MANAGED_PAYMENTS_TAX_CODE rides on that `price_data`, merged in
 */

So the repository contains two documents about the same tax setting, 60 lines apart in different files, and they disagree about where it lives. Anybody following the example file would go into the Stripe Dashboard looking for Products to annotate, find none, and conclude the setup was incomplete. A stale comment in an example file is worse than no comment, because its tone is instructional.

The other half of that entry is still exactly right and worth keeping as a pattern:

# Master switch, MoR is applied only when this is exactly "true", and even then
# only for the EU-27 countries listed in utils/stripe/managed-payments.ts

=== 'true' rather than truthiness, which means MANAGED_PAYMENTS_ENABLED=1 and MANAGED_PAYMENTS_ENABLED=yes both leave it off. For a flag that changes who the merchant of record is on a real invoice, refusing to guess is the correct amount of pedantry.

The second file, and the variable in neither

The WebSocket server has its own example file with six entries, and the two halves of the system have almost nothing in common. One variable, DATABASE_URL, appears in both. The shared secret appears in both under the same name. And then the conventions diverge:

Concern Next.js app WS server
Redis UPSTASH_REDIS_REST_URL + _TOKEN REDIS_URL
Redis client Upstash REST ioredis
Redis needed? yes, for rate limits no, only for multi-instance fan-out
Port n/a WS_PORT, or PORT

The Redis split is deliberate: a REST client works in the edge runtime where a TCP one does not, and the WS server has no such constraint. The port line is the interesting one, because the variable the production server actually listens on is documented in neither example file:

// listen on as `PORT`. Honour an explicit `WS_PORT` first so local dev keeps its
const portSource = process.env.WS_PORT ? 'WS_PORT' : 'PORT'
const rawPort = process.env.WS_PORT ?? process.env.PORT
if (!rawPort) missingVars.push('WS_PORT (or PORT, injected by the host)')

PORT is injected by the host. It is in no example file, it is in no dashboard field anybody typed, and it is the one that is actually in use in production. The error message is the only place in the repository that tells you so, which is a decent argument for writing fallbacks into error strings rather than comments: the string is the one piece of documentation that gets read at the moment it matters.

What I am changing

Three things, all small. Delete NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY and the pricing-table script together, since neither has a job. Rewrite the last paragraph of the Managed Payments comment to say where the tax code actually rides. Rename the Google variable to the flag it is.

The broader lesson is that an example env file is a test with no runner. Nothing in a build fails when it drifts, and the drift here was not a missing variable, which somebody would hit immediately. It was four surplus names and one confident paragraph about objects that no longer exist. The check is twenty seconds of comm over two sorted lists, and it is worth running the day after any refactor that touches a third-party integration.

See the result rather than the config

The part of this that a reader can actually verify is the pricing:

  • The pricing page builds its figures from one table of local amounts, the same table Checkout builds its line items from. Load it through a VPN exit in another country and the currency changes with it.
  • The features index and the solutions pages are the surfaces that NODE_ENV quietly governs, since it decides whether they are indexable at all.
  • The free tier needs no card, so you can sign up and see the Google button whose existence is decided by whether a variable is set.
📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.