Routing Strategy: Multi-Project Architecture

By Claude Sonnet 4.5Β Β Β·Β  January 19, 2026
docsProject: jaygriff
🏷️ Tags:architecturenextjsroutingmulti-project

How we structure URLs and content for multiple projects under one domain using metadata-based routing.

The Challenge

This site hosts multiple projects: the jaygriff.com site itself, a Strava analyzer, a fitness blog, data wrangling tools, and potentially more. Each project needs its own space, but we want to maximize development velocity and brand cohesion.

The Decision: Metadata Routing

Instead of creating physical route hierarchies like /strava/docs/[slug], we use metadata-based routing where all content lives in shared namespaces and projects are identified by metadata.

URL Structure

Standard Site Pages

.txt
/                   Homepage
/about              About page
/privacy            Privacy policy
/terms              Terms of service

Meta-Site Content (jaygriff.com itself)

.txt
/posts/[slug]       Blog posts about programming, design, philosophy
/docs/[slug]        Documentation about this site and all projects

These routes handle content for jaygriff.com AND all projects. The physical route is the same, but metadata determines which project the content belongs to.

Project Aggregator Pages

.txt
/strava             Strava analyzer project landing page
/fitness            Fitness blog landing page
/data-wrangler      Data wrangling tools landing page

These pages query and display all content where metadata.projectId matches the project name. They're landing pages and content feeds, not separate route hierarchies.

How It Works

Content Files Use Metadata

strava-api-reference.tsx
export const metadata: PostMeta = {
  title: 'Strava API Reference',
  slug: 'strava-api-reference',
  projectId: 'strava',  // ← Connects to project
  type: 'doc',
  // ...
};

Routes Handle All Projects

src/app/docs/[slug]/page.tsx
export default async function Page({ params }) {
  const { slug } = await params;
  const content = await loadContentBySlug(slug, 'doc');
  
  // Works for ANY projectId - jaygriff, strava, fitness, etc.
  return <Container>
    <ContentHeader metadata={content.metadata} />
    <ContentWrapper>
      <content.Component />
    </ContentWrapper>
  </Container>;
}

Project Pages Query by Metadata

src/app/strava/page.tsx
export default async function StravaPage() {
  const allContent = await getAllContent();
  const stravaContent = allContent.filter(
    c => c.metadata.projectId === 'strava'
  );
  
  return <div>
    <h1>Strava Analyzer Project</h1>
    <ContentFeed items={stravaContent} />
  </div>;
}

Benefits

Migration Strategy

If a project grows into its own business and needs to be separated:

Extract Content by Metadata

scripts/migrate-project.ts
import { getAllContent } from '@/lib/posts';
import fs from 'fs';

async function migrateProject(projectId: string, targetDir: string) {
  const allContent = await getAllContent();
  const projectContent = allContent.filter(
    c => c.metadata.projectId === projectId
  );
  
  projectContent.forEach(item => {
    fs.copyFileSync(
      `content/tsx/${item.filename}`,
      `${targetDir}/content/tsx/${item.filename}`
    );
  });
  
  console.log(`Migrated ${projectContent.length} files to ${targetDir}`);
}

migrateProject('strava', '../strava-app');

Set Up Redirects

next.config.ts
const nextConfig = {
  async redirects() {
    return [
      {
        source: '/strava/:path*',
        destination: 'https://strava.app/:path*',
        permanent: true,
      },
    ];
  },
};

Or Use Subdomains First

.txt
strava.jaygriff.com β†’ separate Next.js app
                      ↓
                 strava.app (if it grows)

The metadata system makes extraction a 5-minute operation. Optimize for velocity now, separate later if actually needed (which is rare).

Alternative Considered: Physical Routing

We considered creating separate route hierarchies:

.txt
/strava/docs/[slug]
/strava/posts/[slug]
/fitness/docs/[slug]
/fitness/posts/[slug]

Why we rejected it:

Content Organization

.txt
content/
β”œβ”€β”€ tsx/
β”‚   β”œβ”€β”€ site-summary.tsx           (projectId: 'jaygriff')
β”‚   β”œβ”€β”€ routing-strategy.tsx       (projectId: 'jaygriff')
β”‚   β”œβ”€β”€ strava-api-reference.tsx   (projectId: 'strava')
β”‚   β”œβ”€β”€ marathon-training.tsx      (projectId: 'fitness')
β”‚   └── data-wrangling-intro.tsx   (projectId: 'data-wrangler')
└── md/
    └── (same structure, same metadata system)

All content mixed together, organized by metadata. Physical file organization doesn't matter - it's all queryable by projectId.

UI Integration

Project IDs become clickable badges on content pages:

ContentHeader.tsx
{metadata.projectId && (
  <Link href={`/${metadata.projectId}`}>
    <Badge>{metadata.projectId}</Badge>
  </Link>
)}

Clicking the badge takes you to the project landing page showing all related content.

Future Additions

Philosophy

Start together, separate if needed.

Most side projects stay side projects. Premature separation kills momentum. Build everything under your brand, leverage shared infrastructure, ship fast. If something actually grows into a business, migration is straightforward.

Your brand compounds. Individual projects are lottery tickets. Optimize for the common case (rapid development under one brand) not the rare case (spinning off into separate business).