├── .gitignore ├── .idea ├── .gitignore ├── hono-boilerplate.iml ├── modules.xml └── vcs.xml ├── LICENSE ├── README.md ├── bun.lockb ├── index.ts ├── package.json ├── src ├── controllers │ ├── AuthController.ts │ └── CountryController.ts ├── db │ └── supabaseClient.ts ├── helpers.ts ├── middlewares │ └── authMiddleware.ts └── routes.ts ├── tsconfig.json └── wrangler.toml /.gitignore: -------------------------------------------------------------------------------- 1 | # prod 2 | dist/ 3 | 4 | # dev 5 | .yarn/ 6 | !.yarn/releases 7 | .vscode/* 8 | !.vscode/launch.json 9 | !.vscode/*.code-snippets 10 | .idea/ 11 | .idea/* 12 | # deps 13 | node_modules/ 14 | .wrangler 15 | wrangler 16 | 17 | # env 18 | .env 19 | .env.production 20 | .dev.vars 21 | wrangler.toml 22 | # logs 23 | logs/ 24 | *.log 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | pnpm-debug.log* 29 | lerna-debug.log* 30 | 31 | # misc 32 | .DS_Store 33 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/hono-boilerplate.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Göktuğ Ceyhan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🔥 Hono + ⚡️ Supabase Boilerplate 2 | 3 | This project is a backend boilerplate built using the Hono framework. Cloudfare Workers are used to host the backend. It includes integration with Supabase and handles user authentication and authorization. 4 | 5 | [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 6 | [![MIT License](https://img.shields.io/badge/License-MIT-green.svg)](https://github.com/goktugcy/hono-boilerplate/blob/main/LICENSE) 7 | ![GitHub Created At](https://img.shields.io/github/created-at/goktugcy/hono-boilerplate) 8 | ![GitHub Repo stars](https://img.shields.io/github/stars/goktugcy/hono-boilerplate?style=flat) 9 | ![GitHub forks](https://img.shields.io/github/forks/goktugcy/hono-boilerplate?style=flat) 10 | 11 | ## Installation 🚀 12 | 13 | After cloning the repository, install the necessary dependencies by running: 14 | 15 | ```sh 16 | bun install 17 | ``` 18 | 19 | ## Configuration ✨ 20 | 21 | Edit the `.wrangler.toml` file to include the necessary environment variables: 22 | 23 | ```sh 24 | STAGE = "dev" # dev or prod 25 | # Supabase 26 | SUPABASE_URL= 27 | SUPABASE_SERVICE_KEY= 28 | SUPABASE_ANON_KEY= 29 | 30 | ``` 31 | 32 | ## Structure 🎄 33 | 34 | ``` 35 | . 36 | ├── src 37 | │ ├── controllers 38 | │ │ ├── AuthController.ts 39 | │ │ ├── CountryController.ts 40 | │ ├── middlewares 41 | │ │ ├── authMiddleware.ts 42 | │ ├── db 43 | │ │ ├── supabaseClient.ts 44 | │ ├── routes.ts 45 | │ ├── helpers.ts 46 | ├── index.ts 47 | ├── wrangler.toml 48 | ├── package.json 49 | ├── README.md 50 | ├── tsconfig.json 51 | ├── .gitignore 52 | ├── bun.lockb 53 | 54 | ``` 55 | 56 | ## Usage 🍻 57 | 58 | To start the server, run: 59 | 60 | ```sh 61 | bun dev 62 | ``` 63 | 64 | Deploy the server to Cloudfare Workers by running: 65 | 66 | ```sh 67 | bun run deploy 68 | ``` 69 | 70 | ## Authentication 🛡 71 | 72 | The boilerplate includes an authentication middleware that checks if the user is authenticated. The middleware is used in the routes.ts file to protect routes that require authentication. 73 | 74 | ```ts 75 | import { authMiddleware } from './middlewares/authMiddleware'; 76 | 77 | router.get('/countries', authMiddleware, CountryController.index); 78 | ``` 79 | #### Token Extraction: The middleware extracts the accessToken and refreshToken from the request cookies. 80 | 81 | #### Token Verification: 82 | * If the accessToken is expired but the refreshToken is valid, it requests a new accessToken using the refreshToken. 83 | * If both tokens are missing, it responds with an error. 84 | * If the accessToken is present, it verifies the token using Supabase's authentication service. 85 | 86 | ## Supabase Client Usage ⚡️ 87 | 88 | The project uses two Supabase clients: `supabaseAnon` and `supabaseService`. These clients are created in the [`getSupabaseClient`](src/db/supabaseClient.ts) function in [src/db/supabaseClient.ts](src/db/supabaseClient.ts). 89 | 90 | ### `supabaseAnon` 91 | 92 | The `supabaseAnon` client is used for operations that do not require elevated privileges. It is created using the anonymous key (`SUPABASE_ANON_KEY`) and is configured to not automatically refresh tokens, persist sessions, or detect sessions in URLs. 93 | 94 | Example usage: 95 | ```ts 96 | import { getSupabaseClient } from "./src/db/supabaseClient"; 97 | 98 | const getCountries = async (c: Context) => { 99 | const { supabaseAnon } = getSupabaseClient(c); 100 | let { data: countries, error } = await supabaseAnon.from("countries").select("*"); 101 | 102 | if (error) { 103 | return c.json({ error: error.message }, 400); 104 | } 105 | 106 | return c.json(countries, 200); 107 | }; 108 | ``` 109 | 110 | ### `supabaseService` 111 | 112 | The `supabaseService` client is used for operations that require elevated privileges, such as authentication and user management. It is created using the service key (`SUPABASE_SERVICE_KEY`). 113 | 114 | Example usage: 115 | ```ts 116 | import { getSupabaseClient } from "./src/db/supabaseClient"; 117 | 118 | const login = async (c: Context) => { 119 | const { supabaseService } = getSupabaseClient(c); 120 | const { email, password } = await c.req.json<{ email: string; password: string }>(); 121 | 122 | const { data, error } = await supabaseService.auth.signInWithPassword({ 123 | email, 124 | password, 125 | }); 126 | 127 | if (error) { 128 | return c.json({ error: error.message }, 400); 129 | } 130 | 131 | const accessToken = data.session?.access_token; 132 | const refreshToken = data.session?.refresh_token; 133 | 134 | if (!accessToken || !refreshToken) { 135 | return c.json({ error: "Token creation failed" }, 500); 136 | } 137 | 138 | setAuthCookies(c, accessToken, refreshToken); 139 | 140 | return c.json({ message: "Login successful", accessToken, refreshToken }, 200); 141 | }; 142 | ``` 143 | 144 | ## License 145 | 146 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goktugcy/hono-boilerplate/09e9d8775aab02aafe60bf5f7c0805d2ab906acd/bun.lockb -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { logger } from "hono/logger"; 3 | import { prettyJSON } from "hono/pretty-json"; 4 | import { cors } from "hono/cors"; 5 | import routes from "./src/routes"; 6 | 7 | // Initialize Hono app 8 | const app = new Hono(); 9 | 10 | // Cors Middleware 11 | app.use( 12 | "*", 13 | cors({ 14 | origin: "*", 15 | allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], 16 | }) 17 | ); 18 | 19 | // Initialize middlewares 20 | app.use("*", logger(), prettyJSON()); 21 | 22 | // default route 23 | app.get("/", async (c) => { 24 | return c.json({ message: "Welcome to Hono" }, 200); 25 | }); 26 | 27 | // Initialize routes 28 | app.route("/", routes); 29 | 30 | export default app; 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hono-boilerplate", 3 | "scripts": { 4 | "dev": "wrangler dev", 5 | "deploy": "wrangler deploy --minify" 6 | }, 7 | "dependencies": { 8 | "@supabase/supabase-js": "^2.49.1", 9 | "hono": "^4.7.2" 10 | }, 11 | "devDependencies": { 12 | "@cloudflare/workers-types": "^4.20250224.0", 13 | "wrangler": "^3.109.3" 14 | }, 15 | "packageManager": "pnpm@9.10.0+sha512.73a29afa36a0d092ece5271de5177ecbf8318d454ecd701343131b8ebc0c1a91c487da46ab77c8e596d6acf1461e3594ced4becedf8921b074fbd8653ed7051c" 16 | } 17 | -------------------------------------------------------------------------------- /src/controllers/AuthController.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { getSupabaseClient } from "../db/supabaseClient"; 3 | import { deleteCookie } from "hono/cookie"; 4 | import { setAuthCookies } from "../helpers"; 5 | 6 | const login = async (c: Context) => { 7 | const { supabaseService } = getSupabaseClient(c); 8 | const { email, password } = await c.req.json<{ 9 | email: string; 10 | password: string; 11 | }>(); 12 | 13 | const { data, error } = await supabaseService.auth.signInWithPassword({ 14 | email, 15 | password, 16 | }); 17 | 18 | if (error) { 19 | return c.json({ error: error.message }, 400); 20 | } 21 | 22 | const accessToken = data.session?.access_token; 23 | const refreshToken = data.session?.refresh_token; 24 | 25 | if (!accessToken || !refreshToken) { 26 | return c.json({ error: "Token creation failed 😗" }, 500); 27 | } 28 | 29 | setAuthCookies(c, accessToken, refreshToken); 30 | 31 | return c.json( 32 | { message: "Login successful ⚡️", accessToken, refreshToken }, 33 | 200 34 | ); 35 | }; 36 | 37 | const register = async (c: Context) => { 38 | const { supabaseAnon } = getSupabaseClient(c); 39 | const { email, password } = await c.req.json<{ 40 | email: string; 41 | password: string; 42 | }>(); 43 | 44 | const { data, error } = await supabaseAnon.auth.signUp({ 45 | email: email, 46 | password: password, 47 | }); 48 | 49 | if (error) { 50 | return c.json({ error: error.message }, 400); 51 | } 52 | 53 | return c.json({ message: "User registered successfully 🍻" }, 201); 54 | }; 55 | 56 | const logout = async (c: Context) => { 57 | await getSupabaseClient(c).supabaseAnon.auth.signOut(); 58 | deleteCookie(c, "accessToken"); 59 | deleteCookie(c, "refreshToken"); 60 | return c.json({ message: "Logout successful 🍻" }, 200); 61 | }; 62 | 63 | export { login, register, logout }; 64 | -------------------------------------------------------------------------------- /src/controllers/CountryController.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { getSupabaseClient } from "../db/supabaseClient"; 3 | 4 | const getCountries = async (c: Context) => { 5 | let { data: countries, error } = await getSupabaseClient(c) 6 | .supabaseAnon.from("countries") 7 | .select("id, name, iso2, iso3, local_name, continent"); 8 | 9 | if (error) { 10 | return c.json({ error: error.message }, 400); 11 | } 12 | 13 | return c.json(countries, 200); 14 | }; 15 | 16 | const getCountry = async (c: Context) => { 17 | const { id } = c.req.param(); 18 | let { data: country, error } = await getSupabaseClient(c) 19 | .supabaseAnon.from("countries") 20 | .select("id, name, iso2, iso3, local_name, continent") 21 | .eq("id", id) 22 | .single(); 23 | 24 | if (error) { 25 | return c.json({ error: error.message }, 400); 26 | } 27 | 28 | return c.json(country, 200); 29 | }; 30 | 31 | export { getCountries, getCountry }; 32 | -------------------------------------------------------------------------------- /src/db/supabaseClient.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@supabase/supabase-js"; 2 | import { Context } from "hono"; 3 | 4 | const getSupabaseClient = (c: Context) => { 5 | const supabaseUrl = c.env.SUPABASE_URL as string; 6 | const supabaseKey = c.env.SUPABASE_ANON_KEY as string; 7 | const serviceKey = c.env.SUPABASE_SERVICE_KEY as string; 8 | 9 | const supabaseService = createClient(supabaseUrl, serviceKey); 10 | 11 | const supabaseAnon = createClient(supabaseUrl, supabaseKey, { 12 | auth: { 13 | autoRefreshToken: false, 14 | persistSession: false, 15 | detectSessionInUrl: false, 16 | }, 17 | }); 18 | 19 | return { supabaseAnon, supabaseService }; 20 | }; 21 | 22 | export type SupabaseClients = ReturnType; 23 | 24 | export { getSupabaseClient }; 25 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { setCookie } from "hono/cookie"; 3 | 4 | export const setAuthCookies = ( 5 | c: Context, 6 | accessToken: string, 7 | refreshToken: string 8 | ) => { 9 | setCookie(c, "accessToken", accessToken, { 10 | httpOnly: true, 11 | secure: c.env.STAGE === "production", 12 | sameSite: "Strict", 13 | maxAge: 60 * 60, // 1 hour 14 | }); 15 | 16 | setCookie(c, "refreshToken", refreshToken, { 17 | httpOnly: true, 18 | secure: c.env.STAGE === "production", 19 | sameSite: "Strict", 20 | maxAge: 60 * 60 * 24 * 30, // 30 days 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /src/middlewares/authMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { Context, Next } from "hono"; 2 | import { getSupabaseClient } from "../db/supabaseClient"; 3 | import { getCookie } from "hono/cookie"; 4 | import { setAuthCookies } from "../helpers"; 5 | 6 | export const authMiddleware = async (c: Context, next: Next) => { 7 | const { supabaseAnon, supabaseService } = getSupabaseClient(c); 8 | 9 | const accessToken = getCookie(c, "accessToken"); 10 | const refreshToken = getCookie(c, "refreshToken"); 11 | 12 | if (!accessToken && refreshToken) { 13 | // A new access token is requested using the refresh token 14 | const { data, error } = await supabaseService.auth.refreshSession({ 15 | refresh_token: refreshToken, 16 | }); 17 | 18 | if (error || !data.session) { 19 | return c.json({ error: "Session refresh failed" }, 401); 20 | } 21 | 22 | // Set the new access token and refresh token 23 | setAuthCookies(c, data.session.access_token, data.session.refresh_token); 24 | 25 | c.set("user", data.user); 26 | } else if (!accessToken || !refreshToken) { 27 | return c.json({ error: "Authorization tokens missing" }, 401); 28 | } else { 29 | const { data, error } = await supabaseAnon.auth.getUser(accessToken); 30 | 31 | if (error || !data.user) { 32 | return c.json({ error: "Unauthorized" }, 401); 33 | } 34 | 35 | c.set("user", data.user); 36 | } 37 | 38 | await next(); 39 | }; 40 | -------------------------------------------------------------------------------- /src/routes.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { login, register, logout } from "./controllers/AuthController"; 3 | import { authMiddleware } from "./middlewares/authMiddleware"; 4 | import { getCountries, getCountry } from "./controllers/CountryController"; 5 | const routes = new Hono().basePath("/api/v1"); 6 | // Auth routes 7 | routes.post("/login", login); 8 | routes.post("/register", register); 9 | routes.post("/logout", logout); 10 | 11 | // Protected route with authentication 12 | routes.get("/countries", authMiddleware, getCountries); 13 | routes.get("/countries/:id", authMiddleware, getCountry); 14 | 15 | routes.get("/protected", authMiddleware, async (c) => { 16 | return c.json({ message: "This is a protected route" }, 200); 17 | }); 18 | 19 | export default routes; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "Bundler", 6 | "strict": true, 7 | "skipLibCheck": true, 8 | "lib": [ 9 | "ESNext" 10 | ], 11 | "types": [ 12 | "@cloudflare/workers-types/2023-07-01" 13 | ], 14 | "jsx": "react-jsx", 15 | "jsxImportSource": "hono/jsx" 16 | }, 17 | } -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "hono-boilerplate" 2 | main = "./index.ts" 3 | compatibility_date = "2024-11-24" 4 | 5 | # compatibility_flags = [ "nodejs_compat" ] 6 | 7 | 8 | [vars] 9 | STAGE = "dev" 10 | SUPABASE_URL = "YOUR_SUPABASE_URL" 11 | SUPABASE_SERVICE_KEY = "YOUR_SUPABASE_SERVICE_KEY" 12 | SUPABASE_ANON_KEY = "YOUR_SUPABASE_ANON_KEY" 13 | 14 | # [[kv_namespaces]] 15 | # binding = "MY_KV_NAMESPACE" 16 | # id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 17 | 18 | # [[r2_buckets]] 19 | # binding = "MY_BUCKET" 20 | # bucket_name = "my-bucket" 21 | 22 | # [[d1_databases]] 23 | # binding = "DB" 24 | # database_name = "hono-boilerplate" 25 | # database_id = "cb25a0d9-0eaa-49b0-9cde-beff6b904e7f" 26 | 27 | # [ai] 28 | # binding = "AI" 29 | 30 | # [observability] 31 | # enabled = true 32 | # head_sampling_rate = 1 --------------------------------------------------------------------------------