Skip to main content

Frontend Development

The Aya frontend is built with Deno, TanStack Start, and React with server-side rendering and optimistic caching.

Tech Stack

Runtime

Deno - Secure TypeScript runtime

Framework

TanStack Start - Full-stack React with SSR

Styling

CSS Modules + Tailwind @apply

Project Structure

apps/webclient/src/
├── routes/              # File-based routing
│   ├── __root.tsx       # Root layout
│   └── $locale/         # Locale-prefixed routes
├── components/
│   ├── ui/              # shadcn/ui components
│   ├── forms/           # Form components
│   └── page-layouts/    # Layout wrappers
├── modules/
│   ├── backend/         # API client
│   ├── i18n/            # Internationalization
│   └── auth/            # Authentication
├── lib/                # Utilities
├── messages/           # i18n JSON files
├── config.ts           # Site configuration
└── router.tsx          # Router setup

File-Based Routing

TanStack Router uses file-system-based routing:
routes/
├── __root.tsx                    → Layout wrapper
├── index.tsx                     → / (redirects to /en)
└── $locale/
    ├── index.tsx                 → /{locale}/
    ├── articles.tsx              → /{locale}/articles
    └── $slug/
        ├── index.tsx             → /{locale}/{slug}
        ├── qa.tsx                → /{locale}/{slug}/qa
        └── settings.tsx          → /{locale}/{slug}/settings

Creating a Route

src/routes/$locale/articles.tsx
import { createFileRoute } from "@tanstack/react-router";
import { useSuspenseQuery } from "@tanstack/react-query";
import { storiesByKindsQueryOptions } from "@/modules/backend/queries";
import { QueryError } from "@/components/query-error";

export const Route = createFileRoute("/$locale/articles")({
  // 1. Loader: Runs on server (SSR) and client (navigation)
  loader: async ({ params, context }) => {
    const { locale } = params;
    await context.queryClient.ensureQueryData(
      storiesByKindsQueryOptions(locale, ["article"])
    );
    return { locale };
  },

  // 2. Meta tags (uses loader data)
  head: ({ loaderData }) => ({
    title: "Articles - Aya Community",
    meta: [
      { name: "description", content: "Browse technical articles" },
    ],
  }),

  // 3. Error boundary
  errorComponent: QueryError,
});

function Articles() {
  const { locale } = Route.useLoaderData();

  // 4. Read from hydrated cache (no loading state!)
  const { data: articles } = useSuspenseQuery(
    storiesByKindsQueryOptions(locale, ["article"])
  );

  return (
    <div>
      <h1>Articles</h1>
      {articles.map((article) => (
        <ArticleCard key={article.id} article={article} />
      ))}
    </div>
  );
}

export default Articles;
src/routes/$locale/$slug/stories/$storySlug/index.tsx
import { createFileRoute } from "@tanstack/react-router";

export const Route = createFileRoute("/$locale/$slug/stories/$storySlug/")({
  // Access params
  loader: ({ params }) => {
    const { locale, slug, storySlug } = params;
    // locale: "en", slug: "eser", storySlug: "my-article"
  },

  // Typed search params
  validateSearch: (search) => ({
    page: Number(search?.page) || 1,
    sort: (search?.sort as string) || "recent",
  }),
});

function Story() {
  const { locale, slug, storySlug } = Route.useParams();
  const { page, sort } = Route.useSearch();

  // URL: /en/eser/stories/my-article?page=2&sort=popular
  // locale: "en", slug: "eser", storySlug: "my-article"
  // page: 2, sort: "popular"
}

React Query (TanStack Query)

Query Option Factories

Centralize query configurations in src/modules/backend/queries.ts:
src/modules/backend/queries.ts
import { queryOptions } from "@tanstack/react-query";
import { backend } from "./backend";

export const profileQueryOptions = (locale: string, slug: string) =>
  queryOptions({
    queryKey: ["profile", locale, slug],
    queryFn: () => backend.getProfile(locale, slug),
  });

export const profileStoriesQueryOptions = (locale: string, slug: string) =>
  queryOptions({
    queryKey: ["profile-stories", locale, slug],
    queryFn: () => backend.getProfileStories(locale, slug),
  });

Prefetching in Loaders

// Pattern A: Prefetch only (optional data)
loader: async ({ params, context }) => {
  await context.queryClient.prefetchQuery(
    profileStoriesQueryOptions(params.locale, params.slug)
  );
},

// Pattern B: Ensure data (needed for head())
loader: async ({ params, context }) => {
  const profile = await context.queryClient.ensureQueryData(
    profileQueryOptions(params.locale, params.slug)
  );
  return { profile }; // Available in head() via loaderData
},

Using Queries in Components

