Back to journal
·12 min readVueTypeScriptFrontend ArchitectureTanStack QueryZod

My 2026 Frontend Stack

A practical look at how I structure modern Vue apps in 2026, using TypeScript, TanStack Query, Zod, Tailwind, and clear boundaries between API logic, server state, validation, UI, and business rules.

ShareCopy failed

Frontend architecture gets weird when the app starts growing.

At the beginning, everything feels simple. You have a few pages, some API calls, a couple of forms, and a design system that still fits in your head. Then the product changes. More filters. More roles. More edge cases. More screens that reuse almost the same logic, but not quite.

That’s usually when the codebase starts to show its real shape.

For me, a good frontend architecture in 2026 isn’t about picking the newest stack. It’s about making the boring parts predictable. Where does API logic live? Who owns server state? Where do validation rules sit? How do we stop Vue components from becoming half UI and half business logic?

The stack I like for this kind of work is simple:

  • Vue 3 for the UI
  • TypeScript for safer contracts and clearer intent
  • TanStack Query for server state
  • Zod for runtime validation at the edges
  • Tailwind for fast, consistent UI work
  • Clean boundaries between API, domain logic, UI, and app wiring

None of these tools fixes bad architecture by itself. You can still make a mess with all of them. I’ve done it. The point is to give each tool a clear job, then avoid letting it leak into places where it doesn’t belong.

The main rule

Every layer should have a boring job.

That sounds obvious, but it’s where many frontend apps go wrong. A Vue component fetches data, maps the API response, handles permissions, formats dates, manages form state, and renders the UI. It works. Until it doesn’t.

I prefer this split:

src/
  app/
  features/
  shared/
    api/
    ui/
    lib/
    config/

The exact folder names don’t matter that much. The boundary does.

The app folder handles routing, providers, plugins, and app-level setup. The features folder holds product-specific logic. The shared folder contains reusable pieces that don’t know about a specific feature.

For example, a projects feature might look like this:

features/projects/
  api/
    projects.api.ts
    projects.schemas.ts
  composables/
    use-projects.ts
    use-project-details.ts
  components/
    ProjectCard.vue
    ProjectFilters.vue
  pages/
    ProjectsPage.vue
  types.ts

This keeps the feature close to itself. You don’t need to jump across ten folders to understand one screen. At the same time, the UI doesn’t need to know how the API response is shaped or which query key is used.

That’s the balance I want.

TypeScript is not enough on its own

TypeScript helps a lot, but it only checks what we tell it to check.

If the backend returns a field as null instead of a string, TypeScript won’t save us at runtime. If a date comes back in a strange format, the compiler won’t care. If the API contract changed and nobody updated the frontend types, the app can still break in production.

So I use TypeScript for internal confidence, but I don’t treat it as a runtime safety net.

A common mistake is this:

const project = await response.json() as Project;

That line doesn’t validate anything. It just tells TypeScript to trust us.

I’d rather parse the response:

import { z } from "zod";

export const projectSchema = z.object({
  id: z.string(),
  title: z.string(),
  status: z.enum(["draft", "active", "archived"]),
  createdAt: z.string(),
});

export type Project = z.infer<typeof projectSchema>;

Then use it at the API boundary:

import { projectSchema, type Project } from "./projects.schemas";
import { apiClient } from "@/shared/api/api-client";

export async function getProject(projectId: string): Promise<Project> {
  const response = await apiClient.get(`/projects/${projectId}`);

  return projectSchema.parse(response.data);
}

Now the contract sits where it should sit. Near the API call.

Inside the app, we work with a trusted Project type. If the backend sends bad data, the error happens early, not three components later when someone tries to render project.title.toUpperCase().

Is this perfect? No. Zod parsing adds a bit of code. It can also feel repetitive if the app has many endpoints. But I’d rather pay that small cost at the boundary than debug random UI bugs caused by silent data issues.

TanStack Query should own server state

Server state is not client state.

This is one of the biggest mental shifts that makes frontend apps cleaner. Data from the backend has its own lifecycle. It can be loading, stale, refetching, cached, invalidated, or failed. Trying to manage all of that manually in local state gets painful fast.

TanStack Query gives server state a proper home.

In Vue, I usually wrap queries inside composables:

import { computed, type MaybeRefOrGetter, toValue } from "vue";
import { useQuery } from "@tanstack/vue-query";
import { getProject } from "../api/projects.api";

export function useProject(projectId: MaybeRefOrGetter<string | undefined>) {
  return useQuery({
    queryKey: computed(() => ["projects", toValue(projectId)]),
    queryFn: () => getProject(toValue(projectId) as string),
    enabled: computed(() => Boolean(toValue(projectId))),
  });
}

The component stays clean:

<script setup lang="ts">
import { computed } from "vue";
import { useProject } from "../composables/use-project";
import ProjectDetailsView from "./ProjectDetailsView.vue";
import ProjectDetailsSkeleton from "./ProjectDetailsSkeleton.vue";
import ProjectDetailsError from "./ProjectDetailsError.vue";

const props = defineProps<{
  projectId: string;
}>();

const projectId = computed(() => props.projectId);
const projectQuery = useProject(projectId);
</script>

<template>
  <ProjectDetailsSkeleton v-if="projectQuery.isLoading.value" />

  <ProjectDetailsError v-else-if="projectQuery.isError.value" />

  <ProjectDetailsView
    v-else-if="projectQuery.data.value"
    :project="projectQuery.data.value"
  />
</template>

This is not fancy code. That’s why I like it.

The component asks for data. The composable knows how to fetch it. The API function knows the endpoint. The schema validates the response. Each piece has a job.

Mutations follow the same idea:

import { useMutation, useQueryClient } from "@tanstack/vue-query";
import { updateProject } from "../api/projects.api";

export function useUpdateProject() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: updateProject,
    onSuccess: (_, variables) => {
      queryClient.invalidateQueries({
        queryKey: ["projects", variables.projectId],
      });

      queryClient.invalidateQueries({
        queryKey: ["projects", "list"],
      });
    },
  });
}

I try not to overuse optimistic updates unless the UX really needs them. They’re great for small actions like toggles, likes, and quick edits. For more complex data, they can create weird bugs if the backend applies extra rules.

Sometimes the safer UX is a small loading state and a clean refetch.

Zod belongs at the edges

Zod is most useful where data enters the app.

That usually means API responses, form submissions, route params, query params, local storage, and feature flags. These are places where data can be wrong, missing, stale, or manually changed.

I don’t like spreading Zod schemas deep into every component. That makes the UI noisy. It also turns validation into a thing everyone touches everywhere.

For forms, though, Zod fits nicely:

export const projectFormSchema = z.object({
  title: z.string().min(3, "Give the project a clearer title."),
  description: z.string().optional(),
  status: z.enum(["draft", "active"]),
});

export type ProjectFormValues = z.infer<typeof projectFormSchema>;

This gives us one source for validation and form values. It also means we can reuse the same rules when we prepare the API payload.

The trick is to avoid pretending the form shape and API shape are always the same. They often start the same, then drift apart.

A form may use Date objects, select options, temporary IDs, or UI-only fields. The API might expect strings, IDs, or nested objects. That mapping deserves its own function.

export function toUpdateProjectPayload(values: ProjectFormValues) {
  return {
    title: values.title.trim(),
    description: values.description?.trim() || null,
    status: values.status,
  };
}

Small mapping functions are boring. They also prevent a lot of hidden coupling.

Tailwind works best when components still have rules

Tailwind is fast. It’s also easy to abuse.

The problem usually starts when every component becomes a wall of utility classes. At first, that feels productive. Later, small design changes become annoying because the same spacing, border, and text styles exist in twenty different places.

My rule is simple. Use Tailwind freely inside small components, but extract patterns once they repeat.

For example, I don’t want every page to rebuild the same card style:

<script setup lang="ts">
defineProps<{
  as?: string;
}>();
</script>

<template>
  <component
    :is="as || 'div'"
    class="rounded-2xl border bg-white p-5 shadow-sm"
  >
    <slot />
  </component>
</template>

Then feature components can focus on product meaning:

<script setup lang="ts">
import BaseCard from "@/shared/ui/BaseCard.vue";
import type { Project } from "../api/projects.schemas";

defineProps<{
  project: Project;
}>();
</script>

<template>
  <BaseCard>
    <h2 class="text-lg font-semibold">
      {{ project.title }}
    </h2>

    <p class="mt-2 text-sm text-slate-600">
      {{ project.description }}
    </p>
  </BaseCard>
</template>

Tailwind should help us move faster. It shouldn’t replace component thinking.

For larger apps, I also like having a small set of shared primitives: BaseButton, BaseCard, BaseInput, BaseBadge, EmptyState, PageHeader, and ConfirmDialog. Not a huge design system. Just enough to keep the UI from drifting.

The hardest part is knowing when to extract. Too early and you create abstractions nobody needs. Too late and the codebase turns into copy-paste CSS with extra steps.

There’s no perfect rule here. I usually extract when the same pattern appears for the third time and the naming is obvious.

Keep business logic out of components

Components should render decisions, not own all decisions.

This line can be blurry. A component can decide whether to show a loading state. That’s fine. But it probably shouldn’t know all the rules for whether a user can publish, archive, delete, or edit a record.

Instead of this inside a Vue component:

const canPublish = computed(() => {
  return (
    project.value.status === "draft" &&
    user.value.role === "admin" &&
    project.value.validationErrors.length === 0 &&
    !project.value.isLocked
  );
});

