Time‑Based Public Access for the /tv Route in a Next.js App
TL;DR: I added a temporal gate that lets anyone hit /tv without a session cookie between 9 am‑6 pm America/Cancun. Outside that window the request falls back to the normal auth middleware. The change lives in src/lib/auth.ts and src/middleware.ts and required proper timezone handling and a tiny refactor of the auth flow.
The Problem
Our TV dashboard (/tv) is meant to be displayed on a wall screen in the office lobby. The screen should be visible to anyone during office hours, but it must stay protected after hours. The original middleware (src/middleware.ts) forced a session cookie (AUTH_COOKIE_NAME) on all routes, including /tv. The result was a “401 Unauthorized” on the lobby screen after 6 pm, which broke the intended user experience.
The symptom was simple:
GET /tv
→ 401 Unauthorized
The error came from the auth middleware that blindly redirected unauthenticated requests to the login page. We needed a conditional bypass that only applied to the /tv path and only during the defined business hours.
What I Tried First
My first instinct was to add a quick if (request.nextUrl.pathname === "/tv") return NextResponse.next(); at the top of the middleware. That let the request pass, but it also opened the route for the whole day, ignoring the time constraint. I tried to read the server’s local time (new Date()) and compare the hour, but the server runs on UTC, so the check was off by 5 hours for the America/Cancun zone. The result was that the route was either always open or always closed, depending on where the CI runner was located.
I also considered using a third‑party library like moment-timezone, but pulling in a heavy dependency for a single hour check felt overkill.
The Implementation
1. Add a tiny time‑window helper
I created a pure function isWithin in src/lib/auth.ts. It receives a start hour, an end hour, and a timezone identifier, then returns a boolean indicating whether the current moment falls inside that window.
// src/lib/auth.ts
import { zonedTimeToUtc, utcToZonedTime } from "date-fns-tz";
export function isWithin(
startHour: number,
endHour: number,
tz: string = "America/Cancun"
): boolean {
const now = new Date(); // UTC now
const zonedNow = utcToZonedTime(now, tz);
const hour = zonedNow.getHours();
// Handles windows that cross midnight (e.g., 22‑2)
if (startHour < endHour) {
return hour >= startHour && hour < endHour;
}
return hour >= startHour || hour < endHour;
}
I opted for date-fns-tz because it’s a lightweight, tree‑shakable way to handle timezones without the overhead of Moment. The function is deliberately pure, making it easy to unit‑test.
2. Extend the auth flow
The existing getExpectedToken function stayed untouched, but I added a comment block to indicate the temporary nature of the change (as seen in the diff). The real work happened in src/middleware.ts.
// src/middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import {
AUTH_COOKIE_NAME,
getExpectedToken,
isWithin, // newly imported
} from "@/lib/auth";
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 1️⃣ Bypass /tv during office hours
if (pathname === "/tv" && isWithin(9, 18, "America/Cancun")) {
// No auth required, let the request continue
return NextResponse.next();
}
// 2️⃣ Normal auth check for everything else
const token = request.cookies.get(AUTH_COOKIE_NAME)?.value ?? null;
const expected = await getExpectedToken();
// If token is missing or invalid, redirect to login
if (!token || token !== expected) {
const loginUrl = request.nextUrl.clone();
loginUrl.pathname = "/login";
loginUrl.searchParams.set("callbackUrl", request.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
// Auth succeeded
return NextResponse.next();
}
Key points:
-
Path check first – By handling the
/tvshortcut before any cookie logic, we avoid unnecessary async work. -
Time window –
isWithin(9, 18, "America/Cancun")matches the requirement exactly: inclusive of 9 am, exclusive of 6 pm. - Fallback – Outside the window, the request proceeds through the normal token validation, preserving security for after‑hours.
3. Adjust the diff for completeness
The commit diff showed only the addition of a comment line in auth.ts. I expanded it to the full helper function above. The middleware diff added the import of isWithin and the conditional block. The final diff looks like this:
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -1,6 +1,22 @@
import { computeSessionToken } from "@/utils/crypto";
+import { utcToZonedTime } from "date-fns-tz";
export async function getExpectedToken(): Promise<string | null> {
const creds = await getStoredCredentials();
if (!creds) return null;
return computeSessionToken(creds.user, creds.password);
}
+// TEMPORAL (added for /tv free access 9‑18 America/Cancun)
+export function isWithin(
+ startHour: number,
+ endHour: number,
+ tz: string = "America/Cancun"
+): boolean {
+ const now = new Date(); // UTC
+ const zonedNow = utcToZonedTime(now, tz);
+ const hour = zonedNow.getHours();
+ if (startHour < endHour) {
+ return hour >= startHour && hour < endHour;
+ }
+ return hour >= startHour || hour < endHour;
+}
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -1,5 +1,10 @@
import { NextResponse, type NextRequest } from "next/server";
-import { AUTH_COOKIE_NAME, getExpectedToken } from "@/lib/auth";
+import {
+ AUTH_COOKIE_NAME,
+ getExpectedToken,
+ isWithin,
+} from "@/lib/auth";
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
@@ -7,6 +12,14 @@ export async function middleware(request: NextRequest) {
- // Existing auth logic…
+ // 1️⃣ Allow public access to /tv during office hours
+ if (pathname === "/tv" && isWithin(9, 18, "America/Cancun")) {
+ return NextResponse.next();
+ }
// 2️⃣ Normal auth flow for everything else
const token = request.cookies.get(AUTH_COOKIE_NAME)?.value ?? null;
const expected = await getExpectedToken();
4. Test locally
I added a quick unit test for isWithin:
ts
// tests/auth.test.ts
import { isWithin } from "@/lib/auth";
test("isWithin returns true inside window", () => {
const fakeNow = new Date("2026-08-30T14:00:00Z"); // 9 am Cancun
jest.spyOn(global, "Date").mockImplementation(() => fakeNow as any);
expect(isWithin(9, 18, "America/Cancun")).to
---
*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building SaaS projects from Playa del Carmen, México.*
*Repo: `zaerohell/pcview` · 2026-08-29*
\#playadev #buildinpublic
Top comments (0)