SvelteRails Svelte Themes

Svelterails

A combined Svelte and Rails app

TL;DR, clone this repo then:

rails db:migrate
rails db:seed
rails server

Longer how to.

rails new SvelteRails --api
cd SvelteRails
rails g scaffold Item what when:date
rails db:migrate
rails db:seed

Create a default route.

Rails.application.routes.draw do
  resources :items
  root "items#index"
end

Overlay a Svelte app.

npx sv create --template minimal --types ts --install npm .

Here is what is created.

src
├── app.d.ts
├── app.html
├── lib
│   ├── api.ts
│   ├── assets
│   │   └── favicon.svg
│   └── index.ts
└── routes
    ├── +layout.svelte
    ├── +layout.ts
    └── +page.svelte

Make src/lib/api.ts look like the following.

export interface Item {
  id?: number;
  what: string;
  when: string;
}

const API_URL = 'http://localhost:3000';

export async function getItems(): Promise<Item[]> {
  const response = await fetch(`${API_URL}/items`);
  if (!response.ok) throw new Error('Failed to fetch items');
  return response.json();
}

export async function getItem(id: number): Promise<Item> {
  const response = await fetch(`${API_URL}/items/${id}`);
  if (!response.ok) throw new Error('Failed to fetch item');
  return response.json();
}

export async function createItem(item: Omit<Item, 'id'>): Promise<Item> {
  const response = await fetch(`${API_URL}/items`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(item)
  });
  if (!response.ok) throw new Error('Failed to create item');
  return response.json();
}

export async function updateItem(id: number, item: Omit<Item, 'id'>): Promise<Item> {
  const response = await fetch(`${API_URL}/items/${id}`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(item)
  });
  if (!response.ok) throw new Error('Failed to update item');
  return response.json();
}

export async function deleteItem(id: number): Promise<void> {
  const response = await fetch(`${API_URL}/items/${id}`, {
    method: 'DELETE'
  });
  if (!response.ok) throw new Error('Failed to delete item');
}

Make src/routes/+page_svelte look like.

<script lang="ts">
  import { onMount } from 'svelte';
  import { getItems, createItem, updateItem, deleteItem, type Item } from '$lib/api';

  let items: Item[] = [];
  let loading = false;
  let error = '';

  let formData = { what: '', when: '' };
  let editingId: number | null = null;

  onMount(async () => {
    await loadItems();
  });

  async function loadItems() {
    loading = true;
    error = '';
    try {
      items = await getItems();
    } catch (e) {
      error = e instanceof Error ? e.message : 'Failed to load items';
    } finally {
      loading = false;
    }
  }

  async function handleSubmit() {
    if (!formData.what || !formData.when) {
      error = 'Please fill in all fields';
      return;
    }

    loading = true;
    error = '';
    try {
      if (editingId !== null) {
        await updateItem(editingId, formData);
        editingId = null;
      } else {
        await createItem(formData);
      }
      formData = { what: '', when: '' };
      await loadItems();
    } catch (e) {
      error = e instanceof Error ? e.message : 'Operation failed';
    } finally {
      loading = false;
    }
  }

  function handleEdit(item: Item) {
    if (item.id !== undefined) {
      editingId = item.id;
      formData = { what: item.what, when: item.when };
    }
  }

  function cancelEdit() {
    editingId = null;
    formData = { what: '', when: '' };
  }

  async function handleDelete(id: number) {
    if (!confirm('Are you sure you want to delete this item?')) return;

    loading = true;
    error = '';
    try {
      await deleteItem(id);
      await loadItems();
    } catch (e) {
      error = e instanceof Error ? e.message : 'Failed to delete item';
    } finally {
      loading = false;
    }
  }
</script>

