Multi-Tenant Subdomain Routing with Next.js 16, SST, and CloudFront

Storyie Engineering Team
6 min read

Learn how we implemented subdomain-based multi-tenancy for user profiles using Next.js middleware, SST wildcard domains, and CloudFront distribution to enable custom user URLs like username.storyie.com.

Multi-Tenant Subdomain Routing with Next.js 16, SST, and CloudFront

At Storyie, we wanted to give our premium users their own personalized space on the web. Instead of accessing profiles at storyie.com/u/username, paid subscribers can now use username.storyie.com - a cleaner, more memorable URL that feels like their own corner of the internet. In this post, we'll walk through the complete implementation of subdomain-based multi-tenancy using Next.js 16, SST (Serverless Stack), and CloudFront.

Architecture Overview

Our multi-tenant architecture consists of several interconnected layers:

User Request (username.storyie.com)
    |
CloudFront (CDN with wildcard domain)
    |
Lambda@Edge (Edge routing)
    |
Next.js Middleware (proxy.ts)
    |-- Extract subdomain from hostname
    |-- Verify paid subscription status
    |-- Rewrite URL to internal route
    |
Next.js App Router
    |-- /u/[slug]/* (user profile routes)
    |-- /diary/[slug] (subdomain diary routes)

The key insight is that subdomain routing is handled entirely at the middleware layer, with no changes required to the underlying page components. The same React components serve both storyie.com/u/username and username.storyie.com - the middleware simply rewrites the URL before it reaches the router.

SST Configuration: Wildcard Domains

The first step is configuring SST to accept requests on wildcard subdomains. In our sst.config.ts, we define different domain configurations for production and staging environments:

// sst.config.ts
export default $config({
  app(input) {
    return {
      name: 'storyie',
      removal: input?.stage === 'production' ? 'retain' : 'remove',
      home: 'aws',
      providers: {
        aws: { region: 'us-east-1' },
        cloudflare: {
          apiToken: process.env.CLOUDFLARE_API_TOKEN,
          accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
        },
      },
    };
  },
  async run() {
    const stage = $app.stage;

    // Stage-specific domain configuration
    // Production: storyie.com + *.storyie.com (wildcard)
    // Staging: staging.storyie.com + *.staging.storyie.com
    const domain =
      stage === 'production'
        ? {
            name: 'storyie.com',
            aliases: ['*.storyie.com'],
            dns: sst.cloudflare.dns(),
          }
        : {
            name: 'staging.storyie.com',
            aliases: ['*.staging.storyie.com'],
            dns: sst.cloudflare.dns(),
          };

    new sst.aws.Nextjs('StoryieWeb', {
      path: './apps/web',
      domain,
      // ... rest of configuration
    });
  },
});

The aliases array is crucial - it tells CloudFront to accept requests for any subdomain matching the wildcard pattern. SST handles:

  • ACM Certificate: Automatically provisions SSL certificates for both the main domain and wildcard
  • CloudFront Distribution: Configures the CDN to route wildcard requests to our Lambda functions
  • Cloudflare DNS: Creates the necessary DNS records (including wildcard A/AAAA records)

Environment-Specific URLs

We also configure environment-specific base URLs that the application uses for generating links:

environment: {
  // Base URL based on SST stage
  NEXT_PUBLIC_BASE_URL:
    stage === "production"
      ? "https://storyie.com"
      : "https://staging.storyie.com",
  NEXT_PUBLIC_SITE_URL:
    stage === "production"
      ? "https://storyie.com"
      : "https://staging.storyie.com",
  WEB_URL:
    stage === "production"
      ? "https://storyie.com"
      : "https://staging.storyie.com",
}

This ensures that links generated in the application always point to the correct environment, regardless of whether the request came from a subdomain or the main domain.

Next.js Configuration: Development Subdomain Support

During local development, subdomains don't work out of the box because localhost doesn't support them. We use a workaround with .localhost domains in next.config.ts:

// apps/web/next.config.ts
const nextConfig: NextConfig = {
  // Allow subdomain requests during development for multi-tenant setup
  allowedDevOrigins: ['*.storyie.localhost', 'max.storyie.localhost'],

  experimental: {
    serverActions: {},
  },

  // Turbopack configuration
  turbopack: {
    resolveAlias: {
      'react-native': 'react-native-web',
    },
    resolveExtensions: [
      '.web.js',
      '.web.jsx',
      '.web.ts',
      '.web.tsx',
      '.tsx',
      '.ts',
      '.jsx',
      '.js',
      '.mjs',
      '.json',
    ],
  },
};

For local testing, developers can add entries to /etc/hosts:

127.0.0.1 storyie.localhost
127.0.0.1 testuser.storyie.localhost

Then access http://testuser.storyie.localhost:3000 to test subdomain routing locally.

Subdomain Utility Functions

We created a comprehensive set of utility functions in lib/utils/subdomain.ts to handle subdomain operations throughout the application:

// apps/web/lib/utils/subdomain.ts

/**
 * Extract subdomain from hostname
 * @param hostname - Full hostname (e.g., "user.storyie.com")
 * @returns Subdomain or null if no subdomain exists
 */
export function getSubdomain(hostname: string): string | null {
  // Remove port if present
  const host = hostname.split(':')[0];
  const parts = host.split('.');

  // If we have at least 3 parts (subdomain.domain.tld), extract subdomain
  if (parts.length >= 3) {
    return parts[0];
  }

  return null;
}

/**
 * Check if subdomain should be redirected to main domain
 * www, web, app -> redirect to storyie.com
 */
export function shouldRedirectSubdomain(subdomain: string | null): boolean {
  if (!subdomain) return false;

  const redirectSubdomains = ['www', 'web', 'app'];
  return redirectSubdomains.includes(subdomain.toLowerCase());
}

/**
 * Check if subdomain is an environment subdomain (staging, test, etc.)
 * These should not be treated as user subdomains
 */
export function isEnvironmentSubdomain(subdomain: string | null): boolean {
  if (!subdomain) return false;

  const environmentSubdomains = ['staging', 'test', 'preview'];
  return environmentSubdomains.includes(subdomain.toLowerCase());
}

/**
 * Check if hostname is a user subdomain
 * Returns true for user.storyie.com, false for www.storyie.com or staging.storyie.com
 */
export function isUserSubdomain(hostname: string): boolean {
  const subdomain = getSubdomain(hostname);

  if (!subdomain) return false;
  if (shouldRedirectSubdomain(subdomain)) return false;
  if (isEnvironmentSubdomain(subdomain)) return false;

  return true;
}

/**
 * Build URL with subdomain for a user
 * For localhost, falls back to /u/username path since subdomains don't work locally
 */
export function buildSubdomainUrl(
  subdomain: string,
  path = '/',
  baseUrl?: string
): string {
  const base =
    baseUrl || process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
  const url = new URL(base);

  // For localhost, don't add subdomain (doesn't work locally)
  if (url.hostname.includes('localhost')) {
    return `${base}/u/${subdomain}${path}`;
  }

  // Add subdomain to hostname
  url.hostname = `${subdomain}.${getMainDomain(url.hostname)}`;
  url.pathname = path;

  return url.toString();
}

/**
 * Get main service URL (not tenant subdomain)
 * Used for global navigation links (explore, blog, login, etc.)
 */
export function getMainServiceUrl(): string {
  const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';

  if (baseUrl.includes('localhost')) {
    return baseUrl;
  }

  const url = new URL(baseUrl);
  const subdomain = getSubdomain(url.hostname);

  // If current base URL is on a subdomain, remove it
  if (subdomain && isUserSubdomain(url.hostname)) {
    url.hostname = getMainDomain(url.hostname);
  }

  return url.toString().replace(/\/$/, '');
}

These utilities enable consistent subdomain handling across the application, whether we're generating URLs, checking access permissions, or routing requests.

