diff --git a/Cargo.lock b/Cargo.lock
index 60edbe4..f850824 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,21 @@
 # It is not intended for manual editing.
 version = 3
 
+[[package]]
+name = "addr2line"
+version = "0.24.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
+dependencies = [
+ "gimli",
+]
+
+[[package]]
+name = "adler2"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
+
 [[package]]
 name = "aho-corasick"
 version = "1.1.3"
@@ -86,6 +101,21 @@ version = "1.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
 
+[[package]]
+name = "backtrace"
+version = "0.3.74"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a"
+dependencies = [
+ "addr2line",
+ "cfg-if",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+ "windows-targets",
+]
+
 [[package]]
 name = "base64"
 version = "0.22.1"
@@ -265,8 +295,14 @@ version = "0.1.0"
 dependencies = [
  "console_error_panic_hook",
  "dedent",
+ "futures",
+ "idb",
  "leptos",
+ "serde",
+ "serde-wasm-bindgen",
+ "serde_json",
  "thiserror 2.0.12",
+ "url",
 ]
 
 [[package]]
@@ -454,6 +490,12 @@ dependencies = [
  "windows-targets",
 ]
 
+[[package]]
+name = "gimli"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
+
 [[package]]
 name = "gloo-net"
 version = "0.6.0"
@@ -664,6 +706,20 @@ dependencies = [
  "syn",
 ]
 
+[[package]]
+name = "idb"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3afe8830d5802f769dc0be20a87f9f116798c896650cb6266eb5c19a3c109eed"
+dependencies = [
+ "js-sys",
+ "num-traits",
+ "thiserror 1.0.69",
+ "tokio",
+ "wasm-bindgen",
+ "web-sys",
+]
+
 [[package]]
 name = "idna"
 version = "1.0.3"
@@ -919,6 +975,15 @@ version = "0.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
 
+[[package]]
+name = "miniz_oxide"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5"
+dependencies = [
+ "adler2",
+]
+
 [[package]]
 name = "next_tuple"
 version = "0.1.0"
@@ -935,6 +1000,15 @@ dependencies = [
  "minimal-lexical",
 ]
 
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
 [[package]]
 name = "num_cpus"
 version = "1.16.0"
@@ -945,6 +1019,15 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "object"
+version = "0.36.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
+dependencies = [
+ "memchr",
+]
+
 [[package]]
 name = "oco_ref"
 version = "0.2.0"
@@ -1244,6 +1327,12 @@ dependencies = [
  "thiserror 2.0.12",
 ]
 
+[[package]]
+name = "rustc-demangle"
+version = "0.1.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
+
 [[package]]
 name = "rustc-hash"
 version = "2.1.1"
