diff --git a/app/projects/opengraph-image.tsx b/app/projects/opengraph-image.tsx
deleted file mode 100644
index 261c0d5d..00000000
--- a/app/projects/opengraph-image.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { renderOgImage, size, contentType } from '@/lib/seo/og-template';
-
-export { size, contentType };
-
-export const alt = 'Projects — Boundless Builders';
-
-export default function Image() {
- return renderOgImage({
- title: 'Projects',
- subtitle: 'Explore the products being built across the Boundless ecosystem.',
- });
-}
diff --git a/app/projects/page.tsx b/app/projects/page.tsx
deleted file mode 100644
index 5fd224c6..00000000
--- a/app/projects/page.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { ProjectsView } from '@/components/discover/projects-view';
-import { SiteFooter } from '@/components/layout/site-footer';
-import { SiteHeader } from '@/components/layout/site-header';
-import { JsonLd } from '@/components/seo/json-ld';
-import { buildPageMetadata } from '@/lib/seo/metadata';
-import {
- breadcrumbSchema,
- collectionPageSchema,
- homeBreadcrumb,
-} from '@/lib/seo/schema';
-
-const PROJECTS_DESCRIPTION =
- 'Explore the products and projects being built on Stellar through Boundless. See what teams across the ecosystem are shipping right now.';
-
-export const metadata = buildPageMetadata({
- title: 'Projects',
- description: PROJECTS_DESCRIPTION,
- path: '/projects',
-});
-
-const projectsSchema = collectionPageSchema({
- name: 'Projects',
- description: PROJECTS_DESCRIPTION,
- path: '/projects',
-});
-const projectsBreadcrumbs = breadcrumbSchema([
- homeBreadcrumb,
- { name: 'Projects', path: '/projects' },
-]);
-
-export default function ProjectsPage() {
- return (
- <>
-
-
-
-
-
-
- >
- );
-}
diff --git a/app/projects/twitter-image.tsx b/app/projects/twitter-image.tsx
deleted file mode 100644
index a3205359..00000000
--- a/app/projects/twitter-image.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { default, alt, size, contentType } from './opengraph-image';
diff --git a/components/discover/category-tabs.tsx b/components/discover/category-tabs.tsx
deleted file mode 100644
index d4635174..00000000
--- a/components/discover/category-tabs.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-'use client';
-
-import { Button } from '@/components/ui/button';
-
-export function CategoryTabs({
- categories,
- active,
- onSelect,
-}: {
- categories: readonly string[];
- active: string;
- onSelect: (category: string) => void;
-}) {
- return (
-
- {categories.map(category => {
- const isActive = category === active;
- return (
- onSelect(category)}
- className='shrink-0'
- >
- {category}
-
- );
- })}
-
- );
-}
diff --git a/components/discover/discover-header.tsx b/components/discover/discover-header.tsx
deleted file mode 100644
index de3661aa..00000000
--- a/components/discover/discover-header.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-/** Page header for a discovery view: title with result count and subtext. */
-export function DiscoverHeader({
- heading,
- subtext,
- count,
-}: {
- heading: string;
- subtext: string;
- count?: number;
-}) {
- return (
-
-
- {heading}
- {typeof count === 'number' ? (
- ({count})
- ) : null}
-
-
{subtext}
-
- );
-}
diff --git a/components/discover/filter-rail.tsx b/components/discover/filter-rail.tsx
index 42edf93b..73130590 100644
--- a/components/discover/filter-rail.tsx
+++ b/components/discover/filter-rail.tsx
@@ -17,8 +17,8 @@ import { cn } from '@/lib/utils';
/**
* Any selection state shaped as named groups of strings (skills, country,
- * status, category, tags, ...). The generic shape is what lets the rail drive
- * both the projects and builders directories from one component.
+ * status, ...). The parent decides which groups exist, so the rail itself
+ * stays generic.
*/
export type FilterValue = Record;
@@ -198,9 +198,8 @@ function sectionRows(
/**
* Discovery filter controls, shared by the desktop sidebar and the mobile
* sheet. Controlled: the parent owns `value` so it can show a Reset affordance
- * and run the query. Sections are configured by the parent, so the projects
- * (category/tags/status/origin) and builders (skills/country/status) pages
- * render the same controls from one source. `idPrefix` keeps the two instances
+ * and run the query. Sections are configured by the parent (the builders
+ * directory passes skills/country/status). `idPrefix` keeps the two instances
* from sharing input ids.
*/
export function FilterRail({
diff --git a/components/discover/projects-grid.tsx b/components/discover/projects-grid.tsx
deleted file mode 100644
index ac6a2553..00000000
--- a/components/discover/projects-grid.tsx
+++ /dev/null
@@ -1,81 +0,0 @@
-'use client';
-
-import { OpportunityCard } from '@/components/cards/opportunity-card';
-import { OpportunityCardSkeleton } from '@/components/cards/opportunity-card-skeleton';
-import { Pagination } from '@/components/ui/pagination';
-
-import { toProjectCard } from './to-project-card';
-import type { ProjectListResponse } from './use-projects';
-
-const GRID_CLASS = 'grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3';
-
-export function ProjectsGrid({
- data,
- isPending,
- isError,
- isNarrowed,
- page,
- pageSize,
- onPageChange,
-}: {
- data?: ProjectListResponse;
- isPending: boolean;
- isError: boolean;
- /** A search or filter is applied, so an empty result is a miss, not an empty directory. */
- isNarrowed: boolean;
- page: number;
- pageSize: number;
- onPageChange: (page: number) => void;
-}) {
- if (isPending) {
- return (
-
- {Array.from({ length: pageSize }, (_, index) => (
-
- ))}
-
- );
- }
-
- if (isError || !data) {
- return (
-
- Projects could not be loaded right now.
-
- );
- }
-
- if (data.data.length === 0) {
- return (
-
- {isNarrowed
- ? 'No projects match these filters.'
- : 'No projects to show yet.'}
-
- );
- }
-
- return (
-
-
- {data.data.map((project, index) => (
-
- ))}
-
-
- {data.pagination.total > 0 && (
-
- )}
-
- );
-}
diff --git a/components/discover/projects-view.tsx b/components/discover/projects-view.tsx
deleted file mode 100644
index 3da58cdb..00000000
--- a/components/discover/projects-view.tsx
+++ /dev/null
@@ -1,214 +0,0 @@
-'use client';
-
-import { useEffect, useMemo, useState } from 'react';
-
-import {
- Activity01Icon,
- CompassIcon,
- HashtagIcon,
- Tag02Icon,
-} from '@/components/icons';
-import { Section } from '@/components/marketing/section';
-
-import { CategoryTabs } from './category-tabs';
-import { DiscoverHeader } from './discover-header';
-import { DiscoverToolbar } from './discover-toolbar';
-import {
- FilterRail,
- hasActiveFilters,
- type FilterSectionConfig,
- type FilterValue,
-} from './filter-rail';
-import { FilterSheet } from './filter-sheet';
-import { ProjectsGrid } from './projects-grid';
-import {
- useProjectFilters,
- useProjects,
- type ProjectsQueryParams,
-} from './use-projects';
-
-const PAGE_SIZE = 12;
-const ALL_CATEGORIES = 'All';
-const SEARCH_DEBOUNCE_MS = 300;
-
-const EMPTY_FILTERS: FilterValue = {
- category: [],
- tags: [],
- publicStatus: [],
- originType: [],
-};
-
-/**
- * The `/projects` directory. Owns the search, category, filter, and page state
- * and feeds it to `useProjects`, so the controls, the rail, and the grid all
- * read from one source. The projects equivalent of the boundless `discover-view`,
- * minus the pillar tabs, since this page is projects only.
- */
-export function ProjectsView() {
- const [searchInput, setSearchInput] = useState('');
- const [search, setSearch] = useState('');
- const [filters, setFilters] = useState(EMPTY_FILTERS);
- const [page, setPage] = useState(1);
- const [railOpen, setRailOpen] = useState(true);
- const [sheetOpen, setSheetOpen] = useState(false);
-
- // Keep typing off the network until the visitor pauses. Narrowing the results
- // invalidates the page number, so the debounce resets it too.
- useEffect(() => {
- const timer = setTimeout(() => {
- setSearch(searchInput);
- setPage(1);
- }, SEARCH_DEBOUNCE_MS);
- return () => clearTimeout(timer);
- }, [searchInput]);
-
- const applyFilters = (next: FilterValue) => {
- setFilters(next);
- setPage(1);
- };
-
- const params = useMemo(
- () => ({
- page,
- limit: PAGE_SIZE,
- search: search || undefined,
- // `GET /projects` takes a single value for these three, so a multi-select
- // in the rail sends its first entry. Only `tags` is repeatable.
- category: filters.category?.[0],
- publicStatus: filters.publicStatus?.[0],
- originType: filters.originType?.[0],
- tags: filters.tags?.length ? filters.tags : undefined,
- }),
- [page, search, filters]
- );
-
- const { data, isError, isPending } = useProjects(params);
- const {
- data: facets,
- isPending: facetsPending,
- isError: facetsError,
- } = useProjectFilters();
-
- const categories = useMemo(
- () => [
- ALL_CATEGORIES,
- ...(facets?.categories.map(item => item.value) ?? []),
- ],
- [facets]
- );
-
- const filterSections = useMemo(() => {
- if (!facets) return [];
- return [
- {
- group: 'publicStatus',
- selection: 'single',
- title: 'Status',
- icon: Activity01Icon,
- kind: 'enum',
- items: facets.publicStatuses,
- },
- {
- group: 'originType',
- selection: 'single',
- title: 'Origin',
- icon: CompassIcon,
- kind: 'enum',
- items: facets.originTypes,
- },
- {
- group: 'category',
- selection: 'single',
- title: 'Category',
- icon: HashtagIcon,
- kind: 'facet',
- items: facets.categories,
- },
- {
- group: 'tags',
- selection: 'multi',
- title: 'Tags',
- icon: Tag02Icon,
- kind: 'facet',
- defaultOpen: false,
- items: facets.tags,
- },
- ];
- }, [facets]);
-
- const reset = () => applyFilters(EMPTY_FILTERS);
-
- const selectCategory = (category: string) =>
- applyFilters({
- ...filters,
- category: category === ALL_CATEGORIES ? [] : [category],
- });
-
- return (
-
-
-
-
-
- setRailOpen(open => !open)}
- onOpenMobileFilters={() => setSheetOpen(true)}
- filtersActive={hasActiveFilters(filters)}
- onReset={reset}
- query={searchInput}
- onQueryChange={setSearchInput}
- placeholder='Search projects, categories, or tags'
- />
-
-
- {railOpen ? (
-
- ) : null}
-
-
-
0}
- page={page}
- pageSize={PAGE_SIZE}
- onPageChange={setPage}
- />
-
-
-
-
-
-
-
- );
-}
diff --git a/components/discover/use-projects.ts b/components/discover/use-projects.ts
deleted file mode 100644
index 30c398e6..00000000
--- a/components/discover/use-projects.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { useQuery } from '@tanstack/react-query';
-
-import { apiFetch } from '@/lib/api/client';
-import type { Schemas } from '@/lib/api/types';
-
-/** One page of `/projects`. */
-export type ProjectListResponse = Schemas['PublicProjectListDto'];
-
-/** Facets for the `/projects` filter rail. */
-export type ProjectFilters = Schemas['ProjectFiltersDto'];
-
-/** Query params `GET /projects` accepts from the directory page. */
-export interface ProjectsQueryParams {
- page?: number;
- limit?: number;
- search?: string;
- category?: string;
- /** Repeatable filter, sent as a comma-separated `tags` value. */
- tags?: string[];
- publicStatus?: string;
- originType?: string;
-}
-
-export const projectsKeys = {
- all: ['projects'] as const,
- list: (params: ProjectsQueryParams) =>
- ['projects', 'list', params] as const,
- filters: ['projects', 'filters'] as const,
-};
-
-function toQueryString(params: ProjectsQueryParams): string {
- const search = new URLSearchParams();
-
- if (params.page) search.set('page', String(params.page));
- if (params.limit) search.set('limit', String(params.limit));
- if (params.search) search.set('search', params.search);
- if (params.category) search.set('category', params.category);
- if (params.tags?.length) search.set('tags', params.tags.join(','));
- if (params.publicStatus) search.set('publicStatus', params.publicStatus);
- if (params.originType) search.set('originType', params.originType);
-
- const query = search.toString();
- return query ? `?${query}` : '';
-}
-
-/** Paginated `/projects` directory list, filtered by the params above. */
-export function useProjects(params: ProjectsQueryParams = {}) {
- return useQuery({
- queryKey: projectsKeys.list(params),
- queryFn: () =>
- apiFetch(`/projects${toQueryString(params)}`),
- });
-}
-
-/** Filter facets (categories, tags, statuses, origin types) for `/projects`. */
-export function useProjectFilters() {
- return useQuery({
- queryKey: projectsKeys.filters,
- queryFn: () => apiFetch('/projects/filters'),
- });
-}
diff --git a/components/landing/featured-projects.tsx b/components/landing/featured-projects.tsx
index 51cbc141..a2faa73b 100644
--- a/components/landing/featured-projects.tsx
+++ b/components/landing/featured-projects.tsx
@@ -10,8 +10,9 @@ import { Section, SectionHeading } from '@/components/marketing/section';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client';
import type { Schemas } from '@/lib/api/types';
+import { mainAppUrl } from '@/lib/main-app';
-import { toProjectCard } from '@/components/discover/to-project-card';
+import { toProjectCard } from './to-project-card';
const FEATURED_PROJECTS_SKELETON_COUNT = 3;
@@ -84,7 +85,7 @@ export function FeaturedProjects() {
size='large'
asChild
>
-
+
Browse all projects
diff --git a/components/discover/to-project-card.ts b/components/landing/to-project-card.ts
similarity index 100%
rename from components/discover/to-project-card.ts
rename to components/landing/to-project-card.ts
diff --git a/lib/seo/routes.ts b/lib/seo/routes.ts
index a484dd41..0d6d3dc4 100644
--- a/lib/seo/routes.ts
+++ b/lib/seo/routes.ts
@@ -31,10 +31,4 @@ export const routes: RouteEntry[] = [
changeFrequency: 'daily',
priority: 0.9,
},
- {
- slug: '/projects',
- label: 'Projects',
- changeFrequency: 'daily',
- priority: 0.9,
- },
];
diff --git a/proxy.ts b/proxy.ts
new file mode 100644
index 00000000..c46d82bb
--- /dev/null
+++ b/proxy.ts
@@ -0,0 +1,20 @@
+import { NextResponse } from 'next/server';
+
+import { mainAppUrl } from '@/lib/main-app';
+
+/**
+ * This app no longer hosts a projects directory; the main Boundless app owns
+ * it. Every `/projects` path redirects there permanently.
+ *
+ * This lives in the proxy rather than `next.config.ts` because config redirects
+ * always forward the incoming query string, and the filter parameters differ
+ * between the two apps. Here the destination is built by hand, so nothing leaks
+ * across.
+ */
+export function proxy() {
+ return NextResponse.redirect(mainAppUrl('/projects'), 308);
+}
+
+export const config = {
+ matcher: ['/projects', '/projects/:path*'],
+};
diff --git a/scripts/seo-check.mjs b/scripts/seo-check.mjs
index 0b43a4b7..289da3af 100644
--- a/scripts/seo-check.mjs
+++ b/scripts/seo-check.mjs
@@ -47,7 +47,7 @@ const START_TIMEOUT_MS = 60_000;
const FETCH_TIMEOUT_MS = 15_000;
/** HTML routes to check for all four SEO signals. */
-const HTML_ROUTES = ['/', '/about', '/builders', '/projects'];
+const HTML_ROUTES = ['/', '/about', '/builders'];
/** Plain-text / XML routes that must return HTTP 200. */
const STATUS_ROUTES = ['/robots.txt', '/sitemap.xml'];