Login

SolidStart Todo App with Firebase

J
Jonathan Gamble
Published Updated 28 min read


TL;DR#

A SolidStart todo app using reactive components, Solid signals and stores, Firebase Authentication, Firestore, Tailwind CSS, and an SSR-loaded About page.

Setup#

Use the modular Firebase JavaScript SDK directly. Solid's context, stores, and effects provide shared reactive state without an additional Firebase integration library.

This project uses Node.js 24 or newer, SolidStart 2, Vite 8, and Nitro 3. Install the dependencies in this repository:

Bash
npm install

Firebase and Valibot are already included in the package manifest. Tailwind CSS 3 supplies the utility classes used by the components.

Firebase Setup#

Create a Firebase project, register a web app, enable Google Authentication, and create a Firestore database. Add localhost and your deployed hostname to Authentication's authorized domains.

Copy .env.example to .env and replace the placeholders with your Firebase web app configuration:

Dotenv
VITE_PUBLIC_FIREBASE_CONFIG='{"apiKey":"...","authDomain":"...","projectId":"...","appId":"..."}'

The browser and server Firebase clients use the same configuration. This is public web app configuration; access to your data is controlled by Firestore rules. Never put service-account credentials in a Vite environment variable.

Publish the supplied Firestore rules and create the composite index shown below. Building the app does not deploy either file.

In the Firebase console, create about/ZlNJrKd6LcATycPRmBPA with two string fields, name and description. The About page reads this document without a signed-in browser user.

Data Types#

The types live beside the code that uses them rather than in global declarations:

  • UserType and UserState are defined in src/lib/use-user.tsx.
  • TodoDoc is exported from src/lib/use-todos.tsx.
  • The About document is validated and its return type inferred in src/lib/about.ts.

Each todo stores uid, text, complete, and createdAt. Its document ID becomes id when read. Completion changes also write updatedAt.

src/lib/firebase.ts#

Initialize Firebase once and export the shared auth and realtime Firestore clients. Reuse the default Firebase app when the module is evaluated again.

TypeScript
import { getApp, getApps, initializeApp } from "firebase/app";
import { getAuth } from "firebase/auth";
import { getFirestore } from "firebase/firestore";

const config = JSON.parse(import.meta.env.VITE_PUBLIC_FIREBASE_CONFIG);

export const app = getApps().length
  ? getApp()
  : initializeApp(config);

export const auth = getAuth(app);
export const db = getFirestore(app);

Authentication#

Google sign-in is exposed through a Solid context backed by a Firebase auth listener.

The provider creates a store containing the loading state and current user. A browser effect subscribes to token changes, and onCleanup removes the subscription when the provider is disposed.

How the Auth State Is Shared#

setUser() creates the reactive state. UserProvider makes that state available to its descendants, and getUser() reads it. Components should access properties such as user.data inside reactive expressions so Solid can track changes.

src/lib/use-user.tsx#

The listener maps Firebase's user object to the smaller shape used by the UI. Login and logout return Firebase error messages to their callers.

TSX
import { createContext, createEffect, onCleanup, useContext, type ParentComponent } from "solid-js";
import { createStore } from "solid-js/store";
import { FirebaseError } from "firebase/app";
import { GoogleAuthProvider, onIdTokenChanged, signInWithPopup, signOut } from "firebase/auth";
import { auth } from "./firebase";

export type UserType = {
  displayName: string | null;
  photoURL: string | null;
  uid: string;
  email: string | null;
};

type UserState = {
  loading: boolean;
  data: UserType | null;
};

const UserContext = createContext<UserState>();

export const setUser = () => {
  const [user, updateUser] = createStore<UserState>({
    loading: true,
    data: null,
  });

  createEffect(() => {
    const unsubscribe = onIdTokenChanged(auth, (user) => {
      if (!user) {
        updateUser({ loading: false, data: null });
        return;
      }

      const { displayName, photoURL, uid, email } = user;
      updateUser({
        loading: false,
        data: { displayName, photoURL, uid, email },
      });
    });

    onCleanup(unsubscribe);
  });

  return user;
};