I prefer this:

export function getProjectActions(project: Project, user: CurrentUser) {
  return {
    canPublish:
      project.status === "draft" &&
      user.role === "admin" &&
      project.validationErrors.length === 0 &&
      !project.isLocked,

    canArchive:
      project.status === "active" &&
      user.role === "admin",
  };
}

Then the component becomes easier to scan:

<script setup lang="ts">
import { computed } from "vue";
import { getProjectActions } from "../lib/get-project-actions";
import type { Project } from "../api/projects.schemas";
import type { CurrentUser } from "@/shared/auth/types";

const props = defineProps<{
  project: Project;
  user: CurrentUser;
}>();

const actions = computed(() => getProjectActions(props.project, props.user));
</script>

<template>
  <BaseButton :disabled="!actions.canPublish">
    Publish
  </BaseButton>
</template>

This also makes the rules easier to test. You don’t need to render a page just to check if a user can publish a project.

And yes, some people will say this is overkill. For a small app, maybe it is. For a product with roles, lifecycle states, and permissions, it pays off quickly.

Don’t put everything in global state

Global state is useful, but it can turn into a junk drawer.

In Vue apps, I often use Pinia when I need app-wide client state. But I try to keep it limited to things that are truly app-wide: authenticated user, theme, sidebar state, maybe selected organization or workspace.

Server data stays in TanStack Query. Form state stays in the form. UI state stays close to the component unless multiple parts of the app need it.

This keeps state easier to reason about.

A common smell is copying query data into a Pinia store just because another component needs it. Most of the time, that’s not needed. Use the same query key. Let TanStack Query serve the cached data.

The less duplicated state we have, the fewer strange sync bugs we get.

Vue composables should not become hidden services

Vue composables are great. They’re also easy to overload.

A composable should expose a focused piece of behavior. It shouldn’t become a secret service layer that does fetching, validation, permission checks, routing, toast handling, and analytics in one file.

I like composables that are easy to name:

useProjects
useProjectDetails
useUpdateProject
useProjectFilters
useProjectActions

When the name starts becoming vague, that’s a warning sign. Something like useProjectManager may be fine for a small feature, but it can quickly turn into a file where every new requirement gets dumped.

That kind of file becomes hard to test and even harder to delete.

A useful pattern is to separate data composables from UI composables. For example, useProjectDetails can own the query. useProjectFilters can own filter state from the route. getProjectActions can stay as a plain function.

Not everything needs to be a composable.

A good architecture still allows messy reality

No architecture survives real product work untouched.

You’ll get one endpoint that returns a different shape than the others. You’ll add a temporary workaround for a deadline. You’ll keep an old component because rewriting it would risk breaking a demo. That’s normal.

The goal isn’t to create a perfect folder structure. The goal is to make the mess visible and contained.

When something is temporary, name it. Add a comment where it matters.

// Backend still returns legacy status names for archived projects.
// Keep this mapper until the migration is done.
export function normalizeProjectStatus(status: string): ProjectStatus {
  if (status === "disabled") return "archived";

  return projectStatusSchema.parse(status);
}

That kind of comment helps the next developer. It also helps future you, who won’t remember why this weird mapper exists.

What I’d avoid

I’d avoid putting API calls directly in Vue page components. It feels quick, but it makes reuse harder.

I’d also avoid creating a huge shared folder where every helper function goes to die. Shared code should earn its place. If a function is only used by one feature, keep it in that feature.

I’d be careful with generic abstractions too. A useResourceManager composable that handles every entity in the app sounds smart until you need one slightly different flow. Then you start adding flags. Then more flags. Then nobody wants to touch it.

Simple code usually ages better.

The architecture in one flow

When a page needs data, the flow should be easy to trace.

The page renders the feature component. The feature component calls a query composable. The query composable calls an API function. The API function validates the response with Zod. The component receives typed, trusted data and renders UI with shared components and Tailwind.

For writes, the form validates input with Zod. A mapper turns form values into an API payload. The mutation sends the request. TanStack Query invalidates the right queries. The UI updates from the source of truth.

That’s it.

It’s not magic. It’s just clean pressure on the codebase.

Final thoughts

A good frontend architecture should make common work feel obvious.

Where do I add a new endpoint? Where do I validate this response? Where should this permission rule live? How do I reuse this loading state? If the team can answer those questions without a meeting, the architecture is doing its job.

Vue, TypeScript, TanStack Query, Zod, and Tailwind are a strong mix for modern frontend apps. But the real value comes from the boundaries between them.

Let TypeScript describe the app. Let Zod guard the edges. Let TanStack Query own server state. Let Tailwind speed up UI work without replacing components. Keep business rules out of views when they start to grow.

That setup won’t make the app perfect.

It will make it easier to change.

ShareCopy failed