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
@applyProject 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;
Route Params and Search
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 insrc/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";
@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 singleprops 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) { ... }
Backend Facade Pattern
Always use the centralizedbackend 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);
});
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 />
</>
)}
</>
);
}
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