import { useSuspenseQuery, useQuery } from "@tanstack/react-query";

// Required data (prefetched in loader)
function ProfilePage() {
  const { data: profile } = useSuspenseQuery(
    profileQueryOptions(locale, slug)
  );
  // No loading state - data always available
}

// Optional data (may be loading)
function OptionalWidget() {
  const { data, isLoading } = useQuery(
    optionalDataQueryOptions()
  );

  if (isLoading) return <Spinner />;
  return <div>{data?.content}</div>;
}

Mutations

import { useMutation, useQueryClient } from "@tanstack/react-query";
import { backend } from "@/modules/backend/backend";

function FollowButton(props: { profileSlug: string }) {
  const queryClient = useQueryClient();

  const followMutation = useMutation({
    mutationFn: () => backend.followProfile(locale, props.profileSlug),

    // Optimistic update
    onMutate: async () => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({
        queryKey: ["profile", locale, props.profileSlug],
      });

      // Snapshot current value
      const previous = queryClient.getQueryData(
        profileQueryOptions(locale, props.profileSlug).queryKey
      );

      // Optimistically update
      queryClient.setQueryData(
        profileQueryOptions(locale, props.profileSlug).queryKey,
        (old) => ({ ...old, isFollowing: true })
      );

      return { previous };
    },

    // Revert on error
    onError: (err, variables, context) => {
      queryClient.setQueryData(
        profileQueryOptions(locale, props.profileSlug).queryKey,
        context?.previous
      );
    },

    // Always refetch after
    onSettled: () => {
      queryClient.invalidateQueries({
        queryKey: ["profile", locale, props.profileSlug],
      });
    },
  });

  return (
    <button
      onClick={() => followMutation.mutate()}
      disabled={followMutation.isPending}
    >
      {followMutation.isPending ? "Following..." : "Follow"}
    </button>
  );
}

Styling with CSS Modules

Setup Requirements

CRITICAL: Every .module.css file MUST start with:
@reference "@/theme.css";
Without this, @apply directives will crash the production build.

CSS Module Example

src/components/profile-card.module.css
@reference "@/theme.css";

.card {
  @apply border rounded-lg p-4 hover:shadow-lg transition-shadow;
}

.title {
  @apply text-xl font-bold mb-2;
}

.description {
  @apply text-gray-600 dark:text-gray-300;
}
src/components/profile-card.tsx
import styles from "./profile-card.module.css";

type Props = {
  profile: Profile;
};

export function ProfileCard(props: Props) {
  return (
    <div className={styles.card}>
      <h3 className={styles.title}>{props.profile.title}</h3>
      <p className={styles.description}>{props.profile.description}</p>
    </div>
  );
}
No Inline Tailwind: Use CSS Modules with @apply. Only use inline Tailwind in shadcn/ui components.

Component Patterns

Single Props Object

Always use a single props object, never destructure:
// CORRECT
type CardProps = {
  title: string;
  description: string;
};

function Card(props: CardProps) {
  return (
    <div>
      <h2>{props.title}</h2>
      <p>{props.description}</p>
    </div>
  );
}

// WRONG - Don't destructure
function Card({ title, description }: CardProps) { ... }
Exception: shadcn/ui components (generated code) can use destructuring.

Backend Facade Pattern

Always use the centralized backend object:
// CORRECT
import { backend } from "@/modules/backend/backend.ts";
const profile = await backend.getProfile("en", "eser");

// WRONG - Direct imports
import { getProfile } from "@/modules/backend/profiles/get-profile.ts";

Explicit Checks

Never use truthy/falsy checks except for booleans:
// CORRECT
if (value === null) { ... }
if (value === undefined) { ... }
if (array.length === 0) { ... }

// WRONG
if (!value) { ... }
if (!array.length) { ... }

Forms

Form Example with Validation

src/components/forms/create-profile-form.tsx
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { z } from "zod";
import { backend } from "@/modules/backend/backend";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";

const profileSchema = z.object({
  slug: z.string().min(2).max(39).regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/),
  title: z.string().min(2).max(100),
  description: z.string().min(10).max(500),
  kind: z.enum(["individual", "organization", "product"]),
});

type ProfileFormData = z.infer<typeof profileSchema>;

