This document explains how the Solana.com monorepo's 4 applications work together as a unified system.
The Solana.com website is built as a multi-app monorepo. Users always access
the site through solana.com, but requests are routed to different Next.js
applications based on the URL path.
┌─────────────────────┐
│ solana.com │
│ (web app) │
│ Port 3000 │
└──────────┬──────────┘
│
┌──────────────────────────┼──────────────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ templates app │ │ media app │ │ docs app │
│ Port 3001 │ │ Port 3002 │ │ Port 3003 │
│ │ │ │ │ │
│ /developers/ │ │ /news/* │ │ /docs/* │
│ templates/* │ │ /podcasts/* │ │ /learn/* │
│ │ │ │ │ /developers/ │
│ │ │ │ │ cookbook/* │
│ │ │ │ │ guides/* │
└──────────────────┘ └──────────────────┘ └──────────────────┘
The web app acts as the entry point and uses Next.js rewrites to proxy
requests to the appropriate app based on the URL path. This is a proxy-only
architecture—all apps are accessed through solana.com, never directly via
subdomains.
| App | Directory | Routes | Dev Port |
|---|---|---|---|
| web | apps/web |
Everything else | 3000 |
| templates | apps/templates |
/developers/templates/* |
3001 |
| media | apps/media |
/news/*, /podcasts/* |
3002 |
| docs | apps/docs |
/docs/*, /learn/*, some /developers/* |
3003 |
Identifies which app is running, enabling the shared header to decide whether
links should use client-side navigation (Next.js Link) or full page loads (<a>
tag).
| App | Value | Internal Routes (use Next.js Link) |
|---|---|---|
| web | Not set | All routes except those handled by other apps |
| docs | "docs" |
/docs/*, /learn/*, /developers, /developers/cookbook/*, /developers/guides/* |
| media | "media" |
/news/*, /podcasts/* |
| templates | "templates" |
Single-segment paths (when running standalone at root) |
This is set in each app's next.config.ts:
// apps/docs/next.config.ts
const nextConfig: NextConfig = {
env: {
NEXT_PUBLIC_APP_NAME: "docs",
},
// ...
};The web app proxies requests to other apps using Next.js rewrites configured in two files:
Defines the URLs for each app based on environment:
// Docs and Media apps: Production via @vercel/related-projects, dev on localhost
const vercelMediaUrl = withRelatedProject({
projectName: "solana-com-media",
defaultHost: "https://solana-com-media.vercel.app",
});
const developmentMediaUrl = "http://localhost:3002";
export const MEDIA_APP_URL =
process.env.NEXT_PUBLIC_MEDIA_APP_URL ||
(process.env.NODE_ENV === "production"
? vercelMediaUrl
: developmentMediaUrl);
// Templates app: Always uses Vercel URL (no localhost fallback)
const vercelTemplatesUrl = withRelatedProject({
projectName: "templates",
defaultHost: "https://solana-templates.vercel.app",
});
export const TEMPLATES_APP_URL =
process.env.TEMPLATES_APP_URL || vercelTemplatesUrl;URL Resolution Order (Docs & Media):
- Environment variable override (e.g.,
NEXT_PUBLIC_MEDIA_APP_URL) - Production: Auto-detected via
@vercel/related-projects - Development: Localhost with app-specific port
URL Resolution Order (Templates):
- Environment variable override (
TEMPLATES_APP_URL) - Always uses Vercel deployment URL (no localhost fallback)
Note: The templates app doesn't have a localhost fallback because it's typically accessed directly during development rather than through the web app's proxy.
Configures which routes are proxied to which app:
import { MEDIA_APP_URL, DOCS_APP_URL } from "./apps-urls";
export default {
rewrites: {
beforeFiles: [
// Docs app routes
{ source: "/docs", destination: `${DOCS_APP_URL}/docs` },
{ source: "/docs/:path*", destination: `${DOCS_APP_URL}/docs/:path*` },
{ source: "/learn", destination: `${DOCS_APP_URL}/learn` },
{ source: "/learn/:path*", destination: `${DOCS_APP_URL}/learn/:path*` },
// ... more routes
// Media app routes
{ source: "/news", destination: `${MEDIA_APP_URL}/news` },
{ source: "/news/:path*", destination: `${MEDIA_APP_URL}/news/:path*` },
// ... more routes
],
},
};User visits: solana.com/docs/intro
1. Request hits web app (solana.com)
2. Next.js matches rewrite rule: /docs/:path* → DOCS_APP_URL/docs/:path*
3. Request proxied to docs app
4. Docs app renders page, returns HTML
5. HTML references assets at /docs-assets/_next/...
6. Browser requests /docs-assets/_next/css/...
7. Web app rewrites to DOCS_APP_URL/docs-assets/_next/css/...
8. CSS served from docs app
When apps are accessed through the web app's proxy, their static assets (CSS,
JS) need special handling. Without this, asset requests like /_next/static/...
would go to the web app instead of the originating app.
Without asset prefix:
1. User visits solana.com/docs (proxied to docs app)
2. HTML includes: <link href="/_next/static/css/main.css">
3. Browser requests: solana.com/_next/static/css/main.css
4. Web app serves ITS css (wrong!) or 404
Each proxied app uses an assetPrefix to namespace its assets:
Docs app (apps/docs/next.config.ts):
const nextConfig: NextConfig = {
assetPrefix: "/docs-assets",
// ...
};Media app (apps/media/next.config.ts):
const nextConfig: NextConfig = {
assetPrefix: "/media-assets",
// ...
};Templates app (apps/templates/next.config.ts):
const nextConfig: NextConfig = {
assetPrefix: "/templates-assets",
// ...
};The templates app has its pages in src/app/developers/templates/ to match the
URL structure, consistent with how docs has [locale]/docs/ and media has
[locale]/news/.
Each app also needs internal rewrites to handle its prefixed assets:
// apps/docs/next.config.ts
async rewrites() {
return {
beforeFiles: [
{
source: "/docs-assets/_next/:path+",
destination: "/_next/:path+",
},
],
};
}And the web app needs rewrites to proxy these asset requests:
// apps/web/rewrites-redirects.mjs
{
source: "/docs-assets/:path+",
destination: `${DOCS_APP_URL}/docs-assets/:path+`,
},
{
source: "/media-assets/:path+",
destination: `${MEDIA_APP_URL}/media-assets/:path+`,
},
{
source: "/templates-assets/:path+",
destination: `${TEMPLATES_APP_URL}/templates-assets/:path+`,
},The shared header (@solana-com/ui-chrome) needs to know whether to use Next.js
<Link> (for client-side navigation) or <a> tags (for full page loads to
trigger the proxy).
Since all apps are served behind solana.com via rewrites (proxy-only mode),
all links stay as relative paths. The only decision is whether to use
client-side navigation or trigger a full page load.
The logic is in packages/ui-chrome/src/url-config.ts:
const APP_INTERNAL_ROUTES: Record<string, RegExp> = {
docs: /^\/(?:docs|learn)(?:\/|$)|^\/developers(?:$|\/(?:cookbook|guides)(?:\/|$))/,
media: /^\/(?:news|podcasts)(?:\/|$)/,
templates: /^\/[^/]+$/,
};
export function shouldUseNextLink(href: string): boolean {
// On web app: use Link for routes NOT handled by other apps
// On other apps: use Link only for routes internal to that app
}| App | Click on /docs/intro |
Click on /validators |
|---|---|---|
| web | <a> tag (full page load → proxy) |
Next.js Link (client navigation) |
| docs | Next.js Link (client navigation) | <a> tag (full page load → proxy) |
| media | <a> tag (full page load → proxy) |
<a> tag (full page load → proxy) |
Why this matters:
- Next.js Link: Fast client-side navigation, works within the same app
<a>tag: Full page load, triggers the web app's rewrites to proxy to the correct app
From the monorepo root, run all apps in parallel:
pnpm devThis uses Turbo to start all apps simultaneously:
- Web app on http://localhost:3000
- Templates app on http://localhost:3001
- Media app on http://localhost:3002
- Docs app on http://localhost:3003
Access via the web app to test rewrites work:
- http://localhost:3000/docs → proxied to docs app
- http://localhost:3000/news → proxied to media app
- http://localhost:3000/developers/templates → proxied to templates app
Or access apps directly:
- http://localhost:3001/developers/templates → templates app directly
- http://localhost:3002/news → media app directly
- http://localhost:3003/docs → docs app directly
No .env files needed for local development! Just run pnpm dev and everything
works.
All apps use next-intl via the shared @workspace/i18n package. Translation
files are stored as common.json in each app's public/locales/{locale}/
directory.
| App | Sources |
|---|---|
| web | Own public/locales/{locale}/common.json |
| docs | Web app's locales + own locales (merged) |
| media | Own public/locales/{locale}/common.json |
| templates | Web app's locales + own locales (merged) |
The docs and templates apps import translations from both the web app and their own locales, merging them (app-specific translations take precedence):
// apps/docs/src/i18n/request.ts (templates uses the same pattern)
const [webMessages, docsMessages] = await Promise.all([
loadMessages(
(loc) => import(`../../../../apps/web/public/locales/${loc}/common.json`),
locale,
),
loadMessages((loc) => import(`@@/public/locales/${loc}/common.json`), locale),
]);
// Merge translations, with app-specific taking precedence
const messages = { ...webMessages, ...docsMessages };This allows these apps to use all the shared header/footer translations from the web app while adding their own app-specific translations.
The media app only loads its own translations since it has a complete set of translations for its needs.
The primary source of translations is apps/web/public/locales/:
apps/web/public/locales/
├── en/common.json # English (default)
├── es/common.json # Spanish
├── zh/common.json # Chinese
├── ... (20+ locales)
The web app generates the sitemap for the entire site using next-sitemap. It
aggregates URLs from all apps.
See apps/web/next-sitemap.config.js:
module.exports = {
siteUrl: "https://solana.com/",
additionalPaths: async () => {
// Fetch URLs from various sources
const builderUrls = await getAllUrls(); // Builder.io pages
const mediaPostUrls = getMediaPostUrls(); // /news/* posts
const mediaPodcastUrls = getMediaPodcastUrls(); // /podcasts/*
const templateUrls = [...]; // /templates/*
return [...builderUrls, ...mediaPostUrls, ...mediaPodcastUrls, ...templateUrls];
},
};| Source | Description |
|---|---|
| Builder.io | Landing pages, solution pages |
| Media posts | News articles from media app |
| Podcasts | Podcast episodes from media app |
| Templates | Templates fetched from GitHub |
| Static pages | Auto-discovered by next-sitemap |
The sitemap is generated during the web app build and outputs to
apps/web/public/sitemap.xml.
Checklist for adding a 5th app to the system:
# Create new Next.js app in apps/
mkdir apps/newapp
cd apps/newapp
# Set up Next.js with workspace dependenciesconst nextConfig: NextConfig = {
assetPrefix: "/newapp-assets", // Unique prefix
env: {
NEXT_PUBLIC_APP_NAME: "newapp", // Unique identifier
},
async rewrites() {
return {
beforeFiles: [
{
source: "/newapp-assets/_next/:path+",
destination: "/_next/:path+",
},
],
};
},
};In apps/web/apps-urls.js:
const vercelNewappUrl = withRelatedProject({
projectName: "solana-com-newapp",
defaultHost: "https://solana-com-newapp.vercel.app",
});
const developmentNewappUrl = "http://localhost:3004";
export const NEWAPP_APP_URL =
process.env.NEXT_PUBLIC_NEWAPP_APP_URL ||
(process.env.NODE_ENV === "production"
? vercelNewappUrl
: developmentNewappUrl);In apps/web/rewrites-redirects.mjs:
import { NEWAPP_APP_URL } from "./apps-urls";
// Add to beforeFiles array:
{
source: "/newapp-assets/:path+",
destination: `${NEWAPP_APP_URL}/newapp-assets/:path+`,
locale: false,
},
{
source: "/newroute",
destination: `${NEWAPP_APP_URL}/newroute`,
locale: false,
},
{
source: "/newroute/:path*",
destination: `${NEWAPP_APP_URL}/newroute/:path*`,
locale: false,
},In packages/ui-chrome/src/url-config.ts:
const APP_INTERNAL_ROUTES: Record<string, RegExp> = {
// ... existing apps
newapp: /^\/newroute(?:\/|$)/,
};Add any new environment variables to globalEnv:
{
"globalEnv": [
"NEWAPP_APP_URL"
// ... existing vars
]
}Tip: Structure your app's pages directory to match the URL path (e.g.,
src/app/newroute/for routes at/newroute/*).
- Create new Vercel project for the app
- Configure build settings for monorepo
- Link as related project in the web app's Vercel settings
| File | Purpose |
|---|---|
apps/web/apps-urls.js |
App URL configuration |
apps/web/rewrites-redirects.mjs |
Rewrite rules |
apps/web/next-sitemap.config.js |
Sitemap generation |
packages/ui-chrome/src/url-config.ts |
Header link routing logic |
packages/ui-chrome/src/link.tsx |
Shared Link component |
packages/i18n/ |
Shared i18n utilities |
apps/web/public/locales/ |
Primary translation files |
turbo.json |
Environment variable passthrough |