<div class="container">
  <h1>Items Manager</h1>

  {#if error}
    <div class="error">{error}</div>
  {/if}

  <form on:submit|preventDefault={handleSubmit} class="form">
    <div class="form-group">
      <label for="what">What:</label>
      <input
        id="what"
        type="text"
        bind:value={formData.what}
        placeholder="Enter description"
        disabled={loading}
      />
    </div>

    <div class="form-group">
      <label for="when">When:</label>
      <input
        id="when"
        type="date"
        bind:value={formData.when}
        disabled={loading}
      />
    </div>

    <div class="button-group">
      <button type="submit" disabled={loading}>
        {editingId !== null ? 'Update' : 'Create'}
      </button>
      {#if editingId !== null}
        <button type="button" on:click={cancelEdit} disabled={loading}>
          Cancel
        </button>
      {/if}
    </div>
  </form>

  <div class="items-list">
    <h2>Items</h2>
    {#if loading && items.length === 0}
      <p>Loading...</p>
    {:else if items.length === 0}
      <p>No items yet. Create one above!</p>
    {:else}
      <ul>
        {#each items as item (item.id)}
          <li>
            <div class="item-content">
              <strong>{item.what}</strong>
              <span>{new Date(item.when).toLocaleDateString()}</span>
            </div>
            <div class="item-actions">
              <button on:click={() => handleEdit(item)} disabled={loading}>
                Edit
              </button>
              <button
                on:click={() => item.id && handleDelete(item.id)}
                disabled={loading}
                class="delete"
              >
                Delete
              </button>
            </div>
          </li>
        {/each}
      </ul>
    {/if}
  </div>
</div>

<style>
  .container {
    max-width: 800px;
    margin: 0 auto;
    padding: 2rem;
  }

  h1 {
    color: #333;
    margin-bottom: 2rem;
  }

  h2 {
    color: #555;
    margin-bottom: 1rem;
  }

  .error {
    background: #fee;
    color: #c33;
    padding: 1rem;
    border-radius: 4px;
    margin-bottom: 1rem;
  }

  .form {
    background: #f5f5f5;
    padding: 1.5rem;
    border-radius: 8px;
    margin-bottom: 2rem;
  }

  .form-group {
    margin-bottom: 1rem;
  }

  label {
    display: block;
    margin-bottom: 0.5rem;
    font-weight: 500;
    color: #555;
  }

  input {
    width: 100%;
    padding: 0.5rem;
    border: 1px solid #ddd;
    border-radius: 4px;
    font-size: 1rem;
  }

  .button-group {
    display: flex;
    gap: 0.5rem;
  }

  button {
    padding: 0.5rem 1rem;
    background: #007bff;
    color: white;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    font-size: 1rem;
  }

  button:hover:not(:disabled) {
    background: #0056b3;
  }

  button:disabled {
    background: #ccc;
    cursor: not-allowed;
  }

  button.delete {
    background: #dc3545;
  }

  button.delete:hover:not(:disabled) {
    background: #c82333;
  }

  .items-list ul {
    list-style: none;
    padding: 0;
  }

  .items-list li {
    background: white;
    border: 1px solid #ddd;
    border-radius: 4px;
    padding: 1rem;
    margin-bottom: 0.5rem;
    display: flex;
    justify-content: space-between;
    align-items: center;
  }

  .item-content {
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
  }

  .item-content span {
    color: #666;
    font-size: 0.9rem;
  }

  .item-actions {
    display: flex;
    gap: 0.5rem;
  }

  .item-actions button {
    padding: 0.25rem 0.75rem;
    font-size: 0.9rem;
  }
</style>

npm install -D @sveltejs/adapter-static

Update svelte.config.js to create static pages.

import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

/** @type {import('@sveltejs/kit').Config} */
const config = {
    preprocess: vitePreprocess(),

    kit: {
        adapter: adapter({
            pages: 'build',
            assets: 'build',
            fallback: undefined,
            precompress: false,
            strict: true
        })
    }
};

export default config;

Create src/routes/+layout.ts with the following.

export const prerender = true;
export const ssr = false;
export const trailingSlash = 'always';

Here is a tree view of what gets built.

build/
├── _app
│   ├── env.js
│   ├── immutable
│   │   ├── assets
│   │   │   └── 2.DvNvAX5G.css
│   │   ├── chunks
│   │   │   ├── 0-MqESqi.js
│   │   │   ├── 46vft45f.js
│   │   │   ├── B8bJjLrw.js
│   │   │   ├── BG113DiQ.js
│   │   │   ├── BHIs7hwi.js
│   │   │   ├── CJRWyrcJ.js
│   │   │   ├── DCubpxHF.js
│   │   │   ├── yIDQwdGA.js
│   │   │   └── ZGXG3O0-.js
│   │   ├── entry
│   │   │   ├── app.BxxNVJZu.js
│   │   │   └── start.DFbO-oR7.js
│   │   └── nodes
│   │       ├── 0.DdPABvte.js
│   │       ├── 1.Dk2RXuwB.js
│   │       └── 2.4w0spv--.js
│   └── version.json
├── index.html
└── robots.txt

Copy the results to the static rails directory.

cp -Rf build/* public

You can now run rails server and surf to localhost:3000.

If you want to run dynamically, you'll need a CORS extension.

bundle add rack-cors

In config/initializers/cors.rb.

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins "localhost:5173"

    resource "*",
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

Now you can npm run dev --open.

If wanted, add a Google font (https://fonts.google.com) to /src/routes/+layouts.svelte.

<style>
  :global(body) {
    font-family: 'Roboto', sans-serif;
  }
</style>

Top categories

Loading Svelte Themes