@@ -1288,18 +1377,29 @@ dependencies = [
 
 [[package]]
 name = "serde"
-version = "1.0.218"
+version = "1.0.219"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60"
+checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
 dependencies = [
  "serde_derive",
 ]
 
 [[package]]
-name = "serde_derive"
-version = "1.0.218"
+name = "serde-wasm-bindgen"
+version = "0.6.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b"
+checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
+dependencies = [
+ "js-sys",
+ "serde",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.219"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -1549,6 +1649,16 @@ dependencies = [
  "zerovec",
 ]
 
+[[package]]
+name = "tokio"
+version = "1.44.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9975ea0f48b5aa3972bf2d888c238182458437cc2a19374b81b25cdf1023fb3a"
+dependencies = [
+ "backtrace",
+ "pin-project-lite",
+]
+
 [[package]]
 name = "toml"
 version = "0.8.20"
diff --git a/Cargo.toml b/Cargo.toml
index a0d7e12..fa8b96e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,5 +6,11 @@ edition = "2021"
 [dependencies]
 console_error_panic_hook = "0.1.7"
 dedent = "0.1.1"
+futures = "0.3.31"
+idb = "0.6.4"
 leptos = { version = "0.7.7", features = ["csr"] }
+serde = { version = "1.0.219", features = ["derive"] }
+serde-wasm-bindgen = "0.6.5"
+serde_json = "1.0.140"
 thiserror = "2.0.12"
+url = "2.5.4"
diff --git a/src/components.rs b/src/components.rs
new file mode 100644
index 0000000..b4ba26a
--- /dev/null
+++ b/src/components.rs
@@ -0,0 +1 @@
+pub mod card;
diff --git a/src/components/card.rs b/src/components/card.rs
new file mode 100644
index 0000000..929ec38
--- /dev/null
+++ b/src/components/card.rs
@@ -0,0 +1,15 @@
+use leptos::prelude::*;
+
+use crate::utils::card_database::CardDetails;
+
+#[component]
+pub fn Card(details: Option<CardDetails>) -> impl IntoView {
+    match details {
+        Some(details) => view! {
+            <p>{details.name}</p>
+            <span style="white-space: pre-line">{details.description}</span>
+        }
+        .into_any(),
+        None => view! { <p>"Placeholder"</p> }.into_any(),
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 58a3ab2..17388d8 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1 +1,3 @@
+pub mod components;
 pub mod draft_list;
+pub mod utils;
diff --git a/src/main.rs b/src/main.rs
index c0cfc82..9e3bc2f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,6 +1,32 @@
+use draft_manager::{components::card::Card, utils::card_database::CardDatabase};
 use leptos::prelude::*;
 
 fn main() {
     console_error_panic_hook::set_once();
-    leptos::mount::mount_to_body(|| view! { <p>"Hello, world!"</p> })
+
+    leptos::mount::mount_to_body(|| view! { <App /> });
+}
+
+#[component]
+fn App() -> impl IntoView {
+    let card = LocalResource::new(|| async move {
+        let database = CardDatabase::open().await?;
+        database.load_data().await?;
+
+        database.get_card(1861629).await
+    });
+
+    view! {
+        <Transition fallback=move || {
+            view! { <p>"Loading database..."</p> }
+        }>
+            {move || {
+                card.read()
+                    .as_deref()
+                    .and_then(|card| {
+                        view! { <Card details=card.as_ref().unwrap().clone() /> }.into()
+                    })
+            }}
+        </Transition>
+    }
 }
diff --git a/src/utils.rs b/src/utils.rs
new file mode 100644
index 0000000..c73983c
--- /dev/null
+++ b/src/utils.rs
@@ -0,0 +1 @@
+pub mod card_database;
diff --git a/src/utils/card_database.rs b/src/utils/card_database.rs
new file mode 100644
index 0000000..96998eb
--- /dev/null
+++ b/src/utils/card_database.rs
@@ -0,0 +1,145 @@
+use std::{cell::RefCell, rc::Rc};
+
+use idb::{
+    event::VersionChangeEvent, Database, DatabaseEvent, Factory, KeyPath, ObjectStoreParams, Query,
+    TransactionMode,
+};
+
+use serde::{Deserialize, Serialize};
+use serde_wasm_bindgen::Serializer;
+use thiserror::Error;
+
+#[derive(Clone, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum FrameType {
+    Normal,
+    Effect,
+    Ritual,
+    Fusion,
+    Synchro,
+    Xyz,
+    Link,
+    NormalPendulum,
+    EffectPendulum,
+    RitualPendulum,
+    FusionPendulum,
+    SynchroPendulum,
+    XyzPendulum,
+    Spell,
+    Trap,
+    Token,
+}
+
+#[derive(Clone, Deserialize)]
+pub struct CardDetails {
+    pub password: u32,
+
+    pub name: String,
+    pub description: String,
+
+    pub frame: FrameType,
+    pub image: String,
+}
+
+pub struct CardDatabase(Database);
+
+const DB_VERSION: u32 = 1;
+
+impl CardDatabase {
+    pub async fn open() -> Result<Self> {
+        let factory = Factory::new()?;
+        let mut open_request = factory.open("cards", Some(DB_VERSION))?;
+
+        let err = Rc::new(RefCell::new(Ok(())));
+        let moved_err = err.clone(); // Will be moved inside closure
+        open_request.on_upgrade_needed(move |event| {
+            let res = Self::upgrade_database(event);
+
+            if let Err(e) = res {
+                *std::cell::RefCell::<_>::borrow_mut(&moved_err) = Err(e);
+                drop(moved_err);
+            }
+        });
+
+        let database = open_request.await?;
+        // Get the error if there is one - note that if the Rc has a
+        // strong count > 1 and therefore Symbol’s value as variable is void: into_inner returns Symbol’s value as variable is void: None,
+        // this means that the callback was not called, and as such
+        // there cannot have been an error.
+        let res = Rc::into_inner(err).unwrap_or(Ok(()).into()).into_inner();
+
+        res.map(|_| Self(database))
+    }
+
+    pub async fn get_card(&self, key: u32) -> Result<Option<CardDetails>> {
+        let transaction = self.0.transaction(&["cards"], TransactionMode::ReadOnly)?;
+        let store = transaction.object_store("cards")?;
+        let card = store.get(Query::Key(key.into()))?.await?;
+        Ok(card.map(serde_wasm_bindgen::from_value).transpose()?)
+    }
+
+    pub async fn load_data(&self) -> Result<()> {
+        let transaction = self.0.transaction(&["cards"], TransactionMode::ReadWrite)?;
+        let store = transaction.object_store("cards")?;
+
+        // Add some test data for now
+        let decode_talker = serde_json::json!({
+            "password": 1861629,
+            "name": "Decode Talker",
+            "description": "2+ Effect Monsters\r\nGains 500 ATK for each monster it points to. When your opponent activates a card or effect that targets a card(s) you control (Quick Effect): You can Tribute 1 monster this card points to; negate the activation, and if you do, destroy that card.",
+            "frame": "link",
+            "image": "https://images.ygoprodeck.com/images/cards/1861629.jpg"
+        });
+
+        store
+            .put(
+                &decode_talker.serialize(&Serializer::json_compatible())?,
+                None,
+            )?
+            .await?;
+
+        transaction.commit()?.await?;
+
+        Ok(())
+    }
+
+    /// Upgrade the database; this is called by the browser whenever
+    /// we try to open a newer database version.
+    fn upgrade_database(event: VersionChangeEvent) -> Result<()> {
+        let database = event.database()?;
+
+        let upgrade_from = event.old_version()?;
+        match upgrade_from {
+            // This version number signals that the database
+            // hasn't been created before.
+            0 => {
+                let mut store_params = ObjectStoreParams::new();
+                store_params.key_path(Some(KeyPath::new_single("password")));
+
+                let store = database.create_object_store("cards", store_params)?;
+
+                store.create_index(
+                    "fulltext",
+                    KeyPath::new_array(vec!["password", "name", "description"]),
+                    None,
+                )?;
+            }
+            other => return Err(CardDatabaseError::UnknownDatabaseVersion(other)),
+        };
+
+        Ok(())
+    }
+}
+
+#[derive(Debug, Error)]
+pub enum CardDatabaseError {
+    #[error(
+        "unknown card database version `{0}`, please close the tab and re-open the application"
+    )]
+    UnknownDatabaseVersion(u32),
+    #[error("card database operation failed; does the browser support IndexedDB?")]
+    BrowserError(#[from] idb::Error),
+    #[error("invalid test data")]
+    SerdeError(#[from] serde_wasm_bindgen::Error),
+}
+type Result<T> = std::result::Result<T, CardDatabaseError>;