Overview
The Spacy frontend ecosystem is a suite of high-performance Next.js applications designed to connect users with physical spaces. Built on a unified monorepo architecture, it delivers a seamless, app-like experience across consumer, admin, and white-labeled client interfaces, all powered by a shared design system and type-safe data layer.
Web App (apps/web)
The Spacy Frontend (apps/web) is the main consumer-facing Next.js application. It provides the core experience for discovering and interacting with spaces, leveraging the shared design system for a premium feel.
Admin App (apps/admin)
We have a dedicated Admin Dashboard separate from the main user-facing app. This ensures:
- Security: Admin routes and logic are physically separated from the public app.
- Performance: The main app isn’t bloated with admin-only code.
- Consistency: Both apps consume the same
@spacy/uiand@spacy/apipackages.
Client Apps (apps/client-*)
The architecture is designed to scale for multiple clients (e.g., client-a, client-b). Each client gets a dedicated Next.js application that:
- Inherits the core Spacy features via shared packages.
- Can have custom branding, theming, and specific feature flags.
- Deploys independently, allowing for isolated updates and scaling.
Tech Stack
| Technology | Category | Description |
|---|---|---|
| Next.js | Framework | React framework for production (Pages Router). |
| TypeScript | Language | Strict type safety for components and logic. |
| Tailwind CSS | Styling | Utility-first CSS framework for rapid UI development. |
| Shadcn UI | UI Library | Reusable components built with Radix UI and Tailwind. |
| Radix UI | Primitives | Unstyled, accessible UI primitives for the design system. |
| TanStack Query | Server State | Powerful asynchronous state management for API data. |
| Zustand | Global State | State management for complex features (e.g., Map). |
| Jotai | Atomic State | Flexible state management for UI interactions (e.g., Modals). |
| tRPC | Data Fetching | End-to-end typesafe APIs powered by TanStack Query. |
UI Component Architecture
To maintain visual consistency across multiple applications (Web App, Admin Dashboard), we built a shared UI library: @spacy/ui.
Shared Design System
Located in packages/ui, this library exports reusable components built with Radix UI primitives and styled with Tailwind CSS. This ensures that buttons, dialogs, and inputs look and behave the same way everywhere.
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "./lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
// ... other variants
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };packages/ui/src/button.tsxpackages/ui/src/button.tsx
State Management
We distinguish clearly between Server State (data from the API) and Client State (UI interactions).
Server State: tRPC + TanStack Query
For all API interactions, we use tRPC powered by TanStack Query. This gives us:
- Automatic Caching: Data is cached and reused across components.
- Auto-Invalidation: Mutations automatically refresh related queries.
- Type Safety: Full autocomplete for API inputs and outputs.
1. Fetching Data (Query)
// Fetch spaces with tRPC
const { data: spaces, isLoading } = api.space.getAll.useQuery();
if (isLoading) return <Skeleton />;
return (
<div>
{spaces?.map(space => (
<SpaceCard key={space.id} space={space} />
))}
</div>
);apps/web/src/pages/explore/index.tsxapps/web/src/pages/explore/index.tsx
2. Writing Data (Mutation)
Mutations automatically invalidate queries, ensuring the UI stays fresh without manual state updates.
const utils = api.useContext();
const { mutate, isLoading } = api.space.create.useMutation({
onSuccess: () => {
// Automatically refetch the 'getAll' query
utils.space.getAll.invalidate();
},
});
const handleSubmit = data => {
mutate({ name: data.name, category: data.category });
};apps/web/src/components/create-space-form.tsxapps/web/src/components/create-space-form.tsx
Client State
We use a dual-store approach for client-side state management, choosing the right tool for the job.
Complex Global State: Zustand
For complex state objects that need actions and selectors, we use Zustand. Its centralized store model is perfect for logic-heavy state, such as managing the user’s geolocation and map zoom level.
1. Define the Store
import { create } from "zustand";
interface MapState {
currentLocation: google.maps.LatLngLiteral | undefined;
zoom: number;
actions: {
watchCurrentLocation: () => void;
updateZoom: (newZoom: number) => void;
};
}
const DEFAULT_ZOOM = 3.5;
export const mapStore = create<MapState>()(set => ({
currentLocation: undefined,
zoom: DEFAULT_ZOOM,
actions: {
watchCurrentLocation: () => {
if (navigator.geolocation) {
navigator.geolocation.watchPosition(position => {
set(() => ({
currentLocation: {
lat: position.coords.latitude,
lng: position.coords.longitude,
},
}));
});
}
},
updateZoom: (newZoom: number) => {
set(() => ({ zoom: newZoom }));
},
},
}));
export const useCurrentLocation = () =>
mapStore(state => state.currentLocation);
export const useMapStoreActions = () => mapStore(state => state.actions);apps/web/src/stores/map.tsapps/web/src/stores/map.ts
2. Use in Components
import { useEffect } from "react";
import { useMapStoreActions, useCurrentLocation } from "~/stores/map";
const ExplorePage = () => {
const { watchCurrentLocation } = useMapStoreActions();
const currentLocation = useCurrentLocation();
useEffect(() => {
// Start watching location on mount
watchCurrentLocation();
}, [watchCurrentLocation]);
if (!currentLocation) return <div>Locating...</div>;
return (
<GoogleMap
center={currentLocation}
// ...
/>
);
};apps/web/src/pages/explore/index.tsxapps/web/src/pages/explore/index.tsx
Atomic UI State: Jotai
For simple, atomic pieces of UI state we use Jotai. By exporting an atom, we can share state between components without prop drilling.
1. Define and Export the Atom
import { atom, useAtom } from "jotai";
// Export the atom so other components can use it
export const isFilterModalOpen = atom(false);
export const FilterModal = () => {
const [open, setOpen] = useAtom(isFilterModalOpen);
return (
<Dialog open={open} onClose={setOpen}>
{/* ... */}
</Dialog>
);
};apps/web/src/components/filter-modal.tsxapps/web/src/components/filter-modal.tsx
2. Use the Atom in Another Component
import { useAtom } from "jotai";
import { isFilterModalOpen } from "./filter-modal";
const FilterButton = () => {
// We only need the setter here
const [, setOpen] = useAtom(isFilterModalOpen);
return <button onClick={() => setOpen(true)}>Open Filters</button>;
};apps/web/src/components/filter-button.tsxapps/web/src/components/filter-button.tsx



