├── .eslintrc.json ├── src ├── app │ ├── globals.css │ ├── favicon.ico │ ├── page.tsx │ ├── layout.tsx │ └── analytics │ │ └── page.tsx ├── lib │ └── redis.ts ├── utils │ ├── index.ts │ └── analytics.ts ├── middleware.ts └── components │ └── AnalyticsDashboard.tsx ├── next.config.mjs ├── postcss.config.js ├── .gitignore ├── public ├── vercel.svg └── next.svg ├── tsconfig.json ├── package.json ├── README.md └── tailwind.config.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joschan21/next-analytics/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /src/lib/redis.ts: -------------------------------------------------------------------------------- 1 | import { Redis } from '@upstash/redis' 2 | 3 | export const redis = new Redis({ 4 | url: 'https://eu2-lucky-goose-31276.upstash.io', 5 | token: process.env.REDIS_KEY!, 6 | }) -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Home() { 2 | // IMPORTANT: This analytics dashboard is compatible with any app, your main app lives here! 3 | 4 | return
This is an example
5 | } 6 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { format, subDays } from 'date-fns' 2 | 3 | export const getDate = (sub: number = 0) => { 4 | const dateXDaysAgo = subDays(new Date(), sub) 5 | 6 | return format(dateXDaysAgo, 'dd/MM/yyyy') 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server' 2 | import { analytics } from './utils/analytics' 3 | 4 | export default async function middleware(req: NextRequest) { 5 | if (req.nextUrl.pathname === '/') { 6 | try { 7 | await analytics.track('pageview', { 8 | page: '/', 9 | country: req.geo?.country, 10 | }) 11 | } catch (err) { 12 | // fail silently to not affect request 13 | console.error(err) 14 | } 15 | } 16 | 17 | return NextResponse.next() 18 | } 19 | 20 | export const matcher = { 21 | matcher: ['/'], 22 | } 23 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const inter = Inter({ subsets: ["latin"] }); 6 | 7 | export const metadata: Metadata = { 8 | title: "Create Next App", 9 | description: "Generated by create next app", 10 | }; 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: Readonly<{ 15 | children: React.ReactNode; 16 | }>) { 17 | return ( 18 | 19 | {children} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "noUncheckedIndexedAccess": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "analytics-dashboard", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@tremor/react": "^3.13.4", 13 | "@upstash/redis": "^1.28.3", 14 | "date-fns": "^3.3.1", 15 | "lucide-react": "^0.323.0", 16 | "next": "14.1.0", 17 | "react": "^18", 18 | "react-country-flag": "^3.1.0", 19 | "react-dom": "^18" 20 | }, 21 | "devDependencies": { 22 | "@types/node": "^20", 23 | "@types/react": "^18", 24 | "@types/react-dom": "^18", 25 | "autoprefixer": "^10.0.1", 26 | "eslint": "^8", 27 | "eslint-config-next": "14.1.0", 28 | "postcss": "^8", 29 | "tailwindcss": "^3.3.0", 30 | "typescript": "^5" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /src/utils/analytics.ts: -------------------------------------------------------------------------------- 1 | import { redis } from '@/lib/redis' 2 | import { getDate } from '@/utils' 3 | import { parse } from 'date-fns' 4 | 5 | type AnalyticsArgs = { 6 | retention?: number 7 | } 8 | 9 | type TrackOptions = { 10 | persist?: boolean 11 | } 12 | 13 | export class Analytics { 14 | private retention: number = 60 * 60 * 24 * 7 15 | 16 | constructor(opts?: AnalyticsArgs) { 17 | if (opts?.retention) this.retention = opts.retention 18 | } 19 | 20 | async track(namespace: string, event: object = {}, opts?: TrackOptions) { 21 | let key = `analytics::${namespace}` 22 | 23 | if (!opts?.persist) { 24 | key += `::${getDate()}` 25 | } 26 | 27 | // db call to persist this event 28 | await redis.hincrby(key, JSON.stringify(event), 1) 29 | if (!opts?.persist) await redis.expire(key, this.retention) 30 | } 31 | 32 | async retrieveDays(namespace: string, nDays: number) { 33 | type AnalyticsPromise = ReturnType 34 | const promises: AnalyticsPromise[] = [] 35 | 36 | for (let i = 0; i < nDays; i++) { 37 | const formattedDate = getDate(i) 38 | const promise = analytics.retrieve(namespace, formattedDate) 39 | promises.push(promise) 40 | } 41 | 42 | const fetched = await Promise.all(promises) 43 | 44 | const data = fetched.sort((a, b) => { 45 | if ( 46 | parse(a.date, 'dd/MM/yyyy', new Date()) > 47 | parse(b.date, 'dd/MM/yyyy', new Date()) 48 | ) { 49 | return 1 50 | } else { 51 | return -1 52 | } 53 | }) 54 | 55 | return data 56 | } 57 | 58 | async retrieve(namespace: string, date: string) { 59 | const res = await redis.hgetall>( 60 | `analytics::${namespace}::${date}` 61 | ) 62 | 63 | return { 64 | date, 65 | events: Object.entries(res ?? []).map(([key, value]) => ({ 66 | [key]: Number(value), 67 | })), 68 | } 69 | } 70 | } 71 | 72 | export const analytics = new Analytics() 73 | -------------------------------------------------------------------------------- /src/app/analytics/page.tsx: -------------------------------------------------------------------------------- 1 | import AnalyticsDashboard from '@/components/AnalyticsDashboard' 2 | import { getDate } from '@/utils' 3 | import { analytics } from '@/utils/analytics' 4 | 5 | const Page = async () => { 6 | const TRACKING_DAYS = 7 7 | 8 | const pageviews = await analytics.retrieveDays('pageview', TRACKING_DAYS) 9 | 10 | const totalPageviews = pageviews.reduce((acc, curr) => { 11 | return ( 12 | acc + 13 | curr.events.reduce((acc, curr) => { 14 | return acc + Object.values(curr)[0]! 15 | }, 0) 16 | ) 17 | }, 0) 18 | 19 | const avgVisitorsPerDay = (totalPageviews / TRACKING_DAYS).toFixed(1) 20 | 21 | const amtVisitorsToday = pageviews 22 | .filter((ev) => ev.date === getDate()) 23 | .reduce((acc, curr) => { 24 | return ( 25 | acc + 26 | curr.events.reduce((acc, curr) => acc + Object.values(curr)[0]!, 0) 27 | ) 28 | }, 0) 29 | 30 | const topCountriesMap = new Map() 31 | 32 | for (let i = 0; i < pageviews.length; i++) { 33 | const day = pageviews[i] 34 | if (!day) continue 35 | 36 | for (let j = 0; j < day.events.length; j++) { 37 | const event = day.events[j] 38 | if (!event) continue 39 | 40 | const key = Object.keys(event)[0]! 41 | const value = Object.values(event)[0]! 42 | 43 | const parsedKey = JSON.parse(key) 44 | const country = parsedKey?.country 45 | 46 | if (country) { 47 | if (topCountriesMap.has(country)) { 48 | const prevValue = topCountriesMap.get(country)! 49 | topCountriesMap.set(country, prevValue + value) 50 | } else { 51 | topCountriesMap.set(country, value) 52 | } 53 | } 54 | } 55 | } 56 | 57 | const topCountries = [...topCountriesMap.entries()].sort((a ,b) => { 58 | if(a[1] > b[1]) return -1 59 | else return 1 60 | }).slice(0, 5) 61 | 62 | return ( 63 |
64 |
65 | 71 |
72 |
73 | ) 74 | } 75 | 76 | export default Page 77 | -------------------------------------------------------------------------------- /src/components/AnalyticsDashboard.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { analytics } from '@/utils/analytics' 4 | import { BarChart, Card } from '@tremor/react' 5 | import { ArrowDownRight, ArrowRight, ArrowUpRight } from 'lucide-react' 6 | import ReactCountryFlag from 'react-country-flag' 7 | 8 | interface AnalyticsDashboardProps { 9 | avgVisitorsPerDay: string 10 | amtVisitorsToday: number 11 | timeseriesPageviews: Awaited> 12 | topCountries: [string, number][] 13 | } 14 | 15 | const Badge = ({ percentage }: { percentage: number }) => { 16 | const isPositive = percentage > 0 17 | const isNeutral = percentage === 0 18 | const isNegative = percentage < 0 19 | 20 | if (isNaN(percentage)) return null 21 | 22 | const positiveClassname = 'bg-green-900/25 text-green-400 ring-green-400/25' 23 | const neutralClassname = 'bg-zinc-900/25 text-zinc-400 ring-zinc-400/25' 24 | const negativeClassname = 'bg-red-900/25 text-red-400 ring-red-400/25' 25 | 26 | return ( 27 | 35 | {isPositive ? : null} 36 | {isNeutral ? : null} 37 | {isNegative ? : null} 38 | {percentage.toFixed(0)}% 39 | 40 | ) 41 | } 42 | 43 | const AnalyticsDashboard = ({ 44 | avgVisitorsPerDay, 45 | amtVisitorsToday, 46 | timeseriesPageviews, 47 | topCountries, 48 | }: AnalyticsDashboardProps) => { 49 | return ( 50 |
51 |
52 | 53 |

54 | Avg. visitors/day 55 |

56 |

57 | {avgVisitorsPerDay} 58 |

59 |
60 | 61 |

62 | Visitors today 63 | 68 |

69 |

70 | {amtVisitorsToday} 71 |

72 |
73 |
74 | 75 | 76 |

77 | This weeks top visitors: 78 |

79 |
80 | {topCountries?.map(([countryCode, number]) => { 81 | return ( 82 |
83 |

84 | {countryCode} 85 |

86 | 91 | 92 |

93 | {number} 94 |

95 |
96 | ) 97 | })} 98 |
99 |
100 | 101 | 102 | {timeseriesPageviews ? ( 103 | ({ 107 | name: day.date, 108 | Visitors: day.events.reduce((acc, curr) => { 109 | return acc + Object.values(curr)[0]! 110 | }, 0), 111 | }))} 112 | categories={['Visitors']} 113 | index='name' 114 | /> 115 | ) : null} 116 | 117 |
118 | ) 119 | } 120 | 121 | export default AnalyticsDashboard 122 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | const colors = require("tailwindcss/colors"); 3 | 4 | const config: Config = { 5 | content: [ 6 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 9 | 10 | "./node_modules/@tremor/**/*.{js,ts,jsx,tsx}" 11 | ], 12 | theme: { 13 | transparent: "transparent", 14 | current: "currentColor", 15 | extend: { 16 | colors: { 17 | // light mode 18 | tremor: { 19 | brand: { 20 | faint: colors.blue[50], 21 | muted: colors.blue[200], 22 | subtle: colors.blue[400], 23 | DEFAULT: colors.blue[500], 24 | emphasis: colors.blue[700], 25 | inverted: colors.white, 26 | }, 27 | background: { 28 | muted: colors.gray[50], 29 | subtle: colors.gray[100], 30 | DEFAULT: colors.white, 31 | emphasis: colors.gray[700], 32 | }, 33 | border: { 34 | DEFAULT: colors.gray[200], 35 | }, 36 | ring: { 37 | DEFAULT: colors.gray[200], 38 | }, 39 | content: { 40 | subtle: colors.gray[400], 41 | DEFAULT: colors.gray[500], 42 | emphasis: colors.gray[700], 43 | strong: colors.gray[900], 44 | inverted: colors.white, 45 | }, 46 | }, 47 | // dark mode 48 | "dark-tremor": { 49 | brand: { 50 | faint: "#0B1229", 51 | muted: colors.blue[950], 52 | subtle: colors.blue[800], 53 | DEFAULT: colors.blue[500], 54 | emphasis: colors.blue[400], 55 | inverted: colors.blue[950], 56 | }, 57 | background: { 58 | muted: "#131A2B", 59 | subtle: colors.gray[800], 60 | DEFAULT: colors.gray[900], 61 | emphasis: colors.gray[300], 62 | }, 63 | border: { 64 | DEFAULT: colors.gray[800], 65 | }, 66 | ring: { 67 | DEFAULT: colors.gray[800], 68 | }, 69 | content: { 70 | subtle: colors.gray[600], 71 | DEFAULT: colors.gray[500], 72 | emphasis: colors.gray[200], 73 | strong: colors.gray[50], 74 | inverted: colors.gray[950], 75 | }, 76 | }, 77 | }, 78 | boxShadow: { 79 | // light 80 | "tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)", 81 | "tremor-card": "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", 82 | "tremor-dropdown": "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", 83 | // dark 84 | "dark-tremor-input": "0 1px 2px 0 rgb(0 0 0 / 0.05)", 85 | "dark-tremor-card": "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)", 86 | "dark-tremor-dropdown": "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", 87 | }, 88 | borderRadius: { 89 | "tremor-small": "0.375rem", 90 | "tremor-default": "0.5rem", 91 | "tremor-full": "9999px", 92 | }, 93 | fontSize: { 94 | "tremor-label": ["0.75rem", { lineHeight: "1rem" }], 95 | "tremor-default": ["0.875rem", { lineHeight: "1.25rem" }], 96 | "tremor-title": ["1.125rem", { lineHeight: "1.75rem" }], 97 | "tremor-metric": ["1.875rem", { lineHeight: "2.25rem" }], 98 | }, 99 | }, 100 | }, 101 | safelist: [{ 102 | pattern: 103 | /(bg-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))/, 104 | variants: ["hover", "ui-selected"], 105 | }, 106 | { 107 | pattern: 108 | /(text-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))/, 109 | variants: ["hover", "ui-selected"], 110 | }, 111 | { 112 | pattern: 113 | /(border-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))/, 114 | variants: ["hover", "ui-selected"], 115 | }, 116 | { 117 | pattern: 118 | /(ring-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))/, 119 | }, 120 | { 121 | pattern: 122 | /(stroke-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))/, 123 | }, 124 | { 125 | pattern: 126 | /(fill-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-(?:50|100|200|300|400|500|600|700|800|900|950))/, 127 | }], 128 | plugins: [], 129 | }; 130 | export default config; 131 | --------------------------------------------------------------------------------