├── .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 |
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 |
--------------------------------------------------------------------------------