The Proxy Middleware: Heart of Multi-Tenancy

The core of our multi-tenant implementation is the proxy.ts middleware. This runs on every request and handles subdomain detection, access control, and URL rewriting:

// apps/web/proxy.ts
import { createServerClient } from '@supabase/ssr';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import {
  getMainDomain,
  getSubdomain,
  isUserSubdomain,
  shouldRedirectSubdomain,
} from '@/lib/utils/subdomain';

export async function proxy(req: NextRequest) {
  const hostname = req.headers.get('host') || '';
  const pathname = req.nextUrl.pathname;

  // Skip middleware for static files, API routes, and Next.js metadata
  const skipPaths = [
    '/_next',
    '/api',
    '/opengraph-image',
    '/twitter-image',
    '/icon',
    '/favicon.ico',
    '/manifest',
    '/robots.txt',
    '/sitemap.xml',
  ];

  if (skipPaths.some((p) => pathname.startsWith(p)) || pathname.includes('.')) {
    return NextResponse.next();
  }

  // Redirect www/web/app subdomains to main domain
  const subdomain = getSubdomain(hostname);
  if (shouldRedirectSubdomain(subdomain)) {
    const mainDomain = getMainDomain(hostname);
    const url = req.nextUrl.clone();
    url.host = mainDomain;
    return NextResponse.redirect(url, 301);
  }

  // Handle user subdomain routing
  if (isUserSubdomain(hostname)) {
    // Production: Verify paid subscription
    if (process.env.NODE_ENV === 'production') {
      const { tenantQueries } = await import('@/lib/db/queries/tenant');
      const hasAccess = await tenantQueries.hasActivePaidSubscription(
        subdomain!
      );

      if (!hasAccess) {
        // No paid subscription -> redirect to main domain
        const mainDomain = getMainDomain(hostname);
        const url = req.nextUrl.clone();
        url.host = mainDomain;
        url.pathname = '/';
        return NextResponse.redirect(url, 302);
      }
    }

    // Rewrite subdomain requests to internal routes
    const url = req.nextUrl.clone();

    // /diary/[slug] stays as-is for subdomain diary route
    const diaryMatch = pathname.match(/^\/diary\/(.+)$/);
    if (diaryMatch) {
      url.pathname = pathname;
    }
    // /u/[subdomain]/diary/[slug] -> /diary/[slug] (normalize)
    else if (pathname.startsWith(`/u/${subdomain}/diary/`)) {
      const diarySlug = pathname.substring(`/u/${subdomain}/diary/`.length);
      url.pathname = `/diary/${diarySlug}`;
    }
    // /* -> /u/[subdomain]/* (rewrite to user profile)
    else {
      url.pathname = `/u/${subdomain}${pathname}`;
    }

    const response = NextResponse.rewrite(url);

    // Pass subdomain info to page components via headers
    response.headers.set('x-subdomain', subdomain!);
    response.headers.set('x-original-pathname', pathname);

    return response;
  }

  // Continue to standard routing for non-subdomain requests
  // ... authentication handling for protected routes
  return NextResponse.next();
}

URL Rewriting Strategy

The middleware implements smart URL rewriting that provides clean URLs on subdomains while maintaining a single set of page components:

| Request URL | Internal Route | Notes |
| ---------------------------------------------- | --------------- | ----------------------- |
| username.storyie.com/ | /u/username/ | User profile |
| username.storyie.com/diary/my-day | /diary/my-day | Diary (subdomain route) |
| username.storyie.com/u/username/diary/my-day | /diary/my-day | Normalized URL |

The x-subdomain header is crucial - it tells downstream components that the request originated from a subdomain, allowing them to:

  • Generate appropriate links (relative paths vs absolute URLs)
  • Set correct canonical URLs for SEO
  • Customize the user experience based on context

Database: Tenant Management

We store tenant information in a dedicated tenants table using Drizzle ORM:

// packages/database/src/schemas/tenants.ts
import { sql } from 'drizzle-orm';
import {
  index,
  pgPolicy,
  pgTable,
  text,
  timestamp,
  uuid,
} from 'drizzle-orm/pg-core';
import { authUsers } from 'drizzle-orm/supabase';

export const subscriptionPlans = {
  FREE: 'free',
  PRO: 'pro',
  BUSINESS: 'business',
} as const;

export const tenants = pgTable(
  'tenants',
  {
    id: uuid('id')
      .primaryKey()
      .notNull()
      .default(sql`gen_random_uuid()`),
    userId: uuid('user_id')
      .notNull()
      .references(() => authUsers.id, { onDelete: 'cascade' }),
    slug: text('slug').notNull().unique(), // Subdomain (e.g., "acme" for acme.storyie.com)
    subscriptionPlan: text('subscription_plan').notNull().default('free'),
    subscriptionStatus: text('subscription_status').notNull().default('active'),
    subscriptionExpiresAt: timestamp('subscription_expires_at', {
      withTimezone: true,
    }),
    stripeCustomerId: text('stripe_customer_id'),
    stripeSubscriptionId: text('stripe_subscription_id'),
    createdAt: timestamp('created_at', { withTimezone: true })
      .defaultNow()
      .notNull(),
    updatedAt: timestamp('updated_at', { withTimezone: true })
      .defaultNow()
      .notNull(),
  },
  (table) => ({
    userIdIdx: index('tenants_user_id_idx').on(table.userId),
    slugIdx: index('tenants_slug_idx').on(table.slug),
    // RLS policies for security
    selectPolicy: pgPolicy('authenticated_can_view_all_tenants', {
      for: 'select',
      using: sql`true`,
    }),
    insertPolicy: pgPolicy('user_can_insert_own_tenant', {
      for: 'insert',
      withCheck: sql`${table.userId} = (select auth.uid())`,
    }),
    updatePolicy: pgPolicy('user_can_update_own_tenant', {
      for: 'update',
      using: sql`${table.userId} = (select auth.uid())`,
    }),
  })
);

The tenant query checks subscription status before allowing subdomain access:

// apps/web/lib/db/queries/tenant.ts
export const tenantQueries = {
  /**
   * Check if tenant has active paid subscription
   * @param slug - Tenant slug (subdomain)
   * @returns True if tenant has active PRO or BUSINESS subscription
   */
  async hasActivePaidSubscription(slug: string): Promise<boolean> {
    const tenant = await this.getTenantBySlug(slug);

    if (!tenant) return false;
    if (tenant.subscriptionStatus !== 'active') return false;
    if (tenant.subscriptionPlan === 'free') return false;

    // Check expiration date
    if (tenant.subscriptionExpiresAt) {
      const now = new Date();
      if (tenant.subscriptionExpiresAt < now) return false;
    }

    return true;
  },
};

SEO Considerations: Canonical URLs

Subdomain pages create potential duplicate content issues for search engines. We handle this with proper canonical URLs and robots directives:

// apps/web/app/(public)/diary/[diarySlug]/page.tsx
export async function generateMetadata({ params }: PageProps) {
  const { diarySlug } = await params;
  const headersList = await headers();
  const subdomain = headersList.get('x-subdomain');

  // ... fetch diary data

  // Build canonical URL pointing to main domain
  const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || '';
  const canonicalPath = `/u/${authorSlug}/diary/${diarySlug}`;
  const canonicalUrl = buildCanonicalUrl(baseUrl, canonicalPath);

  return {
    title,
    description,
    robots: {
      index: false, // Don't index subdomain pages
      follow: true, // Allow following links for user experience
    },
    alternates: {
      canonical: canonicalUrl, // Point to main domain as canonical
    },
    openGraph: {
      title,
      description,
      type: 'article',
      url: canonicalUrl, // Use canonical URL in Open Graph
    },
  };
}

The SEO utility function detects subdomain requests via the header set by our middleware:

// apps/web/lib/utils/seo.ts
/**
 * Check if the current request is from a subdomain
 */
export function isSubdomainRequest(headers: Headers): boolean {
  const subdomain = headers.get('x-subdomain');
  return !!subdomain;
}

/**
 * Build canonical URL for the main domain
 */
export function buildCanonicalUrl(baseUrl: string, path: string): string {
  const normalizedBaseUrl = baseUrl.endsWith('/')
    ? baseUrl.slice(0, -1)
    : baseUrl;
  const normalizedPath = path.startsWith('/') ? path : `/${path}`;
  return `${normalizedBaseUrl}${normalizedPath}`;
}

Client-Side Navigation: Main Service URLs

When users are on a subdomain, navigation links to global pages (Explore, Blog, Login) should point to the main domain. We handle this in our Header component:

// apps/web/components/Header.tsx
'use client';

import { getMainServiceUrl, isUserSubdomain } from '@/lib/utils/subdomain';

export default function Header() {
  // Get main service URL to avoid tenant domain in global navigation
  const mainServiceUrl = getMainServiceUrl();

  // Check if current domain is a tenant subdomain
  const [isTenantDomain, setIsTenantDomain] = useState(false);

  useEffect(() => {
    if (typeof window !== 'undefined') {
      setIsTenantDomain(isUserSubdomain(window.location.hostname));
    }
  }, []);

  return (
    <header>
      <nav>
        {/* Always link to main domain for global pages */}
        <Link href={mainServiceUrl}>Storyie</Link>
        <Link href={`${mainServiceUrl}/explore`}>Explore</Link>
        <Link href={`${mainServiceUrl}/blog`}>Blog</Link>

        {/* Hide login button on tenant domains (users are already premium) */}
        {!isTenantDomain && (
          <Link href={`${mainServiceUrl}/login`}>Sign in</Link>
        )}
      </nav>
    </header>
  );
}

This ensures that:

  1. Global navigation works correctly - Users on username.storyie.com can navigate to storyie.com/explore
  2. Login is hidden on tenant domains - Premium users don't need the login CTA
  3. Brand consistency - The logo always links to the main domain

CloudFront Cache Considerations

Auth routes must never be cached since OAuth codes are single-use. We configure CloudFront via SST to disable caching for auth endpoints:

// sst.config.ts
transform: {
  cdn: (args) => {
    // Get AWS Managed Cache Policy for disabling cache
    const cachingDisabledPolicy = await aws.cloudfront.getCachePolicy({
      name: "Managed-CachingDisabled",
    });

    const authCacheBehavior = {
      pathPattern: "/api/auth/*",
      viewerProtocolPolicy: "redirect-to-https",
      allowedMethods: ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"],
      cachedMethods: ["GET", "HEAD"],
      cachePolicyId: cachingDisabledPolicy.id,
      compress: true,
    };

    // Add auth behavior to ordered cache behaviors
    args.orderedCacheBehaviors = $resolve([
      args.orderedCacheBehaviors,
      args.defaultCacheBehavior,
    ]).apply(([existing, defaultBehavior]) => {
      return [
        {
          ...authCacheBehavior,
          targetOriginId: defaultBehavior.targetOriginId,
          originRequestPolicyId: defaultBehavior.originRequestPolicyId,
          functionAssociations: defaultBehavior.functionAssociations,
        },
        ...(existing || []),
      ];
    });
  },
}

Staging Environment: Crawler Control

Non-production environments should not be indexed by search engines. We add X-Robots-Tag headers in the middleware:

// apps/web/proxy.ts
function isProductionEnvironment(): boolean {
  const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || '';
  return (
    process.env.NODE_ENV === 'production' && baseUrl.includes('storyie.com')
  );
}

// In the middleware:
if (!isProductionEnvironment()) {
  response.headers.set('X-Robots-Tag', 'noindex, nofollow');
}

