Frontend Frameworks

View Source

AshTypescript works with any TypeScript-based frontend. This guide covers integration patterns for different architectures.

Full-Stack Web Apps

If you're building a full-stack web application where Phoenix serves your frontend directly, we recommend using Inertia.js with React or Svelte. Use Typed Queries to pass data from Phoenix controllers to Inertia pages with full type safety.

React

Quick Setup

Use the React framework installer for automated setup:

mix igniter.install ash_typescript --framework react

This automatically sets up:

  • React 19 with TypeScript and TanStack Query
  • esbuild configuration for .tsx files
  • Welcome page at /ash-typescript with getting-started guide

Manual React Setup

1. Install Dependencies

cd assets
npm install --save react react-dom
npm install --save-dev @types/react @types/react-dom typescript

2. Configure TypeScript

Create assets/tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "jsx": "react-jsx",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true
  },
  "include": ["js/**/*"],
  "exclude": ["node_modules"]
}

3. Create React Entry Point

Create assets/js/app.tsx:

import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './components/App';

const root = document.getElementById('root');
if (root) {
  ReactDOM.createRoot(root).render(
    <React.StrictMode>
      <App />
    </React.StrictMode>
  );
}

4. Update Phoenix Template

In lib/my_app_web/components/layouts/root.html.heex:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="csrf-token" content={get_csrf_token()} />
    <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
    <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}></script>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

5. Configure esbuild

Update config/config.exs:

