SPACY project
SPACY Frontend
Work

SPACY Frontend

Mar 2023
Table of Contents

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:

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:

Tech Stack

TechnologyCategoryDescription
Next.jsFrameworkReact framework for production (Pages Router).
TypeScriptLanguageStrict type safety for components and logic.
Tailwind CSSStylingUtility-first CSS framework for rapid UI development.
Shadcn UIUI LibraryReusable components built with Radix UI and Tailwind.
Radix UIPrimitivesUnstyled, accessible UI primitives for the design system.
TanStack QueryServer StatePowerful asynchronous state management for API data.
ZustandGlobal StateState management for complex features (e.g., Map).
JotaiAtomic StateFlexible state management for UI interactions (e.g., Modals).
tRPCData FetchingEnd-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.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:

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.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.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.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.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.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.tsx

Related Projects

    Mike 3.0

    Send a message to start the chat!

    You can ask the bot anything about me and it will help to find the relevant information!

    Try asking: