Skip to Main Content
ISR, TypeScript, and Next.js vs Astro for Multi‑Tenant SaaS (Waldium)

ISR, TypeScript, and Next.js vs Astro for Multi‑Tenant SaaS (Waldium)

A CTO-level deep dive comparing ISR, TypeScript, and Next.js vs Astro for multi-tenant SaaS using Waldium’s architecture, with concrete code and operational trade-offs.

SSShivam Singhal

Introduction

I’ll be blunt: multi-tenant SaaS can make smart engineers feel like village dunces. Every decision—rendering strategy, cache keys, tenant isolation—ripples across reliability, latency, and cost. In this post I’ll dissect Incremental Static Regeneration (ISR), TypeScript ergonomics, and the trade-offs between Next.js and Astro for multi-tenant SaaS, using our platform Waldium as a concrete case study.

Waldium is a white-labeled, multi-tenant knowledge base and portal product. Tenants get their own subdomains, per-tenant theming, and dynamic content (docs, changelogs, support widgets). We need fast-first loads for marketing and docs, while keeping dashboards responsive and fresh. ISR looked like the goldilocks option—if we could wire it to tenants, invalidate cleanly, and keep costs down.

This article targets senior full-stack developers and platform engineers choosing a framework for their own multi-tenant architecture. I’ll show designs, code, and the places I tripped so you don’t have to.

What Waldium Looks Like Under the Hood

  • Tenancy model: row-level security (RLS) in Postgres + Redis for hot cache + object storage for media assets. Tenants are resolved via subdomain and verified via a domain mapping table.
  • Edge and functions: CDN in front of edge/serverless (Node and sometimes edge runtimes), with on-demand revalidation webhooks.
  • Rendering: we mix SSG, ISR, SSR, and islands hydration depending on route type.
  • Types: universal TypeScript types shared across frontend, server, and infra scripts using a /packages/types workspace. Runtime validation via zod.
  • Theming: per-tenant theme tokens in CSS variables, pulled server-side from DB and streamed early.

High-Level Rendering Choices for Multi-Tenant SaaS

  • SSG (static): Fastest, cheapest per-request, but rebuilds hurt as tenants/pages grow. Great for rarely-changing docs.
  • SSR: Fresh data, but higher P95, increased load on DB, and cold-start risks. Good for auth’d dashboards.
  • ISR: Best-of-both when content updates intermittently. Stale stays available; background revalidation refreshes cache.
  • Islands/partial hydration: Hydrate only interactive parts. Low JS cost, high perceived performance.

Where this intersects with multi-tenancy: you want to keep hot paths cached per-tenant, avoid global rebuilds, make purges targeted, and ensure no data leaks across tenants.

Next.js and ISR in the App Router Era

ISR used to be associated with getStaticProps + revalidate in the Pages Router. In the App Router, revalidation is driven by the fetch cache and cache tags.

Core concepts you’ll use:

  • fetch(..., { next: { revalidate } }): sets ISR TTL in seconds for the response.
  • fetch(..., { next: { tags: ["tenant:acme", "tenant:acme:page:/pricing"] } }): add cache tags for targeted invalidation.
  • revalidateTag(tag): purge cache for that tag on demand (webhook or admin action).
  • Draft mode: bypass cache for preview.

A Tenant-Aware Rewrite With Next.js Middleware

We map subdomains to virtual routes without duplicating page files.

Code: middleware.ts

import { NextResponse, NextRequest } from 'next/server';

const PUBLIC_HOSTNAMES = new Set(['localhost', 'waldium.app']);

export function middleware(req: NextRequest) {
  const url = req.nextUrl.clone();
  const hostname = req.headers.get('host') || '';
  const [sub] = hostname.split(':')[0].split('.');

  // Map subdomain to an internal path namespace
  if (sub && sub !== 'www' && PUBLIC_HOSTNAMES.has(hostname.split(':')[0].slice(-(hostname.split(':')[0].split('.').length > 2 ? 2 : 1)).join?.?.toString?.() ?? 'waldium.app')) {
    // Skip, but in practice you’ll implement a safer domain check above
  }

  if (sub && sub !== 'www' && sub !== 'waldium') {
    url.pathname = `/_sites/${sub}${url.pathname}`; // e.g., /_sites/acme/docs/intro
    return NextResponse.rewrite(url);
  }

  return NextResponse.next();
}