This ensures that staging.storyie.com and *.staging.storyie.com are not indexed by search engines, preventing duplicate content issues and keeping staging content private.

Real-World Example: dev.storyie.com

To see multi-tenant subdomain routing in action, visit dev.storyie.com - the personal diary and notes space of our development team. This is a live example of how premium users experience their own subdomain:

Domain Structure:
├── storyie.com              # Production (main site)
├── dev.storyie.com          # Developer's personal diary (user tenant)
├── [username].storyie.com   # Other premium user subdomains
├── staging.storyie.com      # QA/Testing environment
└── *.staging.storyie.com    # User subdomains on staging

What You'll See at dev.storyie.com

When you visit dev.storyie.com, the multi-tenant routing kicks in:

  1. Middleware Detection: The proxy identifies dev as a user subdomain (not an environment subdomain like staging)
  2. Subscription Check: Verifies the dev tenant has an active paid subscription
  3. URL Rewriting: Rewrites / to /u/dev/ internally, but the user sees the clean subdomain URL
  4. Content Display: Shows the developer's personal diaries, notes, and profile

The visitor experiences:

  • Clean URLs: dev.storyie.com/diary/my-thoughts instead of storyie.com/u/dev/diary/my-thoughts
  • Personal Branding: The subdomain feels like the user's own space
  • Seamless Navigation: Internal links stay within the subdomain context
  • Global Access: Navigation to Explore, Blog, and other main site features works correctly via getMainServiceUrl()

Technical Flow for dev.storyie.com

Here's exactly what happens when someone visits dev.storyie.com/diary/hello-world:

// 1. Request arrives at CloudFront
// Host: dev.storyie.com
// Path: /diary/hello-world

// 2. Middleware extracts subdomain
const subdomain = getSubdomain('dev.storyie.com'); // → "dev"

// 3. Check if it's a user subdomain (not www, staging, etc.)
isUserSubdomain('dev.storyie.com'); // → true

// 4. Verify paid subscription (production only)
await tenantQueries.hasActivePaidSubscription('dev'); // → true

// 5. Rewrite URL (diary paths stay as-is on subdomains)
// /diary/hello-world → /diary/hello-world (no change needed)

// 6. Set headers for downstream components
response.headers.set('x-subdomain', 'dev');
response.headers.set('x-original-pathname', '/diary/hello-world');

// 7. Page component reads x-subdomain header
// Fetches diary by author slug "dev" and diary slug "hello-world"

This demonstrates the power of middleware-based routing: the same diary/[diarySlug]/page.tsx component serves both storyie.com/u/dev/diary/hello-world and dev.storyie.com/diary/hello-world with zero code duplication.

Performance Considerations

Multi-tenant routing adds some overhead, but we've optimized it:

  1. Early Exit for Static Files: Skip middleware entirely for static assets
  2. Cached Database Queries: Tenant subscription checks are cached per-request
  3. Edge Routing: CloudFront handles the initial routing, reducing Lambda cold starts
  4. Minimal Header Overhead: Only essential headers (x-subdomain, x-original-pathname) are added

Conclusion

Implementing subdomain-based multi-tenancy requires coordination across multiple layers:

  • Infrastructure: SST with wildcard domain configuration
  • CDN: CloudFront distribution accepting wildcard requests
  • Middleware: Next.js proxy handling URL rewriting and access control
  • Database: Tenant table with subscription management
  • SEO: Canonical URLs preventing duplicate content

The result is a seamless experience where premium users get their own URL space (username.storyie.com) while we maintain a single codebase that serves both subdomain and path-based routes.

Key Takeaways:

  • Wildcard domains in SST require both name and aliases configuration
  • Middleware-based routing keeps page components simple and reusable
  • Header passing (x-subdomain) enables context-aware rendering
  • Canonical URLs are essential for SEO when content is accessible via multiple URLs
  • Subscription checks should happen at the edge (middleware) for performance

Next Steps:

For questions or feedback, reach out to us on Twitter @StoryieApp.

---

Related Posts: