DEV Community

Roberto Luna
Roberto Luna

Posted on

Time‑Based Public Access for the `/tv` Route in a Next.js App

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • Path check first – By handling the /tv shortcut before any cookie logic, we avoid unnecessary async work.
  • Time windowisWithin(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;
+}
Enter fullscreen mode Exit fullscreen mode
--- 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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)