Note: implement a robust domain ownership check. The above elides production-grade validation to keep the example focused.

ISR Page With Cache Tags (App Router)

File: app/_sites/[tenant]/[...slug]/page.tsx

import 'server-only';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { fetchJson } from '@/lib/fetchJson'; // thin wrapper over fetch

// Fetch tenant-aware page data with ISR and tags
async function getPage(tenant: string, slug: string[]) {
  const path = `/${slug.join('/') || ''}`;
  const res = await fetchJson(
    `${process.env.API_ORIGIN}/content?page=${encodeURIComponent(path)}&tenant=${tenant}`,
    {
      // ISR in App Router
      next: {
        revalidate: 60, // 1 minute TTL for background regeneration
        tags: [
          `tenant:${tenant}`,
          `tenant:${tenant}:page:${path || '/'}`,
        ],
      },
    }
  );
  if (!res.ok) throw new Error('Fetch failed');
  return res.data as { title: string; html: string; theme: Record<string, string> } | null;
}

export async function generateMetadata({ params }: { params: { tenant: string; slug?: string[] } }): Promise<Metadata> {
  const data = await getPage(params.tenant, params.slug ?? []);
  if (!data) return {};
  return { title: data.title };
}

export default async function TenantPage({ params }: { params: { tenant: string; slug?: string[] } }) {
  const data = await getPage(params.tenant, params.slug ?? []);
  if (!data) notFound();

  return (
    <html>
      <head>
        <style>{
          Object.entries(data.theme)
            .map(([k, v]) => `:root{--${k}:${v}}`)
            .join('')
        }</style>
      </head>
      <body>
        {/* streaming-safe: html is trusted/sanitized server-side */}
        <main dangerouslySetInnerHTML={{ __html: data.html }} />
      </body>
    </html>
  );
}

On-Demand Revalidation by Tenant

Wire up a route handler for webhooks (e.g., CMS publish, admin save) to purge tenant caches.

// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
import { verifyHmac } from '@/lib/verifyHmac';

export async function POST(req: Request) {
  const body = await req.json();
  const ok = await verifyHmac(req.headers.get('x-signature') || '', JSON.stringify(body));
  if (!ok) return new Response('Unauthorized', { status: 401 });

  const { tenant, pagePath } = body as { tenant: string; pagePath?: string };
  if (!tenant) return new Response('Bad Request', { status: 400 });

  // Coarse bust
  revalidateTag(`tenant:${tenant}`);
  // Optional fine-grained bust
  if (pagePath) revalidateTag(`tenant:${tenant}:page:${pagePath}`);

  return Response.json({ revalidated: true });
}

Preview With Draft Mode

// app/api/preview/route.ts
import { draftMode } from 'next/headers';

export async function GET() {
  draftMode().enable();
  return new Response('Draft mode enabled');
}

When draft mode is on, the route will bypass the cache—great for tenant editors.

Astro for Multi-Tenant SaaS: SSR + Islands + CDN/Store Caching

Astro’s value prop is content-first performance and partial hydration (islands). ISR is not a first-class primitive in Astro. You’ll implement “ISR-like” behavior using:

  • SSR with server adapter (Node, Cloudflare, Vercel, etc.).
  • Middleware to resolve tenant from subdomain and write to Astro.locals.
  • A tenant-aware cache (CDN or KV/Redis) with TTL and/or on-demand purges.
  • Prerendering of certain routes for zero-latency static delivery.

Tenant Resolution in Astro Middleware

src/middleware.ts

import type { MiddlewareHandler } from 'astro';

export const onRequest: MiddlewareHandler = async (ctx, next) => {
  const host = ctx.request.headers.get('host') ?? '';
  const sub = host.split(':')[0].split('.')[0];
  // Production: validate against domain mapping table
  ctx.locals.tenant = sub && sub !== 'www' ? sub : 'root';
  return next();
};

Types for locals

src/env.d.ts

/// <reference types="astro/client" />

declare namespace App {
  interface Locals {
    tenant: string;
    kv?: {
      get<T = unknown>(key: string): Promise<T | null>;
      set<T = unknown>(key: string, value: T, opts?: { ttl?: number }): Promise<void>;
      delete(key: string): Promise<void>;
      list?(opts?: { prefix?: string }): Promise<{ keys: { name: string }[] }>;
    };
  }
}

A Tenant-Aware SSR Page With KV Cache

src/pages/[...slug].astro

---
import BaseLayout from '../layouts/BaseLayout.astro';
const { locals, params } = Astro;
const tenant = locals.tenant;
const path = '/' + (Array.isArray(params.slug) ? params.slug.join('/') : (params.slug ?? ''));

const cacheKey = `t:${tenant}:p:${path || '/'}`;
let page = await locals.kv?.get<{ title: string; html: string; theme: Record<string, string> }>(cacheKey);

if (!page) {
  const res = await fetch(`${import.meta.env.API_ORIGIN}/content?tenant=${tenant}&page=${encodeURIComponent(path)}`);
  if (!res.ok) return Astro.redirect('/404');
  page = await res.json();
  await locals.kv?.set(cacheKey, page, { ttl: 60 }); // ISR-like TTL
}
---
<html lang="en">
  <head>
    <title>{page?.title}</title>
    <style>
      :root { /* theme tokens */ }
      {Object.entries(page?.theme ?? {}).map(([k,v]) => `:root{--${k}:${v}}`).join('')}
    </style>
  </head>
  <body>
    <BaseLayout>
      <div set:html={page?.html} />
    </BaseLayout>
  </body>
</html>

On-Demand Cache Purge (Astro API Route)

src/pages/api/revalidate.ts

import type { APIRoute } from 'astro';

export const POST: APIRoute = async ({ request, locals }) => {
  const body = await request.json();
  // TODO: verify HMAC
  const { tenant, pagePath } = body as { tenant: string; pagePath?: string };
  if (!tenant) return new Response('Bad Request', { status: 400 });

  if (pagePath) {
    await locals.kv?.delete(`t:${tenant}:p:${pagePath}`);
  } else if (locals.kv?.list) {
    const list = await locals.kv.list({ prefix: `t:${tenant}:p:` });
    for (const k of list.keys) await locals.kv.delete(k.name);
  }

  return new Response(JSON.stringify({ revalidated: true }), {
    headers: { 'content-type': 'application/json' },
  });
};

SWR at the CDN

Even without KV, you can push caching to the CDN using response headers:

new Response(html, {
  headers: { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=86400' },
});

This gives you “ISR-adjacent” behavior—stale is served instantly, CDN revalidates in the background.

Architecture Sketches

Next.js (App Router + ISR)

Tenant subdomain -> Edge (middleware host -> /_sites/[tenant])
  -> RSC route (fetch with {revalidate, tags})
    -> API origin (RLS) -> DB/Cache
  <- HTML stream (themed)
On-demand webhook -> /api/revalidate -> revalidateTag(tenant:*), near-instant purge

Astro (SSR + KV/CDN caching)

Tenant subdomain -> Middleware (locals.tenant)
  -> SSR page (check KV -> miss -> fetch API -> set TTL)
  <- HTML (islands hydrated as needed)
On-demand webhook -> API route -> KV delete (prefix purge) or CDN purge

TypeScript: Where The Rubber Meets The Road

Both frameworks are TypeScript-first, but DX differs:

  • Next.js

    • RSC + server actions are TS-friendly; server-only modules enforce isolation.
    • Route handlers (app/api) are typed via the Request/Response web std, but helpers are evolving; zod remains your friend at the boundary.
    • Cache tags are just strings—codify tags as branded types or helpers to avoid typos.
  • Astro

    • .astro files allow TypeScript in frontmatter; components (React/Svelte/Solid/Vue) are fully TS.
    • Middleware and API routes are typed; app-wide Locals is ergonomic via env.d.ts.
    • Because ISR is DIY, create a strongly-typed cache client interface to avoid footguns across handlers.

Example: Branded cache tag helpers (Next.js)

type TenantTag = `tenant:${string}`;
const tenantTag = (t: string) => `tenant:${t}` as TenantTag;
const pageTag = (t: string, p: string) => `tenant:${t}:page:${p}` as const;

Security and Isolation

  • DB isolation: Prefer RLS in Postgres with a strict JWT/session embedding tenant_id, or schema-per-tenant if your operational maturity supports it. We use RLS with a defense-in-depth guard in the data access layer (tenant id required in all queries).
  • Cache isolation: Namespacing (tenant:ID) must be mandatory. No shared keys between tenants.
  • Revalidation/auth: HMAC-signed webhooks with nonce and replay protection. Keep revalidation coarse-grained as a fallback—fine-grained is a nice-to-have.
  • Secret leakage: Next.js RSC and Astro SSR both keep secrets server-side; never leak theme or content secrets into the client bundle.

Performance, Scalability, and Cost

  • Latency profile

    • Next.js ISR: cache hits are CDN-fast; misses kick a background regeneration while still serving stale. P95s remain low under churny-but-not-constant updates.
    • Astro SSR + KV: cache hits are quick; misses perform a fetch + KV write. With CDN SWR, cold miss penalty can be masked for end users.
  • Build time and scale

    • Next.js: You can avoid building N×M tenant pages by leaning on ISR at runtime. generateStaticParams is optional. This keeps build times bounded.
    • Astro: Prerendering thousands of tenant pages can balloon build times; favor SSR + caching to keep build cheap.
  • Operational cost

    • Next.js ISR does heavy lifting in the platform cache layer; cost shifts to storage + occasional regenerations.
    • Astro’s DIY ISR puts pressure on your cache/KV and function invocations. Effective TTLs and purges matter.
  • Cold starts

    • Node SSR cold starts still exist for both; using edge runtimes (where possible) and keeping bundles small helps.
  • Invalidations

    • Next.js: revalidateTag is near-instant and ergonomic for tenant-level purges.
    • Astro: implement cache prefix purges (KV + optional CDN purge API). Slightly more ops.

Theming at Scale

Theme token resolution must be fast, isolated, and cacheable.

Next.js example: server-only theme fetch with tags

// app/_sites/[tenant]/theme.ts
import 'server-only';

export async function getTheme(tenant: string) {
  const res = await fetch(`${process.env.API_ORIGIN}/theme?tenant=${tenant}`, {
    next: { revalidate: 300, tags: [`tenant:${tenant}`, `tenant:${tenant}:theme`] },
  });
  return (await res.json()) as Record<string, string>;
}

Astro example: early-inline CSS vars in SSR

const theme = (await locals.kv?.get<Record<string, string>>(`t:${tenant}:theme`))
  ?? await fetchThemeFromAPIAndSetKV(tenant);
const inline = Object.entries(theme).map(([k,v]) => `--${k}:${v}`).join(';');

Case Study: Waldium’s Routing and Caching Strategy

  • Routes we prerender (both frameworks):

    • Public marketing for root domain.
    • Tenant home pages with slow-changing hero sections.
  • Routes using ISR or SSR:

    • Docs, changelogs, and portal pages under tenant subdomains: ISR in Next.js; SSR+KV in Astro.
    • Authenticated dashboards: pure SSR with short-lived cache hints, plus client-side SWR for data freshness.
  • Data invalidation sources:

    • CMS publish event -> webhook -> revalidateTag(tenant) or KV prefix purge.
    • Admin theme change -> revalidateTag(tenant:theme) or kv.delete(theme-key).
    • Bulk import -> staged progressive invalidation to avoid thundering herds.

Observations from Our Prototyping

  • Developer ergonomics:

    • Next.js’s cache tags are deceptively simple and extremely effective. It’s easy to create a tenant top-level tag and attach it everywhere.
    • Astro’s simplicity feels liberating until you build your own invalidation mesh; then you realize you’ve become a CDN engineer. It’s not bad—just a choice.
  • Performance under churn:

    • Light, frequent content changes: Next.js ISR’s “serve stale, refresh in background” shines. Users basically never see cold states.
    • Heavy batched updates: Both need guardrails. In Next.js, we stagger revalidateTag calls; in Astro, we queue KV purges and lean on SWR headers to prevent stampedes.
  • Type safety:

    • In both, the critical place to focus is runtime validation at the edge of the system. Whether you use RSC or Astro API routes, validate inputs with zod and codify cache tags/keys in helpers.

Feature-by-Feature Comparison (Waldium Lens)

  • ISR primitive

    • Next.js: native via fetch revalidate/tags + revalidateTag().
    • Astro: emulate via KV/CDN TTL + purges; no first-class ISR.
  • Multi-tenant routing

    • Next.js: middleware rewrites to /_sites/[tenant]; straightforward.
    • Astro: middleware sets locals.tenant; also straightforward.
  • Data fetching

    • Next.js: RSC fetch with per-request caching and streaming; wonderful for TTFB.
    • Astro: SSR fetch inside .astro or framework components; flexible but you own the caching policy.
  • Preview flows

    • Next.js: draftMode() API; trivial.
    • Astro: add query flags or cookies to bypass cache; trivial but DIY.
  • Theming

    • Both: CSS variables + server fetch. Next.js streams early; Astro inlines early.
  • Content-heavy sites with islands

    • Astro’s islands model is hard to beat for minimum JS on the wire.
    • Next.js can approach similar outcomes with RSC + selective client components.
  • Ops complexity

    • Next.js: lower for ISR; higher for advanced edge/device targeting.
    • Astro: higher for invalidation; otherwise minimal.

A Simple Decision Matrix

Choose Next.js (App Router) if:

  • You want native ISR with cache tags and easy on-demand revalidation.
  • Your team is already React-heavy and wants RSC/server actions benefits.
  • You expect lots of tenant content updates and need reliable, low-latency cache refreshes.

Choose Astro if:

  • Your product is content-first with heavy emphasis on minimal client JS and islands.
  • You’re comfortable managing cache TTLs, KV stores, and CDN purges.
  • You want framework-agnostic UI components (React/Svelte/Vue) and fine control over hydration.

Hybrid Strategy (what Waldium runs in practice):

  • Marketing site on Astro (or Next.js static) because it’s mostly static and benefits from islands.
  • Tenant portals and docs on Next.js with ISR + tags for operational simplicity.
  • Shared TypeScript types across both via a monorepo; common data access utilities with runtime validation.

Common Pitfalls and How to Dodge Them

  • Cache key drift: Standardize helpers for tags/keys. Add unit tests around key generation.
  • Tenant leakage: Assert tenant id on every server call. In RLS, SET LOCAL ROLE with tenant context; in app code, never allow “no-tenant” reads.
  • Over-invalidation: Revalidating per-page on bulk changes can stampede the origin. Attach a top-level tenant tag and purge coarsely first; let pages repopulate over time.
  • Build bloat: Don’t pre-generate everything. Let ISR/SSR do the work at runtime.
  • Feature flags: Flags must be part of the cache key/tag set if they affect rendering. Else you’ll serve wrong variants.

Testing and Observability

  • Snapshot tests for rendered HTML per-tenant to catch theme/token regressions.
  • Synthetic checks against hot pages after revalidation events.
  • Structured logging: log tenant, cacheHit/miss, tag, TTL remaining.
  • Traces/metrics: measure P50/P95 for hit vs miss, and revalidation queue depth.

Migration Notes (if you’re switching)

  • Astro -> Next.js:

    • Port shared UI to React client components; keep heavy content in RSC.
    • Replace KV/CDN TTL logic with fetch next:{revalidate, tags} and revalidateTag.
  • Next.js -> Astro:

    • Extract server-only logic into middleware + SSR components; build a KV cache client.
    • Replace draft mode with a cookie flag; add bypass in middleware.

Cost Guardrails

  • Bound regeneration rate: cap per-tenant revalidation frequency.
  • Prefer coarse invalidations: one tag for tenant-level bust, optional fine tags for hot pages.
  • Tiered TTLs: homepage shorter TTL than leaf docs; align with update frequency.
  • Observe memory limits: serverless caches and large RSC payloads can blow limits; stream early, cache small.

Conclusion

If you want turnkey ISR with surgical invalidation for a multi-tenant SaaS, Next.js’s App Router is the clear favorite. Cache tags, draft mode, and RSC yield excellent latency and a clean operational story. Astro shines when you care most about minimal client-side JavaScript and control over hydration, but you’ll own the ISR story—either via KV or the CDN.

Waldium runs a hybrid: Next.js for tenant portals and content where ISR’s ergonomics pay off, and a content-first layer where islands matter most. Both stacks are TypeScript-friendly; both can be secured for multi-tenant isolation; both scale—if you treat cache keys and invalidation as first-class APIs.

The village dunce in me learned this the hard way: choose the rendering model per route, per tenant, not per framework fan club. Make cache keys boring. Make invalidation predictable. Then enjoy the speed.

Related Posts