export function CreateProfileForm(props: { locale: string }) {
  const [formData, setFormData] = useState<ProfileFormData>({
    slug: "",
    title: "",
    description: "",
    kind: "individual",
  });
  const [errors, setErrors] = useState<Record<string, string>>({});

  const createMutation = useMutation({
    mutationFn: () => backend.createProfile(props.locale, formData),
    onSuccess: (profile) => {
      window.location.href = `/${props.locale}/${profile.slug}`;
    },
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    // Validate
    const result = profileSchema.safeParse(formData);
    if (!result.success) {
      const fieldErrors: Record<string, string> = {};
      result.error.errors.forEach((err) => {
        if (err.path[0] !== undefined) {
          fieldErrors[String(err.path[0])] = err.message;
        }
      });
      setErrors(fieldErrors);
      return;
    }

    setErrors({});
    createMutation.mutate();
  };

  return (
    <form onSubmit={handleSubmit}>
      <Input
        value={formData.slug}
        onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
        placeholder="profile-slug"
        error={errors.slug}
      />

      <Input
        value={formData.title}
        onChange={(e) => setFormData({ ...formData, title: e.target.value })}
        placeholder="Profile Title"
        error={errors.title}
      />

      <Textarea
        value={formData.description}
        onChange={(e) => setFormData({ ...formData, description: e.target.value })}
        placeholder="Description"
        error={errors.description}
      />

      <Button type="submit" disabled={createMutation.isPending}>
        {createMutation.isPending ? "Creating..." : "Create Profile"}
      </Button>
    </form>
  );
}

Internationalization

Using Translations

import { useTranslation } from "@/modules/i18n/context";

function Component() {
  const { t } = useTranslation();

  return (
    <div>
      <h1>{t("Profile.Title", "Profile")}</h1>
      <button>{t("Profile.Follow", "Follow")}</button>
      <p>{t("Profile.MemberSince", "Member since {{date}}", {
        date: formatDate(profile.createdAt, locale),
      })}</p>
    </div>
  );
}

Locale Switching

import { useNavigate } from "@tanstack/react-router";

function LocaleSwitcher(props: { currentLocale: string }) {
  const navigate = useNavigate();

  const handleChange = (newLocale: string) => {
    const newPath = window.location.pathname.replace(
      `/${props.currentLocale}/`,
      `/${newLocale}/`
    );
    navigate({ to: newPath });
  };

  return (
    <select value={props.currentLocale} onChange={(e) => handleChange(e.target.value)}>
      <option value="en">English</option>
      <option value="tr">Türkçe</option>
      {/* ... */}
    </select>
  );
}

Testing

Component Testing

src/components/profile-card.test.tsx
import { render, screen } from "@testing-library/react";
import { ProfileCard } from "./profile-card.tsx";

Deno.test("ProfileCard renders profile info", () => {
  const profile = {
    id: "1",
    slug: "eser",
    title: "Eser Ozvataf",
    description: "Software engineer",
  };

  render(<ProfileCard profile={profile} />);

  expect(screen.getByText("Eser Ozvataf")).toBeInTheDocument();
  expect(screen.getByText("Software engineer")).toBeInTheDocument();
});

Snapshot Testing

src/lib/locale-utils.test.ts
import { assertEquals } from "@std/assert";
import { isValidLocale, SUPPORTED_LOCALES } from "./locale-utils.ts";

Deno.test("isValidLocale validates supported locales", () => {
  assertEquals(isValidLocale("en"), true);
  assertEquals(isValidLocale("tr"), true);
  assertEquals(isValidLocale("invalid"), false);
});

Deno.test("SUPPORTED_LOCALES has 13 locales", () => {
  assertEquals(SUPPORTED_LOCALES.length, 13);
});
Run tests:
deno task test          # Run with snapshot updates
deno task test:update   # Regenerate snapshots
deno task test:ci       # CI mode (read-only)

Performance Optimization

Code Splitting

TanStack Router automatically code-splits routes:
// Each route file becomes a separate chunk
// src/routes/$locale/articles.tsx → articles-[hash].js
// src/routes/$locale/$slug/index.tsx → $slug-[hash].js

Lazy Components

import { lazy } from "react";

const HeavyChart = lazy(() => import("./heavy-chart.tsx"));

function Dashboard() {
  return (
    <Suspense fallback={<Spinner />}>
      <HeavyChart data={data} />
    </Suspense>
  );
}

Image Optimization

<img
  src={profile.profilePictureUri}
  alt={profile.title}
  loading="lazy"  // Lazy load images
  width={200}
  height={200}
/>

Debugging

React DevTools

src/routes/__root.tsx
import { TanStackDevtools } from "@tanstack/react-devtools";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";

function RootComponent() {
  return (
    <>
      <Outlet />
      {import.meta.env.DEV && (
        <>
          <TanStackDevtools />
          <TanStackRouterDevtoolsPanel />
        </>
      )}
    </>
  );
}
Press Cmd/Ctrl + Shift + D to open devtools panel.

Next Steps

Backend Development

Implement API endpoints and business logic

Database Guide

Work with PostgreSQL and sqlc

Architecture

Understand the system design

Deployment

Deploy Aya to production

Build docs developers (and LLMs) love