Skip to content

Part 3: Going Full-Stack

Add a SvelteKit frontend alongside your Express backend using multi-container builds and automatic service discovery.

What you'll learn

  • Structuring a monorepo with separate backend and frontend directories
  • Building two containers from a single repository
  • Using ${service:backend} for automatic URL resolution
  • Serving a SvelteKit SPA with nginx
  • Viewing per-service logs

What you'll need

  • The Express + PostgreSQL app from Part 2 deployed and working

Restructure the repository

Move the backend into its own directory and create a frontend alongside it:

todo-app/
  backend/
    index.js
    knexfile.js
    entrypoint.sh
    migrations/
    package.json
    Dockerfile
  frontend/
    src/
    static/
    package.json
    svelte.config.js
    vite.config.js
    Dockerfile
  appmecca.yaml

Move your existing files into backend/. Then scaffold the frontend:

mkdir frontend && cd frontend
npm create svelte@latest . -- --template skeleton
npm install

Create the SvelteKit page

Replace frontend/src/routes/+page.svelte:

<script>
  import { onMount } from "svelte";

  const API_URL = import.meta.env.VITE_API_URL || "";
  let todos = [];
  let newTitle = "";

  onMount(async () => {
    const res = await fetch(`${API_URL}/api/todos`);
    todos = await res.json();
  });

  async function addTodo() {
    const res = await fetch(`${API_URL}/api/todos`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: newTitle }),
    });
    const todo = await res.json();
    todos = [todo, ...todos];
    newTitle = "";
  }

  async function toggle(todo) {
    const res = await fetch(`${API_URL}/api/todos/${todo.id}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ ...todo, done: !todo.done }),
    });
    const updated = await res.json();
    todos = todos.map((t) => (t.id === updated.id ? updated : t));
  }

  async function remove(id) {
    await fetch(`${API_URL}/api/todos/${id}`, { method: "DELETE" });
    todos = todos.filter((t) => t.id !== id);
  }
</script>

<h1>Todo App</h1>

<form on:submit|preventDefault={addTodo}>
  <input bind:value={newTitle} placeholder="What needs doing?" />
  <button type="submit">Add</button>
</form>

<ul>
  {#each todos as todo}
    <li>
      <label>
        <input type="checkbox" checked={todo.done} on:change={() => toggle(todo)} />
        <span class:done={todo.done}>{todo.title}</span>
      </label>
      <button on:click={() => remove(todo.id)}>Delete</button>
    </li>
  {/each}
</ul>

<style>
  .done { text-decoration: line-through; opacity: 0.6; }
</style>

Configure SvelteKit as a static SPA

Install the static adapter:

cd frontend
npm install -D @sveltejs/adapter-static

Update svelte.config.js:

import adapter from "@sveltejs/adapter-static";

export default {
  kit: {
    adapter: adapter({ fallback: "index.html" }),
  },
};

Frontend Dockerfile

Create frontend/Dockerfile:

FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3000

Create frontend/nginx.conf:

server {
    listen 3000;
    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }
}

Update appmecca.yaml

Define both containers and both services:

database:
  enabled: true

containers:
  - name: backend
    dockerfile: backend/Dockerfile
    context: backend/

  - name: frontend
    dockerfile: frontend/Dockerfile
    context: frontend/
    args:
      VITE_API_URL: "${service:backend}"

deploy:
  - name: backend
    container: backend
    port: 3000
    health_check:
      path: /health

  - name: frontend
    container: frontend
    port: 3000
    health_check:
      path: /

Service URL resolution

${service:backend} resolves at build time to the public HTTPS URL of the backend service, for example https://todo-app-backend-yourgroup.dev.example.com. AppMecca injects it as a Docker build arg.

A typo is caught early — the validator rejects a ${service:...} reference to a service that is not declared in the same deploy: block.

Build and deploy

mecca build trigger -g git@github.com:yourorg/todo-app.git
mecca app deploy todo-app

Both containers are built, pushed to AppMecca's private image registry, and deployed. Each service gets its own hostname — always including the service name:

https://todo-app-frontend-yourgroup.dev.example.com
https://todo-app-backend-yourgroup.dev.example.com

View per-service logs

The service is selected with --service, not as a positional argument:

mecca logs todo-app --type container --service frontend
mecca logs todo-app --type container --service backend
mecca logs todo-app --type container --service backend --since 30m
mecca logs todo-app --level error       # errors from every source

Service names

The service name matches the name field under deploy in your appmecca.yaml. Use mecca app show todo-app to see all services, their states and their URLs.

What's next

In Part 4: SPA Runtime Config, you will replace the build-time VITE_API_URL with runtime configuration so the same image works across environments without rebuilding.