---
title: Notes
description: Build a notes application with file operations and native dialogs
sidebar:
  order: 3
---

import { Steps } from "@astrojs/starlight/components";

In this tutorial, you'll build a notes application that demonstrates file operations, native dialogs, and modern desktop app patterns using Wails v3.

![Notes App Screenshot](../../../assets/notes-app.png)

## What You'll Build

- A complete notes app with create, edit, and delete functionality
- Native file save/open dialogs for importing and exporting notes
- Auto-save on typing with debounce to reduce unnecessary updates
- Professional two-column layout (sidebar + editor) mimicking Apple Notes

## What You'll Learn

- Using native file dialogs in Wails (`SaveFileDialog`, `OpenFileDialog`, `InfoDialog`)
- Working with JSON files for data persistence
- Implementing debounced auto-save patterns
- Building professional desktop UIs with modern CSS
- Proper Go struct JSON serialization

## Project Setup

<Steps>

1. **Create a new Wails project**

   ```bash
   wails3 init -n notes-app -t vanilla
   cd notes-app
   ```

2. **Create the NotesService**

   Create a new file `notesservice.go` in the project root:

   ```go
   package main

   import (
   	"encoding/json"
   	"errors"
   	"os"
   	"time"

   	"github.com/wailsapp/wails/v3/pkg/application"
   )

   type Note struct {
   	ID        string    `json:"id"`
   	Title     string    `json:"title"`
   	Content   string    `json:"content"`
   	CreatedAt time.Time `json:"createdAt"`
   	UpdatedAt time.Time `json:"updatedAt"`
   }

   type NotesService struct {
   	notes []Note
   }

   func NewNotesService() *NotesService {
   	return &NotesService{
   		notes: make([]Note, 0),
   	}
   }

   // GetAll returns all notes
   func (n *NotesService) GetAll() []Note {
   	return n.notes
   }

   // Create creates a new note
   func (n *NotesService) Create(title, content string) Note {
   	note := Note{
   		ID:        generateID(),
   		Title:     title,
   		Content:   content,
   		CreatedAt: time.Now(),
   		UpdatedAt: time.Now(),
   	}
   	n.notes = append(n.notes, note)
   	return note
   }

   // Update updates an existing note
   func (n *NotesService) Update(id, title, content string) error {
   	for i := range n.notes {
   		if n.notes[i].ID == id {
   			n.notes[i].Title = title
   			n.notes[i].Content = content
   			n.notes[i].UpdatedAt = time.Now()
   			return nil
   		}
   	}
   	return errors.New("note not found")
   }

   // Delete deletes a note
   func (n *NotesService) Delete(id string) error {
   	for i := range n.notes {
   		if n.notes[i].ID == id {
   			n.notes = append(n.notes[:i], n.notes[i+1:]...)
   			return nil
   		}
   	}
   	return errors.New("note not found")
   }

   // SaveToFile saves notes to a file
   func (n *NotesService) SaveToFile() error {
   	path, err := application.Get().Dialog.SaveFile().
   		SetFilename("notes.json").
   		AddFilter("JSON Files", "*.json").
   		PromptForSingleSelection()

   	if err != nil {
   		return err
   	}

   	data, err := json.MarshalIndent(n.notes, "", "  ")
   	if err != nil {
   		return err
   	}

   	if err := os.WriteFile(path, data, 0644); err != nil {
   		return err
   	}

   	application.Get().Dialog.Info().
   		SetTitle("Success").
   		SetMessage("Notes saved successfully!").
   		Show()

   	return nil
   }

   // LoadFromFile loads notes from a file
   func (n *NotesService) LoadFromFile() error {
   	path, err := application.Get().Dialog.OpenFile().
   		AddFilter("JSON Files", "*.json").
   		PromptForSingleSelection()

   	if err != nil {
   		return err
   	}

   	data, err := os.ReadFile(path)
   	if err != nil {
   		return err
   	}

   	var notes []Note
   	if err := json.Unmarshal(data, &notes); err != nil {
   		return err
   	}

   	n.notes = notes

   	application.Get().Dialog.Info().
   		SetTitle("Success").
   		SetMessage("Notes loaded successfully!").
   		Show()

   	return nil
   }

   func generateID() string {
   	return time.Now().Format("20060102150405")
   }
   ```

   **What's happening here:**

   - **Note struct**: Defines the data structure with JSON tags (lowercase) for proper serialization
   - **CRUD operations**: GetAll, Create, Update, and Delete for managing notes in memory
   - **File dialogs**: Uses `application.Get().Dialog.SaveFile()` and `application.Get().Dialog.OpenFile()` to access native dialogs
   - **Info dialogs**: Shows success messages using `application.Get().Dialog.Info()`
   - **ID generation**: Simple timestamp-based ID generator

3. **Update main.go**

   Replace the contents of `main.go`:

   ```go
   package main

   import (
   	"embed"
   	_ "embed"
   	"log"

   	"github.com/wailsapp/wails/v3/pkg/application"
   )

   //go:embed all:frontend/dist
   var assets embed.FS

   func main() {
   	app := application.New(application.Options{
   		Name:        "Notes App",
   		Description: "A simple notes application",
   		Services: []application.Service{
   			application.NewService(NewNotesService()),
   		},
   		Assets: application.AssetOptions{
   			Handler: application.AssetFileServerFS(assets),
   		},
   		Mac: application.MacOptions{
   			ApplicationShouldTerminateAfterLastWindowClosed: true,
   		},
   	})

   	app.Window.NewWithOptions(application.WebviewWindowOptions{
   		Title:            "Notes App",
   		Width:            1000,
   		Height:           700,
   		BackgroundColour: application.NewRGB(255, 255, 255),
   		URL:              "/",
   	})

   	err := app.Run()
   	if err != nil {
   		log.Fatal(err)
   	}
   }
   ```

   **What's happening here:**

   - Registers `NotesService` with the application
   - Creates a window with dimensions (1000x700) mimicking Apple Notes
   - Sets up proper macOS behavior to quit when the last window closes

4. **Create the HTML structure**

   Replace `frontend/index.html`:

   ```html
   <!DOCTYPE html>
   <html lang="en">
   <head>
       <meta charset="UTF-8">
       <meta name="viewport" content="width=device-width, initial-scale=1.0">
       <title>Notes App</title>
       <link rel="stylesheet" href="./style.css">
   </head>
   <body>
       <div class="app">
           <!-- Sidebar -->
           <div class="sidebar">
               <div class="sidebar-header">
                   <h1>Notes</h1>
                   <button id="new-note-btn" class="btn-primary">+ New Note</button>
               </div>
               <div id="notes-list" class="notes-list"></div>
               <div class="sidebar-footer">
                   <button id="save-btn" class="btn-secondary">Save</button>
                   <button id="load-btn" class="btn-secondary">Load</button>
               </div>
           </div>

           <!-- Editor -->
           <div class="editor">
               <div id="empty-state" class="empty-state">
                   <h2>No note selected</h2>
                   <p>Select a note from the list or create a new one</p>
               </div>
               <div id="note-editor" class="note-editor" style="display: none;">
                   <input type="text" id="note-title" placeholder="Note title" class="title-input">
                   <textarea id="note-content" placeholder="Start typing..." class="content-input"></textarea>
                   <div class="editor-footer">
                       <button id="delete-btn" class="btn-danger">Delete</button>
                       <span id="last-updated" class="last-updated"></span>
                   </div>
               </div>
           </div>
       </div>

       <script src="/wails/runtime.js"></script>
       <script type="module" src="./src/main.js"></script>
   </body>
   </html>
   ```

   **What's happening here:**

   - **Two-column layout**: Sidebar for notes list, main area for editor
   - **Empty state**: Shows when no note is selected
   - **Wails runtime**: Must be loaded before the module script

