Routing Strategy: Multi-Project Architecture
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
/ Homepage
/about About page
/privacy Privacy policy
/terms Terms of serviceMeta-Site Content (jaygriff.com itself)
/posts/[slug] Blog posts about programming, design, philosophy
/docs/[slug] Documentation about this site and all projectsThese 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
/strava Strava analyzer project landing page
/fitness Fitness blog landing page
/data-wrangler Data wrangling tools landing pageThese 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
export const metadata: PostMeta = {
title: 'Strava API Reference',
slug: 'strava-api-reference',
projectId: 'strava', // β Connects to project
type: 'doc',
// ...
};Routes Handle All Projects
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
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
- Single route handler: /docs/[slug] handles all doc pages regardless of project, no duplication
- Shared components: All projects use the same primitives, theme, and system components
- Unified search: One search function finds content across all projects
- Cross-discovery: Users naturally discover your other work
- Brand cohesion: Everything strengthens "Jay Griffin makes cool stuff"
- Fast development: No context switching between repos
- Flexible categorization: Change projectId without breaking URLs
- Multi-project content: A post can belong to multiple projects via tags
Migration Strategy
If a project grows into its own business and needs to be separated:
Extract Content by Metadata
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
const nextConfig = {
async redirects() {
return [
{
source: '/strava/:path*',
destination: 'https://strava.app/:path*',
permanent: true,
},
];
},
};
Or Use Subdomains First
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:
/strava/docs/[slug]
/strava/posts/[slug]
/fitness/docs/[slug]
/fitness/posts/[slug]Why we rejected it:
- Duplicate route handlers for each project
- Can't share content across projects easily
- More complex routing logic
- Harder to search/aggregate across projects
- Premature separation when most projects won't spin off
Content Organization
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:
{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
- Project-specific routes: /strava/app for interactive features
- Project configs: Each project can have custom theme/settings
- Access control: Some projects (finances) can be dev-only via metadata
- Project API routes: /api/strava/* for project-specific functionality
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).