export const UserProvider: ParentComponent = (props) => {
  const user = setUser();

  return (
    <UserContext.Provider value={user}>
      {props.children}
    </UserContext.Provider>
  );
};

export const getUser = () => {
  const user = useContext(UserContext);
  if (!user) throw new Error("getUser requires a UserProvider.");
  return user;
};

export const loginWithGoogle = async () => {
  try {
    await signInWithPopup(auth, new GoogleAuthProvider());
    return { error: null };
  } catch (error) {
    if (error instanceof FirebaseError) {
      return { error: error.message };
    }
    throw error;
  }
};

export const logout = async () => {
  try {
    await signOut(auth);
    return { error: null };
  } catch (error) {
    if (error instanceof FirebaseError) {
      return { error: error.message };
    }
    throw error;
  }
};

src/lib/helpers.tsx#

Shared loading, sign-in, and sign-out controls keep the components small. These basic auth buttons invoke the actions; they do not currently render the returned action errors.

TSX
import { loginWithGoogle, logout } from "./use-user";

export const Loading = () => <p>Loading...</p>;

export const Login = () => (
  <button
    type="button"
    class="p-2 text-white bg-red-600 border rounded-md"
    onClick={() => loginWithGoogle()}
  >
    Sign in with Google
  </button>
);

export const Logout = () => (
  <p>
    <button
      type="button"
      class="p-2 text-white border rounded-md bg-lime-600"
      onClick={() => logout()}
    >
      Logout
    </button>
  </p>
);

src/components/home.tsx#

Show the loading state, signed-in profile, or Google sign-in button according to the auth store.

TSX
import { Loading, Login } from "~/lib/helpers";
import { getUser } from "~/lib/use-user";
import Profile from "./profile";

export default function Home() {
  const user = getUser();

  return (
    <div class="text-center">
      <h1 class="my-3 text-3xl font-semibold">SolidStart Firebase Todo App</h1>
      {user.loading ? <Loading /> : user.data ? <Profile /> : <Login />}
    </div>
  );
}

src/components/profile.tsx#

The profile displays the current user, sign-out button, and todo list. The Show callback exposes the available user through an accessor.

TSX
import { Show } from "solid-js";
import { Logout } from "~/lib/helpers";
import { getUser } from "~/lib/use-user";
import Todos from "./todos";

export default function Profile() {
  const user = getUser();

  return (
    <Show when={user.data}>
      {(user) => (
        <div class="flex flex-col items-center gap-3">
          <h3 class="font-bold">Hi {user().displayName}!</h3>
          <img src={user().photoURL || ""} width="100" height="100" alt="user avatar" />
          <p>Your userID is {user().uid}</p>
          <Logout />
          <Todos />
        </div>
      )}
    </Show>
  );
}

Todos#

Firestore todos are filtered by the signed-in user and updated in real time.

The query combines where("uid", "==", currentUser.uid) with orderBy("createdAt"). The listener is recreated when the authenticated user changes, and the previous subscription is removed by the effect cleanup.

src/lib/use-todos.tsx#

The converter turns Firestore timestamps into JavaScript dates and includes the document ID. Pending server timestamps use an estimate. The store tracks data, loading, and listener errors; the write helpers return action errors separately.

TSX
import { createEffect, onCleanup } from "solid-js";
import { createStore } from "solid-js/store";
import {
  collection,
  deleteDoc,
  doc,
  FirestoreError,
  onSnapshot,
  orderBy,
  query,
  serverTimestamp,
  setDoc,
  Timestamp,
  type FirestoreDataConverter,
  updateDoc,
  where,
} from "firebase/firestore";
import { auth, db } from "./firebase";
import { getUser } from "./use-user";

export type TodoDoc = {
  id: string;
  uid: string;
  text: string;
  complete: boolean;
  createdAt: Date;
};

const todoConverter: FirestoreDataConverter<TodoDoc> = {
  toFirestore(todo) {
    return todo;
  },

  fromFirestore(snapshot) {
    const data = snapshot.data({ serverTimestamps: "estimate" });
    const createdAt = data.createdAt as Timestamp;

    return {
      id: snapshot.id,
      uid: data.uid,
      text: data.text,
      complete: data.complete,
      createdAt: createdAt.toDate(),
    };
  },
};

