diff --git a/Cargo.toml b/Cargo.toml
index b4ba7ebb..f5a3efbc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -7,5 +7,6 @@ members = [
]
exclude = [
"examples/playground",
- "examples/tuono"
+ "examples/tuono",
+ "examples/tutorial"
]
diff --git a/examples/tutorial/.gitignore b/examples/tutorial/.gitignore
new file mode 100644
index 00000000..071acad0
--- /dev/null
+++ b/examples/tutorial/.gitignore
@@ -0,0 +1,13 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+.tuono
+out
+target
diff --git a/examples/tutorial/Cargo.toml b/examples/tutorial/Cargo.toml
new file mode 100644
index 00000000..5ba88e75
--- /dev/null
+++ b/examples/tutorial/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "tuono"
+version = "0.0.1"
+edition = "2021"
+
+[[bin]]
+name = "tuono"
+path = ".tuono/main.rs"
+
+[dependencies]
+axum = {version = "0.7.5", features = ["json"]}
+tokio = { version = "1.37.0", features = ["full"] }
+tower-http = {version = "0.5.2", features = ["fs"]}
+serde = { version = "1.0.202", features = ["derive"] }
+tuono_lib = { path = "../../crates/tuono_lib/"}
+serde_json = "1.0"
+reqwest = {version = "0.12.4", features = ["json"]}
+
diff --git a/examples/tutorial/README.md b/examples/tutorial/README.md
new file mode 100644
index 00000000..72b0fe6d
--- /dev/null
+++ b/examples/tutorial/README.md
@@ -0,0 +1,9 @@
+# Tuono starter
+
+This is the starter tuono project. To download it run in your terminal:
+
+```shell
+$ tuono new [NAME]
+```
+
+
diff --git a/examples/tutorial/package.json b/examples/tutorial/package.json
new file mode 100644
index 00000000..b8745ca3
--- /dev/null
+++ b/examples/tutorial/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "tuono-tutorial",
+ "description": "The react/rust fullstack framework",
+ "version": "0.0.1",
+ "dependencies": {
+ "react": "18.3.1",
+ "react-dom": "18.3.1",
+ "tuono": "link:../../packages/tuono"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.3",
+ "@types/react-dom": "^18.3.0",
+ "typescript": "^5.4.5"
+ }
+}
diff --git a/examples/tutorial/public/favicon.ico b/examples/tutorial/public/favicon.ico
new file mode 100644
index 00000000..d03bcc66
Binary files /dev/null and b/examples/tutorial/public/favicon.ico differ
diff --git a/examples/tutorial/public/react.svg b/examples/tutorial/public/react.svg
new file mode 100644
index 00000000..8fe604f2
--- /dev/null
+++ b/examples/tutorial/public/react.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/examples/tutorial/public/rust.svg b/examples/tutorial/public/rust.svg
new file mode 100644
index 00000000..7f66780e
--- /dev/null
+++ b/examples/tutorial/public/rust.svg
@@ -0,0 +1,4 @@
+
+
diff --git a/examples/tutorial/src/components/PokemonLink.module.css b/examples/tutorial/src/components/PokemonLink.module.css
new file mode 100644
index 00000000..d134c987
--- /dev/null
+++ b/examples/tutorial/src/components/PokemonLink.module.css
@@ -0,0 +1,26 @@
+.link {
+ width: 100%;
+ max-width: 216px;
+ position: relative;
+ background: white;
+ margin-bottom: 10px;
+ border: solid #f0f0f0 1px;
+ text-decoration: none;
+ color: black;
+ padding: 5px 5px 5px 15px;
+ border-radius: 10px;
+ display: flex;
+ justify-content: space-between;
+ transition: 0.2s;
+ align-items: center;
+}
+
+.link:hover {
+ box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px;
+}
+
+.link img {
+ width: 70px;
+ background: white;
+ border-radius: 50%;
+}
diff --git a/examples/tutorial/src/components/PokemonLink.tsx b/examples/tutorial/src/components/PokemonLink.tsx
new file mode 100644
index 00000000..4e7c8b9f
--- /dev/null
+++ b/examples/tutorial/src/components/PokemonLink.tsx
@@ -0,0 +1,23 @@
+import { Link } from 'tuono'
+import styles from './PokemonLink.module.css'
+
+interface Pokemon {
+ name: string
+}
+
+export default function PokemonLink({
+ pokemon,
+ id,
+}: {
+ pokemon: Pokemon
+ id: number
+}): JSX.Element {
+ return (
+
+ {pokemon.name}
+
+
+ )
+}
diff --git a/examples/tutorial/src/components/PokemonView.module.css b/examples/tutorial/src/components/PokemonView.module.css
new file mode 100644
index 00000000..9b8f6f70
--- /dev/null
+++ b/examples/tutorial/src/components/PokemonView.module.css
@@ -0,0 +1,38 @@
+.back-btn {
+ background-color: white;
+ border-radius: 10px;
+ padding: 7px 15px;
+ color: black;
+ text-decoration: none;
+ border: solid #f0f0f0 1px;
+ font-size: 20px;
+}
+
+.back-btn:hover {
+ box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px;
+}
+
+.pokemon {
+ display: flex;
+ justify-content: space-between;
+ margin-top: 20px;
+}
+
+.name {
+ font-size: 50px;
+ font-weight: 700;
+}
+
+.pokemon img {
+ width: 400px;
+}
+
+.spec {
+ display: flex;
+ font-size: 18px;
+ margin-top: 10px;
+}
+
+.label {
+ font-weight: 700;
+}
diff --git a/examples/tutorial/src/components/PokemonView.tsx b/examples/tutorial/src/components/PokemonView.tsx
new file mode 100644
index 00000000..0f643aab
--- /dev/null
+++ b/examples/tutorial/src/components/PokemonView.tsx
@@ -0,0 +1,41 @@
+import { Link } from 'tuono'
+import styles from './PokemonView.module.css'
+
+interface Pokemon {
+ name: string
+ id: string
+ weight: number
+ height: number
+}
+
+export default function PokemonView({
+ pokemon,
+}: {
+ pokemon?: Pokemon
+}): JSX.Element {
+ return (
+
+
+ Back
+
+ {pokemon?.name && (
+
+
+
{pokemon.name}
+
+ - Weight:
+ - {pokemon.weight}lbs
+
+
+ - Height:
+ - {pokemon.height}ft
+
+
+

+
+ )}
+
+ )
+}
diff --git a/examples/tutorial/src/routes/__root.tsx b/examples/tutorial/src/routes/__root.tsx
new file mode 100644
index 00000000..1a39e91f
--- /dev/null
+++ b/examples/tutorial/src/routes/__root.tsx
@@ -0,0 +1,9 @@
+import type { ReactNode } from 'react'
+
+interface RootRouteProps {
+ children: ReactNode
+}
+
+export default function RootRoute({ children }: RootRouteProps): JSX.Element {
+ return {children}
+}
diff --git a/examples/tutorial/src/routes/index.rs b/examples/tutorial/src/routes/index.rs
new file mode 100644
index 00000000..25649768
--- /dev/null
+++ b/examples/tutorial/src/routes/index.rs
@@ -0,0 +1,27 @@
+// src/routes/index.rs
+use serde::{Deserialize, Serialize};
+use tuono_lib::{Request, Response};
+
+const ALL_POKEMON: &str = "https://pokeapi.co/api/v2/pokemon?limit=151";
+
+#[derive(Debug, Serialize, Deserialize)]
+struct Pokemons {
+ results: Vec,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+struct Pokemon {
+ name: String,
+ url: String,
+}
+
+#[tuono_lib::handler]
+async fn get_all_pokemons(_req: Request<'_>, fetch: reqwest::Client) -> Response {
+ return match fetch.get(ALL_POKEMON).send().await {
+ Ok(res) => {
+ let data = res.json::().await.unwrap();
+ Response::Props(Box::new(data))
+ }
+ Err(_err) => Response::Props(Box::new(Pokemons { results: vec![] })),
+ };
+}
diff --git a/examples/tutorial/src/routes/index.tsx b/examples/tutorial/src/routes/index.tsx
new file mode 100644
index 00000000..25105096
--- /dev/null
+++ b/examples/tutorial/src/routes/index.tsx
@@ -0,0 +1,46 @@
+// src/routes/index.tsx
+import type { TuonoProps } from 'tuono'
+import PokemonLink from '../components/PokemonLink'
+
+interface Pokemon {
+ name: string
+}
+
+interface IndexProps {
+ results: Pokemon[]
+}
+
+export default function IndexPage({
+ data,
+}: TuonoProps): JSX.Element {
+ if (!data?.results) {
+ return <>>
+ }
+
+ return (
+ <>
+
+
+
+ TUONO
+
+
+

+

+
+
+
+ {data.results.map((pokemon, i) => {
+ return
+ })}
+
+ >
+ )
+}
diff --git a/examples/tutorial/src/routes/pokemons/[pokemon].rs b/examples/tutorial/src/routes/pokemons/[pokemon].rs
new file mode 100644
index 00000000..1251e91e
--- /dev/null
+++ b/examples/tutorial/src/routes/pokemons/[pokemon].rs
@@ -0,0 +1,31 @@
+use serde::{Deserialize, Serialize};
+use tuono_lib::{Request, Response};
+
+const POKEMON_API: &str = "https://pokeapi.co/api/v2/pokemon";
+
+#[derive(Debug, Serialize, Deserialize)]
+struct Pokemon {
+ name: String,
+ id: u16,
+ weight: u16,
+ height: u16,
+}
+
+#[tuono_lib::handler]
+async fn get_pokemon(req: Request<'_>, fetch: reqwest::Client) -> Response {
+ // The param `pokemon` is defined in the route filename [pokemon].rs
+ let pokemon = req.params.get("pokemon").unwrap();
+
+ return match fetch.get(format!("{POKEMON_API}/{pokemon}")).send().await {
+ Ok(res) => {
+ let data = res.json::().await.unwrap();
+ Response::Props(Box::new(data))
+ }
+ Err(_err) => Response::Props(Box::new(Pokemon {
+ name: "Nope".to_string(),
+ id: 0,
+ weight: 0,
+ height: 0,
+ })),
+ };
+}
diff --git a/examples/tutorial/src/routes/pokemons/[pokemon].tsx b/examples/tutorial/src/routes/pokemons/[pokemon].tsx
new file mode 100644
index 00000000..7cf77395
--- /dev/null
+++ b/examples/tutorial/src/routes/pokemons/[pokemon].tsx
@@ -0,0 +1,6 @@
+import { TuonoProps } from 'tuono'
+import PokemonView from '../../components/PokemonView'
+
+export default function Pokemon({ data }: TuonoProps): JSX.Element {
+ return
+}
diff --git a/examples/tutorial/src/styles/global.css b/examples/tutorial/src/styles/global.css
new file mode 100644
index 00000000..84fc0842
--- /dev/null
+++ b/examples/tutorial/src/styles/global.css
@@ -0,0 +1,109 @@
+@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
+
+@keyframes rotate {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ font-family: 'Poppins', sans-serif;
+ font-weight: 400;
+ font-style: normal;
+}
+
+body {
+ background: #fbfbfb;
+}
+
+main {
+ width: 673px;
+ margin: 20px auto;
+}
+
+.header {
+ display: flex;
+ gap: 20px;
+}
+
+.header a {
+ color: black;
+ font-size: 18px;
+ font-weight: 600;
+ text-decoration: none;
+ z-index: 2;
+}
+
+.title-wrap {
+ height: 200px;
+}
+
+.title {
+ position: absolute;
+ font-size: 200px;
+ line-height: 200px;
+ z-index: 0;
+ letter-spacing: -2px;
+ margin-left: -8px;
+ user-select: none;
+ pointer-events: none;
+}
+
+.title span {
+ opacity: 0;
+}
+
+.button {
+ width: 140px;
+ height: 30px;
+ border: solid 3px black;
+ border-radius: 10px;
+ color: black;
+ text-decoration: none;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ transition: 0.2s;
+}
+
+.button:hover {
+ color: white;
+ background: black;
+}
+
+.logo {
+ margin-left: 240px;
+ position: relative;
+ top: 25px;
+}
+
+.logo img {
+ position: absolute;
+}
+
+.rust {
+ animation: rotate 6s ease-in-out infinite;
+}
+
+.react {
+ top: 33px;
+ left: 28px;
+ animation: rotate 6s linear infinite reverse;
+}
+
+.subtitle {
+ font-size: 30px;
+ line-height: 30px;
+ letter-spacing: -1px;
+}
+
+.subtitle-wrap {
+ display: flex;
+ justify-content: space-between;
+}
diff --git a/examples/tutorial/tsconfig.json b/examples/tutorial/tsconfig.json
new file mode 100644
index 00000000..a7fc6fbf
--- /dev/null
+++ b/examples/tutorial/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/examples/tutorial/tsconfig.node.json b/examples/tutorial/tsconfig.node.json
new file mode 100644
index 00000000..97ede7ee
--- /dev/null
+++ b/examples/tutorial/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true
+ },
+ "include": ["vite.config.ts"]
+}