config :esbuild,
  version: "0.17.11",
  default: [
    args: ~w(
      js/app.tsx
      --bundle
      --target=es2020
      --outdir=../priv/static/assets
      --external:/fonts/*
      --external:/images/*
    ),
    cd: Path.expand("../assets", __DIR__),
    env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
  ]

React Component Example

import React, { useEffect, useState } from 'react';
import { listTodos, createTodo, buildCSRFHeaders } from '../ash_rpc';

export function TodoList() {
  const [todos, setTodos] = useState<Array<{id: string, title: string, completed: boolean}>>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    loadTodos();
  }, []);

  async function loadTodos() {
    setLoading(true);
    const result = await listTodos({
      fields: ["id", "title", "completed"],
      headers: buildCSRFHeaders()
    });

    if (result.success) {
      setTodos(result.data);
    }
    setLoading(false);
  }

  async function handleCreate(title: string) {
    const result = await createTodo({
      fields: ["id", "title", "completed"],
      input: { title },
      headers: buildCSRFHeaders()
    });

    if (result.success) {
      setTodos([...todos, result.data]);
    }
  }

  if (loading) return <div>Loading...</div>;

  return (
    <div>
      <h1>Todos</h1>
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            {todo.title} - {todo.completed ? 'Done' : 'Pending'}
          </li>
        ))}
      </ul>
      <button onClick={() => handleCreate('New Todo')}>
        Add Todo
      </button>
    </div>
  );
}

With TanStack Query

For better data fetching patterns, use TanStack Query:

npm install @tanstack/react-query
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { listTodos, createTodo, buildCSRFHeaders } from '../ash_rpc';

export function TodoListWithQuery() {
  const queryClient = useQueryClient();
  const headers = buildCSRFHeaders();

  const { data: todos, isLoading } = useQuery({
    queryKey: ['todos'],
    queryFn: async () => {
      const result = await listTodos({
        fields: ["id", "title", "completed"],
        headers
      });
      if (!result.success) {
        throw new Error(result.errors.map(e => e.message).join(', '));
      }
      return result.data;
    }
  });

  const createMutation = useMutation({
    mutationFn: async (title: string) => {
      const result = await createTodo({
        fields: ["id", "title", "completed"],
        input: { title },
        headers
      });
      if (!result.success) {
        throw new Error(result.errors.map(e => e.message).join(', '));
      }
      return result.data;
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    }
  });

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      <ul>
        {todos?.map(todo => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
      <button onClick={() => createMutation.mutate('New Todo')}>
        Add Todo
      </button>
    </div>
  );
}

Vue

AshTypescript works seamlessly with Vue 3 and the Composition API.

Setup

cd assets
npm install vue
npm install --save-dev @vitejs/plugin-vue

Vue Component Example

<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { listTodos, createTodo, buildCSRFHeaders } from '../ash_rpc';

const todos = ref<Array<{id: string, title: string, completed: boolean}>>([]);
const loading = ref(true);
const headers = buildCSRFHeaders();

onMounted(async () => {
  const result = await listTodos({
    fields: ["id", "title", "completed"],
    headers
  });

  if (result.success) {
    todos.value = result.data;
  }
  loading.value = false;
});

async function addTodo(title: string) {
  const result = await createTodo({
    fields: ["id", "title", "completed"],
    input: { title },
    headers
  });

  if (result.success) {
    todos.value.push(result.data);
  }
}
</script>

<template>
  <div v-if="loading">Loading...</div>
  <div v-else>
    <ul>
      <li v-for="todo in todos" :key="todo.id">
        {{ todo.title }} - {{ todo.completed ? 'Done' : 'Pending' }}
      </li>
    </ul>
    <button @click="addTodo('New Todo')">Add Todo</button>
  </div>
</template>

Svelte

AshTypescript integrates naturally with Svelte and SvelteKit.

Svelte Component Example

<script lang="ts">
  import { onMount } from 'svelte';
  import { listTodos, createTodo, buildCSRFHeaders } from '../ash_rpc';

  let todos: Array<{id: string, title: string, completed: boolean}> = [];
  let loading = true;
  const headers = buildCSRFHeaders();

  onMount(async () => {
    const result = await listTodos({
      fields: ["id", "title", "completed"],
      headers
    });

    if (result.success) {
      todos = result.data;
    }
    loading = false;
  });

  async function addTodo(title: string) {
    const result = await createTodo({
      fields: ["id", "title", "completed"],
      input: { title },
      headers
    });

    if (result.success) {
      todos = [...todos, result.data];
    }
  }
</script>

{#if loading}
  <div>Loading...</div>
{:else}
  <ul>
    {#each todos as todo (todo.id)}
      <li>{todo.title} - {todo.completed ? 'Done' : 'Pending'}</li>
    {/each}
  </ul>
  <button on:click={() => addTodo('New Todo')}>Add Todo</button>
{/if}

Vanilla TypeScript

For applications without a framework, use the generated functions directly:

import { listTodos, createTodo, buildCSRFHeaders } from './ash_rpc';

const headers = buildCSRFHeaders();

async function init() {
  const result = await listTodos({
    fields: ["id", "title", "completed"],
    headers
  });

  if (result.success) {
    renderTodos(result.data);
  }
}

function renderTodos(todos: Array<{id: string, title: string, completed: boolean}>) {
  const container = document.getElementById('todos');
  if (!container) return;

  container.innerHTML = todos
    .map(todo => `<li>${todo.title} - ${todo.completed ? 'Done' : 'Pending'}</li>`)
    .join('');
}

document.addEventListener('DOMContentLoaded', init);

Inertia.js (Full-Stack Phoenix)

For full-stack Phoenix applications, use Inertia.js with Typed Queries. This provides:

  • SSR with type-safe page props
  • No separate API needed
  • Seamless navigation with SPA-like feel
# Phoenix controller with typed query
defmodule MyAppWeb.TodoController do
  use MyAppWeb, :controller

  def index(conn, _params) do
    todos =
      case AshTypescript.Rpc.run_typed_query(:my_app, :dashboard_todo, %{}, conn) do
        %{"success" => true, "data" => data} -> data
        _ -> []
      end

    conn
    |> assign_prop(:todos, todos)
    |> render_inertia("TodoList")
  end
end
<!-- Svelte page with generated types -->
<script lang="ts">
  import type { DashboardTodo } from '$js/ash_rpc';

  interface Props {
    todos: DashboardTodo[];
  }

  let { todos }: Props = $props();
</script>

{#each todos as todo (todo.id)}
  <div>{todo.title}</div>
{/each}

See Typed Queries for detailed patterns and configuration.

CSRF Protection

For browser-based applications using session authentication:

import { buildCSRFHeaders } from './ash_rpc';

// Include CSRF headers in all requests
const result = await listTodos({
  fields: ["id", "title"],
  headers: buildCSRFHeaders()
});

The buildCSRFHeaders() function reads the CSRF token from the meta tag in your layout:

<meta name="csrf-token" content={get_csrf_token()} />

Example Repository

Check out the AshTypescript Demo for a complete Phoenix + React + TypeScript example featuring:

  • TanStack Query for data fetching
  • TanStack Table for data display
  • Complete CRUD operations
  • Best practices and patterns

Next Steps