export const useTodos = () => {
  const user = getUser();
  const [todos, setTodos] = createStore<{
    data: TodoDoc[];
    loading: boolean;
    error: string | null;
  }>({
    data: [],
    loading: true,
    error: null,
  });

  createEffect(() => {
    const currentUser = user.data;

    if (!currentUser) {
      setTodos({
        data: [],
        loading: false,
        error: null
      });

      return;
    }

    setTodos({
      data: [],
      loading: true,
      error: null
    });

    const unsubscribe = onSnapshot(
      query(
        collection(db, "todos"),
        where("uid", "==", currentUser.uid),
        orderBy("createdAt"),
      ).withConverter(todoConverter),
      (snapshot) => {
        const data = snapshot.docs.map((item) => item.data());
        setTodos({
          data,
          loading: false,
          error: null
        });
      },
      (error) => {
        setTodos({
          data: [],
          loading: false,
          error: error.message
        });
      },
    );

    onCleanup(unsubscribe);
  });

  return todos;
};

export const addTodo = async (text: string) => {
  const user = auth.currentUser;

  if (!user) return { error: "No user" };
  if (!text.trim()) return { error: "Enter a task." };

  try {
    await setDoc(doc(collection(db, "todos")), {
      uid: user.uid,
      text: text.trim(),
      complete: false,
      createdAt: serverTimestamp(),
    });
    return { error: null };
  } catch (error) {
    if (error instanceof FirestoreError) {
      return { error: error.message };
    }
    throw error;
  }
};

export const updateTodo = async (id: string, complete: boolean) => {
  try {
    await updateDoc(doc(db, "todos", id), {
      complete,
      updatedAt: serverTimestamp(),
    });
    return { error: null };
  } catch (error) {
    if (error instanceof FirestoreError) {
      return { error: error.message };
    }
    throw error;
  }
};

export const deleteTodo = async (id: string) => {
  try {
    await deleteDoc(doc(db, "todos", id));
    return { error: null };
  } catch (error) {
    if (error instanceof FirestoreError) {
      return { error: error.message };
    }
    throw error;
  }
};

src/components/todos.tsx#

Todos coordinates the store, list items, and TodoForm. The form uses signals for its input and action error, generates its initial example value after mounting, and resets only after a successful write.

TSX
import { createSignal, For, onMount, Show } from "solid-js";
import { collection, doc } from "firebase/firestore";
import { db } from "~/lib/firebase";
import { addTodo, useTodos } from "~/lib/use-todos";
import { Todo } from "./todo-item";

export const generateText = () =>
  doc(collection(db, "todos")).id.substring(0, 10).toLowerCase();

export default function Todos() {
  const todos = useTodos();

  return (
    <div>
      <div class="flex flex-col gap-3">
        {todos.loading ? (
          <p>Loading todos...</p>
        ) : todos.error ? (
          <p role="alert" class="text-red-600">{todos.error}</p>
        ) : (
          <For each={todos.data} fallback={<p><b>Add your first todo item!</b></p>}>
            {(todo) => <Todo todo={todo} />}
          </For>
        )}
      </div>
      <TodoForm />
    </div>
  );
}

export function TodoForm() {
  const [text, setText] = createSignal("");
  const [error, setError] = createSignal<string | null>(null);

  onMount(() => setText(generateText()));

  const onSubmit = async (event: SubmitEvent) => {
    event.preventDefault();
    setError(null);

    const result = await addTodo(text());
    setError(result.error);

    if (!result.error) setText(generateText());
  };

  return (
    <div class="mt-5">
      <form class="flex items-center justify-center gap-3" onSubmit={onSubmit}>
        <input
          class="p-2 border rounded-lg"
          aria-label="New task"
          value={text()}
          onInput={(event) => setText(event.currentTarget.value)}
          required
        />
        <button class="p-2 font-semibold text-white bg-purple-600 border rounded-lg" type="submit">
          Add Task
        </button>
      </form>
      <Show when={error()}>
        <p role="alert" class="mt-2 text-center text-red-600">{error()}</p>
      </Show>
    </div>
  );
}