5. **Add the CSS styling**

   Replace `frontend/public/style.css`:

   ```css
   * {
       margin: 0;
       padding: 0;
       box-sizing: border-box;
   }

   body {
       font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
       height: 100vh;
       overflow: hidden;
   }

   .app {
       display: flex;
       height: 100vh;
   }

   /* Sidebar */
   .sidebar {
       width: 300px;
       background: #f5f5f5;
       border-right: 1px solid #e0e0e0;
       display: flex;
       flex-direction: column;
   }

   .sidebar-header {
       padding: 20px;
       border-bottom: 1px solid #e0e0e0;
   }

   .sidebar-header h1 {
       font-size: 24px;
       margin-bottom: 16px;
   }

   .notes-list {
       flex: 1;
       overflow-y: auto;
   }

   .note-item {
       padding: 16px 20px;
       border-bottom: 1px solid #e0e0e0;
       cursor: pointer;
       transition: background 0.2s;
   }

   .note-item:hover {
       background: #e8e8e8;
   }

   .note-item.active {
       background: #007aff;
       color: white;
   }

   .note-item h3 {
       font-size: 16px;
       margin-bottom: 4px;
   }

   .note-item p {
       font-size: 14px;
       opacity: 0.7;
       white-space: nowrap;
       overflow: hidden;
       text-overflow: ellipsis;
   }

   .sidebar-footer {
       padding: 16px 20px;
       border-top: 1px solid #e0e0e0;
       display: flex;
       gap: 8px;
   }

   /* Editor */
   .editor {
       flex: 1;
       display: flex;
       flex-direction: column;
   }

   .empty-state {
       flex: 1;
       display: flex;
       flex-direction: column;
       align-items: center;
       justify-content: center;
       color: #999;
   }

   .note-editor {
       flex: 1;
       display: flex;
       flex-direction: column;
       padding: 20px;
   }

   .title-input {
       font-size: 32px;
       font-weight: bold;
       border: none;
       outline: none;
       margin-bottom: 16px;
       padding: 8px 0;
   }

   .content-input {
       flex: 1;
       font-size: 16px;
       border: none;
       outline: none;
       resize: none;
       font-family: inherit;
       line-height: 1.6;
   }

   .editor-footer {
       display: flex;
       justify-content: space-between;
       align-items: center;
       padding-top: 16px;
       border-top: 1px solid #e0e0e0;
   }

   .last-updated {
       font-size: 14px;
       color: #999;
   }

   /* Buttons */
   .btn-primary {
       background: #007aff;
       color: white;
       border: none;
       padding: 10px 20px;
       border-radius: 6px;
       cursor: pointer;
       font-size: 14px;
       font-weight: 500;
       width: 100%;
   }

   .btn-primary:hover {
       background: #0056b3;
   }

   .btn-secondary {
       background: white;
       color: #333;
       border: 1px solid #e0e0e0;
       padding: 8px 16px;
       border-radius: 6px;
       cursor: pointer;
       font-size: 14px;
       flex: 1;
   }

   .btn-secondary:hover {
       background: #f5f5f5;
   }

   .btn-danger {
       background: #ff3b30;
       color: white;
       border: none;
       padding: 8px 16px;
       border-radius: 6px;
       cursor: pointer;
       font-size: 14px;
   }

   .btn-danger:hover {
       background: #cc0000;
   }
   ```

   **What's happening here:**

   - Apple-style design with clean typography and colors
   - Flexbox layout for responsive sidebar and editor
   - Active note highlighting with blue background
   - Smooth hover transitions

