Appearance
Env, config & CORS
Secrets aren't
VITE_-prefixed. CORS is whyfetchfromlocalhost:5173tolocalhost:3000fails until you allow it.
.env on the server
bash
# .env - never commit
PORT=3000
JWT_SECRET=change-me-to-a-long-random-string
DATABASE_PATH=./data.sqlite
CORS_ORIGIN=http://localhost:5173
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASS=
MAIL_FROM="Notes API <noreply@example.com>"
APP_URL=http://localhost:5173bash
# .env.example - commit this
PORT=3000
JWT_SECRET=dev-only-change-me
DATABASE_PATH=./data.sqlite
CORS_ORIGIN=http://localhost:5173
# …Load at boot:
bash
npm install dotenvts
import 'dotenv/config'
// or: import dotenv from 'dotenv'; dotenv.config()
const port = Number(process.env.PORT ?? 3000)
const jwtSecret = process.env.JWT_SECRET
if (!jwtSecret) throw new Error('JWT_SECRET missing')Fail fast on missing secrets. Silent undefined JWT secrets are a security horror movie.
FE vs server env (important)
| Vite SPA | Express |
|---|---|
import.meta.env.VITE_API_URL | process.env.PORT |
Anything VITE_ is public in the bundle | Secrets stay on the server |
| Never put JWT_SECRET in Vite | Sign tokens only in Express |
Your SPA only needs the API base URL. Tokens live in memory / httpOnly cookies / localStorage debates - we use Authorization: Bearer for this tutorial.
CORS without the mythology
Browser rule: JS on origin A cannot read responses from origin B unless B opts in.
Vite http://localhost:5173 + API http://localhost:3000 = two origins.
bash
npm install cors
npm install -D @types/corsts
import cors from 'cors'
app.use(
cors({
origin: process.env.CORS_ORIGIN ?? 'http://localhost:5173',
credentials: false, // true later if you use cookie sessions
}),
)Comments for the code above:
origincan be a string or a whitelist function- Preflight
OPTIONSis handled bycorsfor you credentials: trueneeds matchingfetch(..., { credentials: 'include' })and a specific origin (not*)
curl doesn't care about CORS. Your SPA does. If Postman works but the browser doesn't - it's CORS (or mixed content), not your route logic.
Config module pattern
ts
// src/config.ts
function required(name: string): string {
const v = process.env[name]
if (!v) throw new Error(`Missing env ${name}`)
return v
}
export const config = {
port: Number(process.env.PORT ?? 3000),
jwtSecret: required('JWT_SECRET'),
corsOrigin: process.env.CORS_ORIGIN ?? 'http://localhost:5173',
databasePath: process.env.DATABASE_PATH ?? './data.sqlite',
appUrl: process.env.APP_URL ?? 'http://localhost:5173',
}Import config instead of sprinkling process.env everywhere.
Checklist before the auth arc
- [ ]
.envexists locally, listed in.gitignore - [ ]
JWT_SECRETis long and not "secret" - [ ] CORS allows your Vite origin
- [ ] SPA points at
http://localhost:3000(or whatever PORT is)
Next we store real data - SQLite as a friendly "collection on disk".
Best,
-- ł.