src/components/todo-item.tsx#

Each item owns its action error so a failed update or delete does not replace the list state. Read the todo through props to preserve reactivity.

TSX
import { createSignal, Show } from "solid-js";
import { deleteTodo, updateTodo, type TodoDoc } from "~/lib/use-todos";

export function Todo(props: { todo: TodoDoc }) {
  const [error, setError] = createSignal<string | null>(null);

  const toggleComplete = async () => {
    const result = await updateTodo(props.todo.id, !props.todo.complete);
    setError(result.error);
  };

  const handleDelete = async () => {
    const result = await deleteTodo(props.todo.id);
    setError(result.error);
  };

  return (
    <div class="grid grid-cols-[auto,auto,auto,auto] gap-3 items-center justify-items-start">
      <span class={props.todo.complete ? "line-through text-green-700" : ""}>
        {props.todo.text}
      </span>
      <span class={props.todo.complete ? "line-through text-green-700" : ""}>
        {props.todo.id}
      </span>
      <button type="button" aria-label="Toggle task completion" onClick={toggleComplete}>
        {props.todo.complete ? "✔️" : "❌"}
      </button>
      <button type="button" aria-label="Delete task" onClick={handleDelete}>
        🗑
      </button>
      <Show when={error()}>
        <p role="alert" class="col-span-4 text-red-600">{error()}</p>
      </Show>
    </div>
  );
}

Firestore Rules#

Publish these rules in your Firebase project. Users can read and delete their own todos, create valid todos for themselves, and change only completion and update timestamp fields.

The single About document is publicly readable and cannot be written through the client SDK. Create or edit it through the Firebase console.

firestore.rules#

Text
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    function signedIn() { return request.auth != null; }
    function validTodo(data) {
      return data.uid is string && data.text is string &&
        data.text.size() > 0 && data.complete is bool && data.createdAt is timestamp;
    }
    match /todos/{id} {
      allow read, delete: if signedIn() && resource.data.uid == request.auth.uid;
      allow create: if signedIn() && request.resource.data.uid == request.auth.uid &&
        validTodo(request.resource.data) &&
        request.resource.data.keys().hasOnly(['uid', 'text', 'complete', 'createdAt']) &&
        request.resource.data.complete == false &&
        request.resource.data.createdAt == request.time;
      allow update: if signedIn() && resource.data.uid == request.auth.uid &&
        validTodo(request.resource.data) &&
        request.resource.data.diff(resource.data).affectedKeys().hasOnly(['complete', 'updatedAt']) &&
        request.resource.data.updatedAt == request.time;
    }
    match /about/{id} {
      allow read: if id == 'ZlNJrKd6LcATycPRmBPA';
      allow write: if false;
    }
  }
}

Firestore Index#

Create a collection-scope composite index for todos with uid ascending and createdAt ascending. You can use the Firebase console or deploy the supplied index file through a Firebase CLI configuration that references it. A missing-index query error also includes a creation link.

firestore.indexes.json#

JSON
{
  "indexes": [
    {
      "collectionGroup": "todos",
      "queryScope": "COLLECTION",
      "fields": [
        { "fieldPath": "uid", "order": "ASCENDING" },
        { "fieldPath": "createdAt", "order": "ASCENDING" }
      ]
    }
  ],
  "fieldOverrides": []
}

If you have older todos that use a created field, copy those timestamps into createdAt. Firestore ordering excludes documents that do not contain the ordered field.

Server-side Firestore#

The About page reads one Firestore document in a SolidStart server query.

The server uses Firestore Lite for the one-time read and validates the result before returning it. This Firebase web SDK client still obeys Firestore security rules; it does not have Admin SDK privileges.

src/lib/firebase-lite.ts#

Import this helper only from server-side data code. It reuses the default Firebase app and creates a Firestore Lite client.