6. **Implement the JavaScript logic**

   Replace `frontend/src/main.js`:

   ```javascript
   import { NotesService } from '../bindings/changeme'

   let notes = []
   let currentNote = null

   // Load notes on startup
   async function loadNotes() {
       notes = await NotesService.GetAll()
       renderNotesList()
   }

   // Render notes list
   function renderNotesList() {
       const notesList = document.getElementById('notes-list')

       if (notes.length === 0) {
           notesList.innerHTML = '<div style="padding: 20px; text-align: center; color: #999;">No notes yet</div>'
           return
       }

       notesList.innerHTML = notes.map(note => `
           <div class="note-item ${currentNote?.id === note.id ? 'active' : ''}" data-id="${note.id}">
               <h3>${note.title || 'Untitled'}</h3>
               <p>${note.content || 'No content'}</p>
           </div>
       `).join('')

       // Add click handlers
       document.querySelectorAll('.note-item').forEach(item => {
           item.addEventListener('click', () => {
               const id = item.dataset.id
               selectNote(id)
           })
       })
   }

   // Select a note
   function selectNote(id) {
       currentNote = notes.find(n => n.id === id)
       if (currentNote) {
           document.getElementById('empty-state').style.display = 'none'
           document.getElementById('note-editor').style.display = 'flex'
           document.getElementById('note-title').value = currentNote.title
           document.getElementById('note-content').value = currentNote.content
           document.getElementById('last-updated').textContent =
               `Last updated: ${new Date(currentNote.updatedAt).toLocaleString()}`
           renderNotesList()
       }
   }

   // Create new note
   document.getElementById('new-note-btn').addEventListener('click', async () => {
       const note = await NotesService.Create('Untitled', '')
       notes.push(note)
       selectNote(note.id)
       // Focus the title input and select all text so user can immediately type
       const titleInput = document.getElementById('note-title')
       titleInput.focus()
       titleInput.select()
   })

   // Update note on input
   let updateTimeout
   function scheduleUpdate() {
       clearTimeout(updateTimeout)
       updateTimeout = setTimeout(async () => {
           if (currentNote) {
               const title = document.getElementById('note-title').value
               const content = document.getElementById('note-content').value

               await NotesService.Update(currentNote.id, title, content)

               // Update local copy
               const note = notes.find(n => n.id === currentNote.id)
               if (note) {
                   note.title = title
                   note.content = content
                   note.updatedAt = new Date().toISOString()
               }

               renderNotesList()
               document.getElementById('last-updated').textContent =
                   `Last updated: ${new Date().toLocaleString()}`
           }
       }, 500)
   }

   document.getElementById('note-title').addEventListener('input', scheduleUpdate)
   document.getElementById('note-content').addEventListener('input', scheduleUpdate)

   // Delete note
   document.getElementById('delete-btn').addEventListener('click', async () => {
       if (!currentNote) return

       try {
           await NotesService.Delete(currentNote.id)
           notes = notes.filter(n => n.id !== currentNote.id)
           currentNote = null
           document.getElementById('empty-state').style.display = 'flex'
           document.getElementById('note-editor').style.display = 'none'
           renderNotesList()
       } catch (error) {
           console.error('Delete failed:', error)
       }
   })

   // Save to file
   document.getElementById('save-btn').addEventListener('click', async () => {
       try {
           await NotesService.SaveToFile()
       } catch (error) {
           if (error) console.error('Save failed:', error)
       }
   })

   // Load from file
   document.getElementById('load-btn').addEventListener('click', async () => {
       try {
           await NotesService.LoadFromFile()
           notes = await NotesService.GetAll()
           currentNote = null
           document.getElementById('empty-state').style.display = 'flex'
           document.getElementById('note-editor').style.display = 'none'
           renderNotesList()
       } catch (error) {
           if (error) console.error('Load failed:', error)
       }
   })

   // Initialize
   loadNotes()
   ```

   **What's happening here:**

   - **Auto-save**: 500ms debounce prevents excessive backend calls while typing
   - **Property access**: Uses lowercase property names (`.id`, `.title`) matching Go JSON tags
   - **Focus management**: Auto-focuses and selects title when creating new notes
   - **Delete without confirm**: Browser's `confirm()` doesn't work in Wails webviews
   - **File operations**: Native dialogs handle save/load with proper error handling

7. **Run the application**

   ```bash
   wails3 dev
   ```

   The app will start and you can:
   - Click "+ New Note" to create notes
   - Edit title and content (auto-saves after 500ms)
   - Click notes in the sidebar to switch between them
   - Click "Delete" to remove the current note
   - Click "Save" to export notes as JSON
   - Click "Load" to import previously saved notes

</Steps>

## Key Concepts

### Package-Level Dialog Functions

In Wails v3, file and message dialogs are package-level functions, not methods on the app instance:

```go
// Correct - package-level function
path, err := application.SaveFileDialog().
    SetFilename("notes.json").
    AddFilter("JSON Files", "*.json").
    PromptForSingleSelection()

// Wrong - not available
path, err := app.Dialog.SaveFile() // This doesn't exist
```

### JSON Tag Mapping

Go struct JSON tags must be lowercase to match JavaScript property access:

```go
type Note struct {
    ID string `json:"id"` // Must be lowercase
}
```

```javascript
// JavaScript accesses with lowercase
const noteId = note.id // Correct
const noteId = note.ID // Would be undefined
```

### Debounced Auto-Save

The 500ms debounce reduces unnecessary backend calls:

```javascript
let updateTimeout
function scheduleUpdate() {
    clearTimeout(updateTimeout) // Cancel previous timer
    updateTimeout = setTimeout(async () => {
        // Only saves if user stops typing for 500ms
        await NotesService.Update(currentNote.id, title, content)
    }, 500)
}
```

## Next Steps

- Add categories or tags to organize notes
- Implement search and filtering
- Add rich text editing with a WYSIWYG editor
- Sync notes to cloud storage
- Add keyboard shortcuts for common operations