TypeScript
import { getApp, getApps, initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore/lite";

const config = JSON.parse(import.meta.env.VITE_PUBLIC_FIREBASE_CONFIG);

const serverApp = getApps().length
  ? getApp()
  : initializeApp(config);

export const serverDB = getFirestore(serverApp);

src/lib/about.ts#

Keep the Firestore read and Valibot validation together. Missing or malformed documents throw errors rather than returning invalid data to the component.

TypeScript
import { doc, getDoc } from "firebase/firestore/lite";
import * as v from "valibot";
import { serverDB } from "./firebase-lite";

const AboutDocSchema = v.object({
  name: v.string(),
  description: v.string(),
});

export async function getAbout() {
  const snapshot = await getDoc(doc(serverDB, "about/ZlNJrKd6LcATycPRmBPA"));

  if (!snapshot.exists()) {
    throw new Error("About document does not exist.");
  }

  const result = v.safeParse(AboutDocSchema, snapshot.data());

  if (!result.success) {
    throw new Error("Malformed About document.");
  }

  return result.output;
}

src/routes/about.tsx#

The query marks its callback with use server and calls the statically imported helper. Route preloading starts the read, and createAsync exposes the result to the component. deferStream waits for the data during the server render.

TSX
import { Title } from "@solidjs/meta";
import { RouteDefinition, query, createAsync } from "@solidjs/router";
import { Show } from "solid-js";
import { getAbout } from "~/lib/about";

const getAboutPage = query(async () => {
  'use server';
  return getAbout();
}, 'about');

export const route = {
  preload: () => getAboutPage(),
} satisfies RouteDefinition;

export default function About() {

  const about = createAsync(() => getAboutPage(), { deferStream: true });

  return (
    <Show when={about()}>
      {(data) => (
        <>
          <Title>About</Title>
          <div class="flex items-center justify-center my-5">
            <div class="border w-[400px] p-5 flex flex-col gap-3">
              <h1 class="text-3xl font-semibold">{data().name}</h1>
              <p>{data().description}</p>
            </div>
          </div>
        </>
      )}
    </Show>
  );
};

App Structure#

SolidStart discovers routes in src/routes. The index route provides the authentication context for the home component and its descendants. The root app supplies metadata support, a Suspense boundary, and navigation.

src/routes/index.tsx#

TSX
import { Meta, Title } from "@solidjs/meta";
import Home from "~/components/home";
import { UserProvider } from "~/lib/use-user";

export default function Index() {
  return (
    <main>
      <UserProvider>
        <Title>SolidStart Firebase Todo App</Title>
        <Meta name="description" content="Sign in with Google and manage your realtime Firebase todo list with SolidStart." />
        <Home />
      </UserProvider>
    </main>
  );
}

src/app.tsx#

TSX
import { MetaProvider } from "@solidjs/meta";
import { Router, A } from "@solidjs/router";
import { FileRoutes } from "@solidjs/start/router";
import { Suspense } from "solid-js";


export default function App() {
  return (
    <Router
      root={props => (
        <MetaProvider>
          <Suspense>{props.children}</Suspense>
          <nav class="flex justify-center gap-3 mt-5">
            <A href="/">Home</A>
            <A href="/about">About</A>
          </nav>
        </MetaProvider>
      )}
    >
      <FileRoutes />
    </Router>
  );
}

Build Configuration#

SolidStart and Nitro are configured through Vite. The current deployment preset is Vercel, and local development uses Nitro's Node worker runner.

vite.config.ts#

The optimizeDeps entry converts the indicated browser dependency for development.

TypeScript
import { solidStart } from "@solidjs/start/config";
import { nitro } from "nitro/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [solidStart(), nitro()],
  optimizeDeps: {
    // The browser export is UMD and needs conversion to ESM during development.
    include: ["@jridgewell/resolve-uri"],
  },
  nitro: {
    preset: "vercel",
    devServer: {
      runner: "node-worker",
    },
  },
});

Run the App#

Bash
npm run dev

Sign in with Google, add a task, toggle completion, and delete it. Open a second signed-in tab to see realtime changes. Sign out and confirm the private list disappears. Visit /about to check the server-loaded document.

Stop the development server with Ctrl+C when finished.

Check and Build#

Bash
npm run typecheck
npm run build

For a local production preview, run npm run preview and stop it with Ctrl+C when finished.

And that's it!

J

Comments

Share a thought or join the conversation.

Sign In to comment or reply.