├── .env.example
├── .gitignore
├── LICENSE.md
├── README.md
├── components
├── articles
│ ├── ArticleDetails.tsx
│ ├── ArticleList.tsx
│ ├── ArticleListItem.tsx
│ └── CreateArticleForm.tsx
├── common
│ ├── Icon.tsx
│ └── InputWithLabel.tsx
├── page
│ ├── Footer.tsx
│ ├── GoogleAnalytics.tsx
│ ├── Header.tsx
│ ├── Notifications.tsx
│ └── PageHead.tsx
└── user
│ ├── SigninWithEmailForm.tsx
│ └── SigninWithGoogleButton.tsx
├── config
└── config.ts
├── docs
├── demo.jpg
└── lighthouse_score.png
├── hooks
├── useArticles.tsx
└── useUser.ts
├── jsconfig.json
├── lib
├── data
│ └── firebase.ts
├── formatDate.ts
├── handleRestRequest.ts
├── isClientSide.ts
├── isDevelopment.ts
├── makeRestRequest.ts
├── showNotification.ts
└── toSlug.ts
├── next-env.d.ts
├── next.config.js
├── package.json
├── pages
├── _app.tsx
├── _document.tsx
├── about.tsx
├── api
│ ├── notifications.tsx
│ └── revalidate.tsx
├── articles
│ └── [slug].tsx
├── index.tsx
├── robots.txt.tsx
├── signin
│ ├── authenticate.tsx
│ └── index.tsx
└── sitemap.xml.tsx
├── public
├── app.css
├── favicon.ico
├── favicon.png
├── icons
│ ├── feedback.svg
│ ├── help.svg
│ ├── home.svg
│ ├── menu.svg
│ └── person.svg
├── images
│ └── google_g.svg
├── manifest.json
└── share_preview.jpg
├── tsconfig.json
├── types
└── global.d.ts
├── vercel.json
└── yarn.lock
/.env.example:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_APP_URL=https://nextjs-pwa-firebase-boilerplate.vercel.app/
2 |
3 | NEXT_PUBLIC_FIREBASE_PROJECT_ID=nextjs-pwa-firebase-boilerplate
4 | NEXT_PUBLIC_FIREBASE_API_KEY=AIza...
--------------------------------------------------------------------------------
/.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 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 |
21 | # debug
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
26 | # local env files
27 | .env.local
28 | .env.development.local
29 | .env.test.local
30 | .env.production.local
31 |
32 | .vercel
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright (c) 2020, Tom Söderlund
2 |
3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
4 |
5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
6 |
7 | Source: http://opensource.org/licenses/ISC
8 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | # Next.js Firebase PWA
4 |
5 | **Next.js serverless PWA with Firebase and React Hooks**
6 |
7 | 
8 |
9 | _Note 1: This boilerplate is being converted to TypeScript. For pure JavaScript, see branch `old-javascript`_
10 |
11 | _Note 2: this is my v4 boilerplate for React web apps. See also my [GraphQL + Postgres SQL boilerplate](https://github.com/tomsoderlund/nextjs-pwa-graphql-sql-boilerplate), [Redux + REST + Postgres SQL boilerplate](https://github.com/tomsoderlund/nextjs-sql-rest-api-boilerplate) and [Redux + REST + MongoDB boilerplate](https://github.com/tomsoderlund/nextjs-express-mongoose-crudify-boilerplate). For a simple Next.js landing page, see [nextjs-generic-landing-page](https://github.com/tomsoderlund/nextjs-generic-landing-page)._
12 |
13 | ## Support this project
14 |
15 | Did you or your company find `nextjs-pwa-firebase-boilerplate` useful? Please consider giving a small donation, it helps me spend more time on open-source projects:
16 |
17 | [](https://ko-fi.com/tomsoderlund)
18 |
19 | ## Why is this awesome?
20 |
21 | This is a great template for a any project where you want **React (with Hooks)** (with **static site generation (SSG)** or **server-side rendering (SSR)**, powered by [Next.js](https://github.com/zeit/next.js)) as frontend and **Firebase** as backend. *Lightning fast, all JavaScript.*
22 |
23 | - Great starting point for a [PWA (Progressive Web App)](https://en.wikipedia.org/wiki/Progressive_web_applications), which you can add to your Home Screen and use as a full-screen app.
24 | - PWA features such as `manifest.json` and offline support (`next-offline`).
25 | - Can be deployed as [serverless functions on Vercel/Zeit Now](#deploying).
26 | - Uses the new Firebase [Firestore](https://firebase.google.com/docs/firestore) database, but easy to replace/remove database.
27 | - Login/Signup with Firebase Authentication.
28 | - Can use SSG `getStaticProps` or SSR `getServerSideProps`.
29 | - React Hooks and Context for state management and business logic.
30 | - Free-form database model. No GraphQL or REST API, just add React Hooks and modify `getStaticProps`/`getServerSideProps` when changing/adding database tables.
31 | - Easy to style the visual theme using CSS (e.g. using [Design Profile Generator](https://tomsoderlund.github.io/design-profile-generator/)).
32 | - SEO support with `sitemap.xml` and `robots.txt`.
33 | - Google Analytics and `google-site-verification` support (see `config/config.ts`).
34 | - Flexible configuration with `config/config.ts` and `.env.local` file.
35 | - Code linting and formatting with StandardJS (`yarn lint`/`yarn fix`).
36 | - Unit testing with Jasmine (`yarn unit`, not yet included).
37 | - Great page speed, see [Lighthouse](https://developers.google.com/web/tools/lighthouse) score:
38 |
39 | 
40 |
41 | ## Demo
42 |
43 | See [**nextjs-pwa-firebase-boilerplate** running on Vercel here](https://nextjs-pwa-firebase-boilerplate.vercel.app/).
44 |
45 | 
46 |
47 | ## How to use
48 |
49 | Clone this repository:
50 |
51 | git clone https://github.com/tomsoderlund/nextjs-pwa-firebase-boilerplate.git [MY_APP]
52 | cd [MY_APP]
53 |
54 | Remove the `.git` folder since you want to create a new repository
55 |
56 | rm -rf .git
57 |
58 | Install dependencies:
59 |
60 | yarn # or npm install
61 |
62 | At this point, you need to [set up Firebase Firestore, see below](#set-up-firebase-database-firestore).
63 |
64 | When Firebase is set up, start the web app by:
65 |
66 | yarn dev
67 |
68 | In production:
69 |
70 | yarn build
71 | yarn start
72 |
73 | If you navigate to `http://localhost:3004/` you will see a web page with a list of articles (or an empty list if you haven’t added one).
74 |
75 | ## Modifying the app to your needs
76 |
77 | ### Change app name and description
78 |
79 | - Do search/replace for the `name`’s “Next.js Firebase PWA”, “nextjs-pwa-firebase-boilerplate” and `description` “Next.js serverless PWA with Firebase and React Hooks” to something else.
80 | - Change the `version` in `package.json` to `0.1.0` or similar.
81 | - Change the `license` in `package.json` to whatever suits your project.
82 |
83 | ### Renaming “Article” to something else
84 |
85 | The main database item is called `Article`, but you probably want something else in your app.
86 |
87 | Rename the files:
88 |
89 | git mv hooks/useArticles.tsx hooks/use{NewName}s.tsx
90 |
91 | mkdir -p components/{newName}s
92 | git mv components/articles/CreateArticleForm.tsx components/{newName}s/Create{NewName}Form.tsx
93 | git mv components/articles/ArticleDetails.tsx components/{newName}s/{NewName}Details.tsx
94 | git mv components/articles/ArticleList.tsx components/{newName}s/{NewName}List.tsx
95 | git mv components/articles/ArticleListItem.tsx components/{newName}s/{NewName}ListItem.tsx
96 | rm -r components/articles
97 |
98 | mkdir pages/{newName}s
99 | git mv "pages/articles/[slug].tsx" "pages/{newName}s/[slug].tsx"
100 | rm -r pages/articles
101 |
102 | Then, do search/replace inside the files for different casing: `article`, `Article`, `ARTICLE`.
103 |
104 | ### Change port number
105 |
106 | Do search/replace for `3004` to something else.
107 |
108 | ### Set up Firebase database (Firestore)
109 |
110 | Set up the database (if you don’t need a database, see “How to remove/replace Firebase as database” below):
111 |
112 | 1. Go to https://console.firebase.google.com/ and create a new project, a new web app, and a new Cloud Firestore database.
113 | 2. Copy the `firebaseConfig` (from when setting up the Firebase web app) to `lib/data/firebase.ts`
114 | 3. Edit the `.env.local` file, setting the `NEXT_PUBLIC_FIREBASE_API_KEY` value.
115 |
116 | ### How to remove the Firebase dependency
117 |
118 | - Run `yarn remove firebase`
119 | - Delete `lib/data/firebase.ts` and modify `hooks/useArticles.tsx`.
120 |
121 | ### Replace Firebase with Postgres SQL
122 |
123 | - Use a Postgres hosting provider (e.g. https://www.elephantsql.com/)
124 | - Use [`createSqlRestRoutesServerless` in `sql-wizard`](https://github.com/tomsoderlund/sql-wizard#creating-rest-routes-serverless-eg-for-nextjs-and-vercel) to set up your own API routes.
125 |
126 | ### Replace Firebase with Supabase (Postgres SQL, real-time updates)
127 |
128 | - Remove Firebase: `yarn remove firebase`
129 | - Add Supabase: `yarn add @supabase/supabase-js`
130 | - Add `NEXT_PUBLIC_SUPABASE_API_KEY` to `.env.local`
131 | - Create a `lib/data/supabase.ts`:
132 | ```
133 | import { createClient } from '@supabase/supabase-js'
134 | const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
135 | const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_API_KEY
136 | export const supabase = createClient(supabaseUrl, supabaseKey)
137 | ```
138 | - Update the JS files that reference `lib/data/firebase`
139 |
140 | ### Change visual theme (CSS)
141 |
142 | 1. Change included CSS files in `pages/_app.tsx`
143 | 2. Change CSS in `public/app.css`
144 | 3. Change font(s) in `PageHead.tsx`
145 | 4. Change colors in `public/manifest.json`
146 |
147 | ### Login/Signup with Firebase Authentication
148 |
149 | You need to enable Email/Password authentication in https://console.firebase.google.com/project/YOURAPP/authentication/providers
150 |
151 | ## Deploying on Vercel
152 |
153 | > Note: If you set up your project using the Deploy button, you need to clone your own repo instead of this repository.
154 |
155 | Setup and deploy your own project using this template with [Vercel](https://vercel.com). All you’ll need is your Firebase Public API Key.
156 |
157 | [](https://vercel.com/import/git?s=https%3A%2F%2Fgithub.com%2Ftomsoderlund%2Fnextjs-pwa-firebase-boilerplate&env=NEXT_PUBLIC_FIREBASE_API_KEY&envDescription=Enter%20your%20public%20Firebase%20API%20Key&envLink=https://github.com/tomsoderlund/nextjs-pwa-firebase-boilerplate#deploying-with-vercel)
158 |
--------------------------------------------------------------------------------
/components/articles/ArticleDetails.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { Article } from 'hooks/useArticles'
4 |
5 | const ArticleDetails = ({ article }: { article: Article }) => {
6 | return (
7 | <>
8 |
{article.name}
9 | {article.dateCreated.toString()}
10 | {article.content}
11 | >
12 | )
13 | }
14 | export default ArticleDetails
15 |
--------------------------------------------------------------------------------
/components/articles/ArticleList.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { useArticles } from 'hooks/useArticles'
4 |
5 | import ArticleListItem from './ArticleListItem'
6 |
7 | const ArticleList = () => {
8 | const { articles } = useArticles()
9 |
10 | if (articles === null) return 'Loading...'
11 |
12 | return (
13 |
14 | {articles?.map(article => (
15 |
20 | ))}
21 |
22 | )
23 | }
24 | export default ArticleList
25 |
--------------------------------------------------------------------------------
/components/articles/ArticleListItem.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link'
2 |
3 | import showNotification from 'lib/showNotification'
4 | import { Article, useArticles, articlePath } from 'hooks/useArticles'
5 |
6 | interface ArticleListItemProps {
7 | article: Article
8 | inProgress: boolean
9 | }
10 |
11 | const ArticleListItem = ({ article, inProgress = false }: ArticleListItemProps) => {
12 | const promptAndUpdateArticle = usePromptAndUpdateArticle(article, 'name')
13 | const promptAndDeleteArticle = usePromptAndDeleteArticle(article)
14 |
15 | return (
16 |
71 | )
72 | }
73 | export default ArticleListItem
74 |
75 | const usePromptAndUpdateArticle = (article: Article, fieldName: keyof Article) => {
76 | const { updateArticle } = useArticles()
77 |
78 | const handleUpdate = async () => {
79 | const newValue = window.prompt(`New value for ${fieldName}?`, article[fieldName] as string)
80 | if (newValue !== null) {
81 | const notificationId = showNotification('Updating article...')
82 | await updateArticle?.({
83 | id: article.id,
84 | [fieldName]: (newValue === '' ? null : newValue)
85 | })
86 | showNotification('Article updated', 'success', { notificationId })
87 | }
88 | }
89 |
90 | return handleUpdate
91 | }
92 |
93 | const usePromptAndDeleteArticle = (article: Article) => {
94 | const { deleteArticle } = useArticles()
95 |
96 | const handleDelete = async () => {
97 | if (window.confirm(`Delete ${article.name}?`)) {
98 | const notificationId = showNotification('Deleting article...')
99 | await deleteArticle?.(article.id as string)
100 | showNotification('Article deleted', 'success', { notificationId })
101 | }
102 | }
103 |
104 | return handleDelete
105 | }
106 |
--------------------------------------------------------------------------------
/components/articles/CreateArticleForm.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 |
3 | import showNotification from 'lib/showNotification'
4 | import { useArticles } from 'hooks/useArticles'
5 | import InputWithLabel from 'components/common/InputWithLabel'
6 |
7 | const DEFAULT_INPUTS = { name: '' }
8 |
9 | const useCreateArticleForm = () => {
10 | const [inputs, setInputs] = useState(DEFAULT_INPUTS)
11 | const { createArticle } = useArticles()
12 | const [inProgress, setInProgress] = useState(false)
13 |
14 | const handleInputChange = ({ target }: React.ChangeEvent) => {
15 | const value = (target instanceof HTMLInputElement && target.type === 'checkbox') ? target.checked : target.value
16 | setInputs({ ...inputs, [target.name]: value })
17 | }
18 |
19 | const handleSubmit = async (event: React.FormEvent) => {
20 | if (event) event.preventDefault()
21 | setInProgress(true)
22 | const notificationId = showNotification('Creating new article...')
23 | await createArticle?.(inputs)
24 | // Clear input form when done
25 | setInputs(DEFAULT_INPUTS)
26 | setInProgress(false)
27 | showNotification('Article created', 'success', { notificationId })
28 | }
29 |
30 | return { inputs, inProgress, handleInputChange, handleSubmit }
31 | }
32 |
33 | const CreateArticleForm = () => {
34 | const { inputs, inProgress, handleInputChange, handleSubmit } = useCreateArticleForm()
35 | return (
36 |
64 | )
65 | }
66 | export default CreateArticleForm
67 |
--------------------------------------------------------------------------------
/components/common/Icon.tsx:
--------------------------------------------------------------------------------
1 | // Setup:
2 | // 1. yarn add react-svg-inline raw-loader
3 | // 2. Uncomment 'webpack' section in next.config.js
4 | import React from 'react'
5 | import SVGInline from 'react-svg-inline'
6 |
7 | const DEFAULT_SIZE = '16'
8 |
9 | interface IconProps {
10 | type?: string
11 | width?: string
12 | height?: string
13 | color?: string
14 | rotation?: number
15 | }
16 |
17 | const Icon = ({ type = 'home', width = DEFAULT_SIZE, height = DEFAULT_SIZE, color = 'rgba(0, 0, 0, 0.85)', rotation }: IconProps) => {
18 | return (
19 |
33 | )
34 | }
35 | export default Icon
36 |
--------------------------------------------------------------------------------
/components/common/InputWithLabel.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | interface InputWithLabelProps {
4 | name: string
5 | label?: string
6 | placeholder?: string
7 | value?: string
8 | type?: string
9 | autoComplete?: string
10 | onChange?: (event: React.ChangeEvent | React.ChangeEvent) => void
11 | required?: boolean
12 | disabled?: boolean
13 | }
14 |
15 | const InputWithLabel: React.FC = ({
16 | name,
17 | label,
18 | placeholder,
19 | value,
20 | type,
21 | autoComplete,
22 | onChange,
23 | required,
24 | disabled
25 | }) => {
26 | return (
27 |
28 | {label}{required && * }
29 | {type === 'multiline'
30 | ? (
31 |
41 | )
42 | : (
43 |
53 | )}
54 |
60 |
61 | )
62 | }
63 | export default InputWithLabel
64 |
--------------------------------------------------------------------------------
/components/page/Footer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Link from 'next/link'
3 |
4 | import { config } from 'config/config'
5 |
6 | const Footer = (): React.ReactElement => (
7 |
8 |
9 | About {config.appName}
10 | {' | '}
11 | By Tomorroworld
12 |
13 | {' | '}
14 | Contact
15 |
16 |
34 |
35 | )
36 | export default Footer
37 |
38 | const TomorroworldLogo = (): React.ReactElement => (
39 |
40 |
45 |
57 |
58 | )
59 |
--------------------------------------------------------------------------------
/components/page/GoogleAnalytics.tsx:
--------------------------------------------------------------------------------
1 | import { config } from '../../config/config'
2 | import isDevelopment from '../../lib/isDevelopment'
3 |
4 | declare global {
5 | interface Window {
6 | gtag: (event: string, action: string, options: any) => void
7 | }
8 | }
9 |
10 | /* options: { 'page_title' : 'homepage' } */
11 | // See https://developers.google.com/analytics/devguides/collection/gtagjs
12 | export const googlePageview = (path: string, options?: any): void => {
13 | const completeOptions = Object.assign({}, options, { page_path: path }) // 'page_title' : 'homepage'
14 | if (config.googleAnalyticsId !== undefined && config.googleAnalyticsId !== null) window.gtag('config', config.googleAnalyticsId, completeOptions)
15 | if (isDevelopment()) console.log('Google pageview:', { path, options: completeOptions })
16 | }
17 |
18 | // options: { 'event_category' : 'bbb', 'event_label' : 'ccc' }
19 | // See https://developers.google.com/analytics/devguides/collection/gtagjs/events
20 | export const googleEvent = (action: string, options?: any): void => {
21 | if (config.googleAnalyticsId !== undefined) window.gtag('event', action, options)
22 | if (isDevelopment()) console.log('Google event:', { action, options })
23 | }
24 |
--------------------------------------------------------------------------------
/components/page/Header.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Link from 'next/link'
3 | import { config } from '../../config/config'
4 |
5 | interface HeaderProps {
6 | title?: string
7 | children?: React.ReactNode
8 | }
9 |
10 | const Header: React.FC = ({ title = config.appName, children }) => (
11 |
12 |
13 | {title}
14 | {children}
15 |
33 |
34 | )
35 |
36 | export default Header
37 |
38 | const AppIcon: React.FC = () => (
39 |
40 |
41 |
42 |
57 |
58 |
59 | )
60 |
--------------------------------------------------------------------------------
/components/page/Notifications.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import { ToastContainer } from 'react-toastify'
4 | import 'react-toastify/dist/ReactToastify.min.css'
5 |
6 | const Notifications = (): React.ReactElement => {
7 | return (
8 | <>
9 |
10 |
11 |
33 | >
34 | )
35 | }
36 | export default Notifications
37 |
--------------------------------------------------------------------------------
/components/page/PageHead.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Head from 'next/head'
3 |
4 | import { config } from 'config/config'
5 |
6 | export interface PageProps {
7 | title?: string
8 | description?: string
9 | imageUrl?: string
10 | iconUrl?: string
11 | path?: string
12 | }
13 |
14 | const PageHead = ({ title, description, imageUrl, iconUrl = '/favicon.png', path = '/' }: PageProps): React.ReactElement | null => {
15 | const pageTitle = (title !== undefined && title !== null)
16 | ? `${(title)} – ${config.appName as string}`
17 | : `${config.appName as string} – ${(config.appTagline as string)}`
18 |
19 | const pageDescription = description ?? config.appDescription ?? ''
20 |
21 | // SEO: title 60 characters, description 160 characters
22 | // if (isDevelopment()) console.log(`PageHead (dev):\n• title (${60 - pageTitle.length}): “${pageTitle}”\n• description (${160 - pageDescription.length}): “${pageDescription}”`)
23 |
24 | const thumbnailUrl = imageUrl ?? `${config.appUrl as string}images/preview_default.png` // ?? `https://screens.myscreenshooterserver.com/?url=${config.appUrl}${path.slice(1)}${(path.includes('?') ? '&' : '?')}thumbnail=true`
25 |
26 | return (
27 |
28 | {pageTitle}
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | {(thumbnailUrl !== undefined && thumbnailUrl !== null) && (
45 | <>
46 |
47 |
48 | >
49 | )}
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | {/*
61 |
62 |
63 |
64 |
65 |
66 |
67 | */}
68 |
69 |
70 | )
71 | }
72 | export default PageHead
73 |
--------------------------------------------------------------------------------
/components/user/SigninWithEmailForm.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import { getAuth, sendSignInLinkToEmail } from 'firebase/auth'
3 |
4 | import { firebaseApp } from 'lib/data/firebase'
5 | import showNotification from 'lib/showNotification'
6 | import { googleEvent } from 'components/page/GoogleAnalytics'
7 | // import makeRestRequest from 'lib/makeRestRequest'
8 |
9 | // const anonymizeEmail = (email: string): string => email.split('@').map((part, isDomain) => isDomain ? part : part[0] + new Array(part.length).join('•')).join('@')
10 |
11 | interface SimpleEvent {
12 | target: {
13 | name: string
14 | value: string
15 | }
16 | }
17 |
18 | interface SigninWithEmailFormProps {
19 | buttonText?: string
20 | thankyouText?: string
21 | googleEventName?: string
22 | redirectTo?: string
23 | onCompleted?: (error: Error | null, inputs: { email: string }) => void
24 | }
25 |
26 | const SigninWithEmailForm = ({ buttonText = 'Sign in', thankyouText = 'Check your email for a sign-in link!', googleEventName = 'user_login', redirectTo, onCompleted }: SigninWithEmailFormProps): React.ReactElement => {
27 | const [inProgress, setInProgress] = useState(false)
28 | const [isSubmitted, setIsSubmitted] = useState(false)
29 | const auth = getAuth(firebaseApp)
30 |
31 | const [inputs, setInputs] = useState({ email: '' })
32 | const handleInputChange = ({ target }: SimpleEvent): void => setInputs({ ...inputs, [target.name]: target.value })
33 |
34 | const handleSubmit = async (event: React.FormEvent): Promise => {
35 | event.preventDefault()
36 | setInProgress(true)
37 |
38 | try {
39 | // Firebase sign-in with just email link, no password
40 | const actionCodeSettings = {
41 | url: `${window.location.origin}/signin/authenticate${redirectTo !== undefined ? `?redirectTo=${encodeURIComponent(redirectTo)}` : ''}`,
42 | handleCodeInApp: true
43 | }
44 | await sendSignInLinkToEmail(auth, inputs.email, actionCodeSettings)
45 | window.localStorage.setItem('emailForSignIn', inputs.email)
46 | // makeRestRequest('POST', '/api/notifications', { email: anonymizeEmail(inputs.email) })
47 | handleInputChange({ target: { name: 'email', value: '' } })
48 | setIsSubmitted(true)
49 | if (googleEventName) googleEvent(googleEventName)
50 | onCompleted?.(null, inputs)
51 | } catch (error: any) {
52 | console.warn(error.message as string)
53 | showNotification(`Could not sign in: ${error.message}`, 'error')
54 | onCompleted?.(null, inputs)
55 | } finally {
56 | setInProgress(false)
57 | }
58 | }
59 |
60 | return (
61 |
62 | {!isSubmitted
63 | ? (
64 | <>
65 |
85 |
86 | (No password necessary, we will send a sign-in link to your email inbox)
87 |
88 | >
89 | )
90 | : (
91 |
{thankyouText}
92 | )}
93 |
94 | )
95 | }
96 |
97 | export default SigninWithEmailForm
98 |
--------------------------------------------------------------------------------
/components/user/SigninWithGoogleButton.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useRouter } from 'next/router'
3 | import Image from 'next/image'
4 | import { getAuth, GoogleAuthProvider, signInWithPopup } from 'firebase/auth'
5 |
6 | import { config } from 'config/config'
7 | import { firebaseApp } from 'lib/data/firebase'
8 |
9 | interface SigninWithGoogleButtonProps {
10 | googleEventName?: string
11 | redirectTo?: string
12 | }
13 |
14 | const SigninWithGoogleButton = ({ redirectTo = config.startPagePath ?? '/' }: SigninWithGoogleButtonProps): React.ReactElement => {
15 | const router = useRouter()
16 | const auth = getAuth(firebaseApp)
17 | const provider = new GoogleAuthProvider()
18 |
19 | const handleGoogleSignin = async (event: React.MouseEvent): Promise => {
20 | event.preventDefault()
21 | // See https://firebase.google.com/docs/auth/web/google-signin
22 | const result = await signInWithPopup(auth, provider)
23 | const user = result.user
24 | console.log('handleGoogleSignin:', { user })
25 | void router.push(redirectTo)
26 | }
27 | return (
28 |
29 | { void handleGoogleSignin(event) }}>
30 |
31 | Sign in with Google
32 |
33 |
41 |
42 | )
43 | }
44 | export default SigninWithGoogleButton
45 |
--------------------------------------------------------------------------------
/config/config.ts:
--------------------------------------------------------------------------------
1 | import packageJson from '../package.json'
2 | import manifest from '../public/manifest.json'
3 |
4 | export const environment = process.env.NODE_ENV
5 | export const isDevelopment = process.env.NODE_ENV === 'development'
6 | const appSlug = packageJson.name
7 | const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'https://nextjs-pwa-firebase-boilerplate.vercel.app/'
8 | const serverPort = parseInt(process.env.PORT ?? '3004')
9 |
10 | interface EnvironmentConfiguration {
11 | appSlug: string
12 | appVersion: string
13 | appUrl: string
14 | appName: string
15 | appTagline?: string
16 | appDescription?: string
17 | serverPort: number
18 | locale?: string
19 | googleAnalyticsId?: string | null
20 | fonts?: string[][]
21 |
22 | startPagePath?: string
23 | allowedHostsList?: string[]
24 |
25 | isProduction?: boolean
26 | sendRealMessages?: boolean
27 | }
28 |
29 | interface AllConfigurations {
30 | default?: EnvironmentConfiguration
31 | development?: Partial
32 | production?: Partial
33 | test?: Partial
34 | }
35 |
36 | const completeConfig: AllConfigurations = {
37 |
38 | default: {
39 | serverPort,
40 | appSlug,
41 | appVersion: packageJson.version,
42 | appUrl,
43 | appName: manifest.name,
44 | appTagline: manifest.description,
45 | appDescription: `${manifest.name} – ${manifest.description}`,
46 | locale: 'en_US', // sv_SE
47 | googleAnalyticsId: 'G-XXXXXXXXXX',
48 | fonts: [
49 | ['Inter', 'wght@300;400;500;700']
50 | ],
51 | allowedHostsList: [`localhost:${serverPort}`, (new URL(appUrl)).host]
52 | },
53 |
54 | development: {
55 | appUrl: `http://localhost:${serverPort}/`,
56 | googleAnalyticsId: null
57 | },
58 |
59 | production: {
60 | }
61 |
62 | }
63 |
64 | // Public API
65 | export const config = { ...completeConfig.default, ...completeConfig[environment] }
66 | export default config
67 |
--------------------------------------------------------------------------------
/docs/demo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tomsoderlund/nextjs-pwa-firebase-boilerplate/8c2090150cbb392c6dae593ae1dcd1d4ac0cc3f1/docs/demo.jpg
--------------------------------------------------------------------------------
/docs/lighthouse_score.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tomsoderlund/nextjs-pwa-firebase-boilerplate/8c2090150cbb392c6dae593ae1dcd1d4ac0cc3f1/docs/lighthouse_score.png
--------------------------------------------------------------------------------
/hooks/useArticles.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * articles module
3 | * @description Hooks for manipulating articles data
4 |
5 | Two different hooks: 1) one for lists of articles, and 2) one for a specific article (commented out by default).
6 |
7 | How to use the first:
8 |
9 | Wrap your component/page with the ArticlesContextProvider.
10 | NOTE: must be wrapped on higher level than where useArticles is used.
11 |
12 | import { ArticlesContextProvider } from 'hooks/useArticles'
13 |
14 |
18 | ...
19 |
20 |
21 | Then to use (“consume”) inside component or hook:
22 |
23 | import { useArticles } from 'hooks/useArticles'
24 |
25 | const { articles, createArticle } = useArticles()
26 | await createArticle(data)
27 |
28 | */
29 |
30 | import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'
31 |
32 | import { collection, doc, getDoc, setDoc, updateDoc, deleteDoc, Timestamp, onSnapshot, serverTimestamp } from 'firebase/firestore'
33 | import { firebaseDB, doesDocumentExist, docWithId, getDocumentItem, getCollectionItems, FirestoreDoc } from 'lib/data/firebase'
34 | import toSlug from 'lib/toSlug'
35 | import makeRestRequest from 'lib/makeRestRequest'
36 |
37 | export interface Article extends FirestoreDoc {
38 | id?: string
39 | name: string
40 | dateCreated: Timestamp
41 | dateUpdated?: Timestamp
42 | }
43 |
44 | // Tip: if you don’t need SSR, you can move these inside the ArticlesContextProvider and create “chains” of child Firebase collections that depend on their parents
45 | // Collection/Item as Firebase references
46 | export const articlesCollectionRef = () => collection(firebaseDB, 'articles')
47 | export const articleRef = (articleId: string) => doc(firebaseDB, 'articles', articleId)
48 |
49 | // Collection/Item as objects
50 | export const articlesCollection = async (): Promise => await getCollectionItems(articlesCollectionRef()) as Article[] // Add .orderBy('dateCreated') to sort by date but only rows where dateCreated exists
51 |
52 | export const articleObject = async (articleId: string) => {
53 | const projRef = await articleRef(articleId)
54 | return await getDocumentItem(projRef)
55 | }
56 |
57 | export const getArticleSlug = (article: Article) => article.id
58 |
59 | export const articlePath = (article: Article) => {
60 | return {
61 | href: `/articles/${getArticleSlug(article)}`
62 | }
63 | }
64 |
65 | // Example: extending the database with Comments
66 | // export const commentsCollectionRef = (articleId: string) => articleRef(articleId).collection('comments')
67 | // export const commentRef = (articleId: string, commentId: string) => commentsCollection(articleId).doc(commentId)
68 |
69 | // ----- Articles collection -----
70 |
71 | interface ArticlesInputProps {
72 | articles: Article[]
73 | onError?: (error: string) => void
74 | children: ReactNode
75 | }
76 |
77 | interface ArticlesReturnProps {
78 | articles?: Article[]
79 | getArticles: () => Promise
80 | createArticle: (variables: Partial) => Promise
81 | updateArticle: (variables: Partial) => Promise
82 | deleteArticle: (articleId: string) => Promise
83 | }
84 |
85 | const ArticlesContext = createContext>({})
86 |
87 | export const ArticlesContextProvider = (props: ArticlesInputProps) => {
88 | const [articles, setArticles] = useState(props.articles ?? [])
89 |
90 | useEffect(() => {
91 | const unsubscribe = onSnapshot(collection(firebaseDB, 'articles'), (snapshot) => {
92 | const filteredArticles = snapshot.docs
93 | .map(docWithId)
94 | // .filter((article) => (article.ownerUserId === user?.uid) || isAdmin)
95 | setArticles(filteredArticles as Article[])
96 | })
97 | return () => unsubscribe()
98 | }, [])
99 |
100 | const revalidateArticle = async (article: Article) => {
101 | await makeRestRequest('POST', '/api/revalidate', { path: articlePath(article).href })
102 | }
103 |
104 | const createArticle = async (variables: Partial) => {
105 | const valuesWithTimestamp = { ...variables, dateCreated: serverTimestamp() }
106 |
107 | const articleId = toSlug(variables.name as string)
108 | const newArticleRef = articleRef(articleId)
109 |
110 | if (await doesDocumentExist(newArticleRef)) {
111 | throw new Error(`Article '${articleId}' already exists`)
112 | }
113 |
114 | await setDoc(newArticleRef, valuesWithTimestamp)
115 |
116 | const newArticleSnapshot = await getDoc(newArticleRef)
117 | const newArticleWithId: Article = docWithId(newArticleSnapshot) as Article
118 | setArticles([...articles, newArticleWithId])
119 | revalidateArticle(newArticleWithId)
120 | return newArticleWithId
121 | }
122 |
123 | const updateArticle = async (variables: Partial) => {
124 | const { id, dateCreated, ...values } = variables
125 | const valuesWithTimestamp = { ...values, dateUpdated: serverTimestamp() }
126 | await updateDoc(articleRef(id as string), valuesWithTimestamp)
127 | const articleSnapshot = await getDoc(articleRef(id as string))
128 | const articleWithId: Article = docWithId(articleSnapshot) as Article
129 | setArticles(articles?.map((article) => (article.id === id ? articleWithId : article)))
130 | revalidateArticle(articleWithId)
131 | return articleWithId
132 | }
133 |
134 | const deleteArticle = async (articleId: string) => {
135 | await deleteDoc(articleRef(articleId))
136 | setArticles(articles?.filter(article => article.id !== articleId))
137 | }
138 |
139 | const articlesContext = {
140 | articles,
141 | createArticle,
142 | updateArticle,
143 | deleteArticle
144 | }
145 |
146 | return {props.children}
147 | }
148 |
149 | export const useArticles = () => {
150 | const context = useContext(ArticlesContext)
151 | if (context == null) {
152 | throw new Error('useArticles must be used within an ArticlesContextProvider')
153 | }
154 | return context
155 | }
156 |
157 | // ----- One Article -----
158 | /*
159 |
160 | export const ArticleContext = createContext<{
161 | article: any;
162 | updateArticle: (variables: Partial) => Promise;
163 | deleteArticle: (variables: Partial) => Promise;
164 | } | undefined>(undefined);
165 |
166 | interface ArticleContextProviderProps {
167 | article: any;
168 | children: ReactNode;
169 | }
170 |
171 | export const ArticleContextProvider = (props: ArticleContextProviderProps) => {
172 | const [article, setArticle] = useState(props.article);
173 |
174 | const thisArticleRef = useMemo(() => articleRef(props.article.id), [props.article.id]);
175 |
176 | useEffect(() => {
177 | const unsubscribe = thisArticleRef.onSnapshot(snapshot => {
178 | setArticle(docWithId(snapshot));
179 | });
180 | return () => unsubscribe();
181 | }, [thisArticleRef]);
182 |
183 | const updateArticle = async (variables: Partial) => {
184 | // implement update logic here
185 | };
186 |
187 | const deleteArticle = async (variables: Partial) => {
188 | // implement delete logic here
189 | };
190 |
191 | const articleContext = {
192 | article,
193 | updateArticle,
194 | deleteArticle
195 | };
196 |
197 | return {props.children} ;
198 | };
199 |
200 | export const useArticle = () => {
201 | const context = useContext(ArticleContext);
202 | if (!context) {
203 | throw new Error('useArticle must be used within an ArticleContextProvider');
204 | }
205 | return context;
206 | };
207 | */
208 |
--------------------------------------------------------------------------------
/hooks/useUser.ts:
--------------------------------------------------------------------------------
1 | /*
2 | import useUser from 'hooks/useUser'
3 | const { user } = useUser()
4 | */
5 | import { useState, useEffect } from 'react'
6 | import { getAuth, User } from 'firebase/auth'
7 |
8 | import { firebaseApp } from 'lib/data/firebase'
9 |
10 | interface UserHook {
11 | user: User | null
12 | signOut: () => Promise
13 | }
14 |
15 | export default function useUser (): UserHook {
16 | const [user, setUser] = useState(null)
17 | const auth = getAuth(firebaseApp)
18 |
19 | useEffect(() => {
20 | const unsubscribe = auth.onAuthStateChanged((firebaseUser) => {
21 | setUser(firebaseUser)
22 | })
23 | return () => unsubscribe()
24 | }, [])
25 |
26 | const signOut = async () => {
27 | try {
28 | await auth.signOut()
29 | } catch (error: unknown) {
30 | console.warn(`Warning: ${(error instanceof Error) ? error.message : 'Unknown error'}`)
31 | }
32 | }
33 |
34 | return { user, signOut }
35 | }
36 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "."
4 | }
5 | }
--------------------------------------------------------------------------------
/lib/data/firebase.ts:
--------------------------------------------------------------------------------
1 | import { initializeApp } from 'firebase/app'
2 | import { getFirestore, getDoc, getDocs, DocumentData, DocumentSnapshot, DocumentReference, CollectionReference, QuerySnapshot } from 'firebase/firestore'
3 | import 'firebase/auth'
4 | // import 'firebase/analytics'
5 |
6 | export const firebaseConfig = {
7 | apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
8 | authDomain: `${process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID}.firebaseapp.com`,
9 | databaseURL: `https://${process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID}.firebaseio.com`,
10 | projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
11 | storageBucket: `${process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID}.appspot.com`,
12 | messagingSenderId: '454142492082',
13 | appId: '1:454142492082:web:9f097c0b9039832ca7b8ab'
14 | }
15 |
16 | // Initialize Firebase
17 | export const firebaseApp = initializeApp(firebaseConfig)
18 | export const firebaseDB = getFirestore(firebaseApp)
19 | // if (isClientSide()) firebase.analytics()
20 |
21 | // Helpers
22 | export type FirestoreDoc = DocumentData
23 |
24 | export const doesDocumentExist = async (docRef: DocumentReference): Promise => (await getDoc(docRef)).exists()
25 |
26 | export const docWithId = (doc: DocumentSnapshot): FirestoreDoc => ({
27 | id: doc.id,
28 | ...doc.data()
29 | })
30 |
31 | export const getDocumentItem = async (docRef: DocumentReference): Promise => {
32 | const docSnapshot = await getDoc(docRef)
33 | return docWithId(docSnapshot)
34 | }
35 |
36 | export const getCollectionItems = async (collectionRef: CollectionReference): Promise => {
37 | const querySnapshot: QuerySnapshot = await getDocs(collectionRef)
38 | const snapshots: FirestoreDoc[] = []
39 | querySnapshot.forEach((doc) => {
40 | snapshots.push(docWithId(doc))
41 | })
42 | return snapshots
43 | }
44 |
45 | // To avoid “cannot be serialized as JSON” error
46 | export const convertDates = (doc: FirestoreDoc): FirestoreDoc => ({
47 | ...doc,
48 | dateCreated: doc.dateCreated?.toDate().toString() ?? null,
49 | dateUpdated: doc.dateUpdated?.toDate().toString() ?? null
50 | })
51 |
--------------------------------------------------------------------------------
/lib/formatDate.ts:
--------------------------------------------------------------------------------
1 | // import dayjs from 'dayjs'
2 | // import relativeTime from 'dayjs/plugin/relativeTime'
3 | // dayjs.extend(relativeTime)
4 |
5 | export const formatDate = (dateObj: Date): string => `${dateObj.getFullYear()}-${('0' + (dateObj.getMonth() + 1).toString()).slice(-2)}-${('0' + dateObj.getDate().toString()).slice(-2)}`
6 |
7 | // export const dateAsISO = (date: Date | string): string | undefined => (date !== null && date !== undefined) ? (new Date(date)).toISOString() : undefined
8 |
--------------------------------------------------------------------------------
/lib/handleRestRequest.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from 'next'
2 |
3 | type ActionFunction = (req: NextApiRequest, res: NextApiResponse) => Promise
4 |
5 | interface RequestAndResponse {
6 | req: NextApiRequest
7 | res: NextApiResponse
8 | }
9 |
10 | /** handleRestRequest(async () => {...}, { req, res }) */
11 | export const handleRestRequest = async function handleRestRequest (actionFunction: ActionFunction, { req, res }: RequestAndResponse): Promise {
12 | try {
13 | await actionFunction(req, res)
14 | } catch (error: any) {
15 | const reference = `E${Math.round(1000 * Math.random())}`
16 | const { message, status = 400 }: CustomError = error
17 | console.error(`[${reference}] Error ${status ?? ''}: “${message ?? ''}” –`, error)
18 | if (!isNaN(status)) res.status(status)
19 | res.json({ status, message, reference })
20 | }
21 | }
22 |
23 | // From: https://levelup.gitconnected.com/the-definite-guide-to-handling-errors-gracefully-in-javascript-58424d9c60e6
24 | /** throw new CustomError(`Account not found`, 404) */
25 | export class CustomError extends Error {
26 | status?: number
27 |
28 | constructor (message: string, status: number, metadata?: Record) {
29 | super(message)
30 | Object.setPrototypeOf(this, CustomError.prototype)
31 | Error.captureStackTrace?.(this, CustomError)
32 | this.status = status
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/lib/isClientSide.ts:
--------------------------------------------------------------------------------
1 | const isClientSide = (): boolean => typeof window !== 'undefined'
2 | export default isClientSide
3 |
--------------------------------------------------------------------------------
/lib/isDevelopment.ts:
--------------------------------------------------------------------------------
1 | const isDevelopment = (): boolean => process.env.NODE_ENV === 'development'
2 | export default isDevelopment
3 |
--------------------------------------------------------------------------------
/lib/makeRestRequest.ts:
--------------------------------------------------------------------------------
1 | import { config } from 'config/config'
2 |
3 | export const makeRestRequest = async (method = 'GET', path: string, data?: object, sessionAccessToken?: string): Promise => {
4 | const completeUrl = config.appUrl as string + path.substring(1)
5 | // console.log('makeRestRequest:', { completeUrl, method, path, data, sessionAccessToken })
6 | return await fetch(
7 | completeUrl,
8 | {
9 | method,
10 | mode: 'cors',
11 | headers: {
12 | Accept: 'application/json',
13 | 'Content-Type': 'application/json',
14 | // Auth token
15 | ...(sessionAccessToken !== undefined && {
16 | Authorization: `Bearer ${sessionAccessToken ?? ''}`
17 | })
18 | },
19 | body: (data !== undefined) ? JSON.stringify(data) : null
20 | }
21 | )
22 | .then(async (res: Response) => {
23 | if (!res.ok) {
24 | const jsonData = await res.json()
25 | throwError(jsonData, res)
26 | } else {
27 | let jsonData: any = {}
28 | try {
29 | jsonData = await res.json()
30 | } catch (error: any) {
31 | console.warn('Parse JSON:', error?.message ?? error)
32 | }
33 | // console.log('*apiResponses.json:\n', JSON.stringify({ method, path, response: jsonData }, null, 2))
34 | if (jsonData.code?.toLowerCase?.()?.includes('error') === true) throwError(jsonData, res)
35 | return jsonData
36 | }
37 | })
38 | }
39 |
40 | export default makeRestRequest
41 |
42 | const throwError = (jsonData: any, res: Response): void => {
43 | const errorMessage = jsonData?.message?.includes('{') === true
44 | // Variant 1: { message: '{ message }' }
45 | ? JSON.parse(jsonData?.message)?.message ?? jsonData?.message
46 | // Variant 2: { errors: [{ message }] }
47 | : jsonData?.errors?.[0]?.message ??
48 | // Variant 3: { message }
49 | jsonData?.message ??
50 | // Fallback: use HTTP status text
51 | res.statusText
52 | throw new Error(errorMessage)
53 | }
54 |
--------------------------------------------------------------------------------
/lib/showNotification.ts:
--------------------------------------------------------------------------------
1 | import { toast } from 'react-toastify'
2 |
3 | const defaultOptions = {
4 | autoClose: 3 * 1000,
5 | hideProgressBar: true,
6 | closeButton: false,
7 | position: toast.POSITION.BOTTOM_LEFT
8 | }
9 |
10 | // Type: info, success, warning, error. Returns a notificationId that you can use for follow-up notifications.
11 | const showNotification = (message: string, type = 'info', options?: any): any => {
12 | if (options?.notificationId !== undefined) {
13 | // Update existing notification
14 | return toast.update(options?.notificationId, { ...defaultOptions, render: message, type, hideProgressBar: true, ...options })
15 | } else {
16 | // Create new notification
17 | toast.dismiss()
18 | return toast[type](message, { ...defaultOptions, ...options })
19 | }
20 | }
21 |
22 | export default showNotification
23 |
24 | // Commonly used notifications
25 | export const showErrorNotification = (error: any): void => {
26 | console.error(error)
27 | showNotification(error.message, 'error')
28 | }
29 |
--------------------------------------------------------------------------------
/lib/toSlug.ts:
--------------------------------------------------------------------------------
1 | const toSlug = (str: string): string => str?.replace(/ /g, '-')?.replace(/[^\w-]+/g, '')?.toLowerCase()
2 | export default toSlug
3 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | const withOffline = require('next-offline')
2 |
3 | const nextConfig = {
4 | images: {
5 | domains: ['www.tomsoderlund.com']
6 | },
7 |
8 | transformManifest: manifest => ['/'].concat(manifest), // add the homepage to the cache
9 |
10 | // Trying to set NODE_ENV=production when running yarn dev causes a build-time error so we turn on the SW in dev mode so that we can actually test it
11 | generateInDevMode: true,
12 |
13 | workboxOpts: {
14 | swDest: 'static/service-worker.js',
15 | runtimeCaching: [
16 | {
17 | urlPattern: /^https?.*/,
18 | handler: 'NetworkFirst',
19 | options: {
20 | cacheName: 'https-calls',
21 | networkTimeoutSeconds: 15,
22 | expiration: {
23 | maxEntries: 150,
24 | maxAgeSeconds: 30 * 24 * 60 * 60 // 1 month
25 | },
26 | cacheableResponse: {
27 | statuses: [0, 200]
28 | }
29 | }
30 | }
31 | ]
32 | }
33 |
34 | // // For support:
35 | // webpack: (config, { buildId, dev, isServer, defaultLoaders, webpack }) => {
36 | // config.module.rules.push({
37 | // test: /\.svg$/,
38 | // loader: 'raw-loader'
39 | // })
40 | // return config
41 | // }
42 | }
43 |
44 | module.exports = withOffline(nextConfig)
45 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextjs-pwa-firebase-boilerplate",
3 | "description": "Next.js serverless PWA with Firebase and React Hooks",
4 | "version": "1.18.0",
5 | "author": "Tom Söderlund ",
6 | "license": "ISC",
7 | "scripts": {
8 | "dev": "next dev -p 3004",
9 | "vercel": "echo 'Running as Vercel serverless'; vercel dev --listen 3004",
10 | "build": "next build",
11 | "start": "next start -p 3004",
12 | "test": "echo 'Running Standard.js and Jasmine unit tests...\n' && yarn lint && yarn unit",
13 | "unit": "jasmine",
14 | "lint": "ts-standard",
15 | "fix": "ts-standard --fix",
16 | "deploy": "vercel --prod",
17 | "v+": "yarn version --patch",
18 | "v++": "yarn version --minor"
19 | },
20 | "ts-standard": {
21 | "ignore": [
22 | ".next"
23 | ],
24 | "globals": [
25 | "fetch"
26 | ]
27 | },
28 | "dependencies": {
29 | "aether-css-framework": "^1.7.1",
30 | "firebase": "^10.13.1",
31 | "next": "^13.1.2",
32 | "next-offline": "^5.0.5",
33 | "react": "^18.2.0",
34 | "react-dom": "^18.2.0",
35 | "react-svg-inline": "^2.1.1",
36 | "react-toastify": "^9.1.1",
37 | "typescript": "^5.5.4"
38 | },
39 | "devDependencies": {
40 | "@types/node": "^22.1.0",
41 | "@types/react": "^18.3.3",
42 | "@types/react-dom": "^18.3.0",
43 | "@types/react-svg-inline": "^2.1.6",
44 | "ts-standard": "^12.0.2",
45 | "webpack": "^5.25.0"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import type { AppProps } from 'next/app'
3 | import Router from 'next/router'
4 | import Link from 'next/link'
5 |
6 | import PageHead from 'components/page/PageHead'
7 | // import Header from 'components/page/Header'
8 | import Footer from 'components/page/Footer'
9 | import Notifications from 'components/page/Notifications'
10 | import { googlePageview } from 'components/page/GoogleAnalytics'
11 |
12 | // Import global CSS files here
13 | import 'aether-css-framework/dist/aether.min.css'
14 | import 'public/app.css'
15 |
16 | Router.events.on('routeChangeComplete', path => googlePageview(path))
17 |
18 | export default function App ({ Component, pageProps, router }: AppProps): React.ReactElement {
19 | // props (Server + Client): Component, err, pageProps, router
20 | return (
21 | <>
22 |
26 | {/*
27 |
30 | */}
31 |
32 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | >
45 | )
46 | }
47 |
--------------------------------------------------------------------------------
/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Document, { Html, Head, Main, NextScript } from 'next/document'
3 |
4 | import { config } from '../config/config'
5 |
6 | export default class MyDocument extends Document {
7 | // this.props (Server only): __NEXT_DATA__, ampPath, assetPrefix, bodyTags, canonicalBase, dangerousAsPath, dataOnly, devFiles, dynamicImports, files, hasCssMode, head, headTags, html, htmlProps, hybridAmp, inAmpMode, isDevelopment, polyfillFiles, staticMarkup, styles
8 | // Page props in: this.props.__NEXT_DATA__.props.pageProps
9 | render (): React.ReactElement {
10 | const { locale } = this.props.__NEXT_DATA__
11 | return (
12 |
13 |
14 | `family=${`${fontName.replace(/ /g, '+')}${fontWeight !== undefined ? ':' + fontWeight : ''}`}`).join('&') ?? ''}&display=swap`} />
15 | {/* Global Site Tag (gtag.js) - Google Analytics */}
16 | {config.googleAnalyticsId !== undefined
17 | ? (
18 | <>
19 |
20 |
28 | >
29 | )
30 | : null}
31 |
32 |
33 |
34 |
35 |
36 |
37 | )
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/pages/about.tsx:
--------------------------------------------------------------------------------
1 | /*
2 | Don't forget to add to next.config.js:
3 |
4 | images: {
5 | domains: ['www.tomsoderlund.com']
6 | },
7 | */
8 | import React from 'react'
9 | import type { GetStaticPropsContext, GetStaticPropsResult } from 'next'
10 | import Image from 'next/image'
11 |
12 | import { PageProps } from 'components/page/PageHead'
13 | import { config } from 'config/config'
14 |
15 | const AboutPage: React.FC = ({ title }) => {
16 | return (
17 |
18 |
19 |
26 |
27 |
{title}
28 |
29 | I created {config.appName} because I’ve always been passionate about building fast,
30 | modern web apps using the best technology out there. This project combines the power of React Hooks,
31 | Firebase as a backend, and the flexibility of Next.js for SSG or SSR—all to make lightning-fast apps
32 | entirely in JavaScript.
33 |
34 |
35 | As a compulsive maker and someone constantly exploring new ways to build impactful projects,
36 | I wanted a streamlined, flexible template that could handle everything from e-commerce sites to
37 | digital media platforms. It’s a solution born from my experience working as a CTO and full-stack
38 | developer at sustainability-focused startups.
39 |
40 |
41 | You might find my other projects interesting too.
42 | For example, my startup and AI blog ,
43 | or my web and mobile apps at Tomorroworld .
44 |
45 |
46 | If you’re curious to learn more or collaborate,
47 | feel free to email me or check out Tomorroworld for a deeper
48 | dive into my world of projects, ranging from AI to sustainability tech.
49 |
50 |
51 |
52 |
81 |
82 | )
83 | }
84 | export default AboutPage
85 |
86 | export async function getStaticProps ({ params }: GetStaticPropsContext): Promise> {
87 | return {
88 | props: {
89 | title: 'About',
90 | description: `I created ${config.appName} because I’ve always been passionate about building fast, modern web apps using the best technology out there.`
91 | }
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/pages/api/notifications.tsx:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from 'next'
2 |
3 | import { handleRestRequest, CustomError } from 'lib/handleRestRequest'
4 | import { config } from 'config/config'
5 |
6 | const SLACK_WEBHOOK = 'https://hooks.slack.com/services/TTUFA...'
7 |
8 | export default async (req: NextApiRequest, res: NextApiResponse) => await handleRestRequest(async (req, res) => {
9 | switch (req.method) {
10 | case 'POST':
11 | await createSlackNotification(req, res)
12 | break
13 | default:
14 | throw new CustomError('Method not allowed', 405)
15 | }
16 | }, { req, res })
17 |
18 | const createSlackNotification = async (req: NextApiRequest, res: NextApiResponse) => {
19 | if (!config.allowedHostsList?.includes(req.headers.host as string)) throw new CustomError('Request not authorized', 401, { origin: req.headers.origin })
20 | const { email = '?', id = '?', requestType = 'Firebase login' } = req.body
21 | const text = `New ${requestType} for ${config.appName}: ${email} (#${id})`
22 | const results = await postToSlack({ text })
23 | res.statusCode = 200
24 | res.json({ results })
25 | }
26 |
27 | async function postToSlack ({ text }: { text: string }) {
28 | return await fetch(SLACK_WEBHOOK, { // eslint-disable-line no-undef
29 | method: 'POST',
30 | headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
31 | body: JSON.stringify({ text })
32 | })
33 | }
34 |
--------------------------------------------------------------------------------
/pages/api/revalidate.tsx:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from 'next'
2 |
3 | import { handleRestRequest, CustomError } from 'lib/handleRestRequest'
4 | import { config } from 'config/config'
5 |
6 | export default async (req: NextApiRequest, res: NextApiResponse) => await handleRestRequest(async (req, res) => {
7 | if (!config.allowedHostsList?.includes(req.headers.host as string)) throw new CustomError('Request not authorized', 401, { host: req.headers.host })
8 | switch (req.method) {
9 | case 'POST':
10 | await revalidatePagePath(req, res)
11 | break
12 | default:
13 | throw new CustomError('Method not allowed', 405)
14 | }
15 | }, { req, res })
16 |
17 | // https://nextjs.org/docs/pages/building-your-application/rendering/incremental-static-regeneration#using-on-demand-revalidation
18 | const revalidatePagePath = async (req: NextApiRequest, res: NextApiResponse) => {
19 | const { path } = req.body
20 | // Use asPath e.g. '/blog/post-1'
21 | await res.revalidate(path)
22 | return res.json({ revalidated: true })
23 | }
24 |
--------------------------------------------------------------------------------
/pages/articles/[slug].tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import type { GetStaticPropsContext, GetStaticPropsResult, GetStaticPathsContext, GetStaticPathsResult } from 'next'
3 | import { ParsedUrlQuery } from 'querystring'
4 | import { useRouter } from 'next/router'
5 | import Link from 'next/link'
6 |
7 | import { convertDates } from 'lib/data/firebase'
8 | import { Article, articleObject } from 'hooks/useArticles'
9 |
10 | import { PageProps } from 'components/page/PageHead'
11 | import ArticleDetails from 'components/articles/ArticleDetails'
12 |
13 | interface ArticleDetailsPageParams extends ParsedUrlQuery {
14 | slug: string
15 | }
16 |
17 | interface ArticleDetailsPageProps extends PageProps {
18 | article: Article
19 | }
20 |
21 | function ArticleDetailsPage ({ article }: ArticleDetailsPageProps): React.ReactElement {
22 | // Note: 'query' contains both /:params and ?query=value from url
23 | const { query } = useRouter()
24 | return (
25 | <>
26 | {article && (
27 |
30 | )}
31 |
32 | Routing
33 | Current query: {JSON.stringify(query)}
34 |
35 |
38 | >
39 | )
40 | }
41 |
42 | export default ArticleDetailsPage
43 |
44 | const getArticlePageProps = async (slug: string): Promise => {
45 | const articleId = slug.split('-').pop()
46 | const article = convertDates(await articleObject(articleId as string)) as Article
47 | return {
48 | article,
49 | title: article.name,
50 | description: article.content
51 | }
52 | }
53 |
54 | // SSG
55 | export async function getStaticProps ({ params }: GetStaticPropsContext): Promise> {
56 | // if (article === undefined) {
57 | // return { notFound: true }
58 | // }
59 | return {
60 | props: await getArticlePageProps(params?.slug as string),
61 | revalidate: 10 * 60 // Refresh page every 10 minutes
62 | }
63 | }
64 |
65 | export async function getStaticPaths (context: GetStaticPathsContext): Promise> {
66 | // const paths = (await articlesCollection()).map(article => ({ params: { slug: getArticleSlug(article) }, locale: 'en' }))
67 | return {
68 | paths: [
69 | // { params: { propNameThatMustBePartOfFolderStructure: 'value' }, locale: 'en' }
70 | ],
71 | fallback: true // true → build page if missing, false → serve 404
72 | }
73 | }
74 |
75 | // SSR
76 | // export async function getServerSideProps ({ req, res, query: { slug } }) {
77 | // return {
78 | // props: await getArticlePageProps(slug)
79 | // }
80 | // }
81 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import type { GetStaticPropsContext, GetStaticPropsResult } from 'next'
3 | import { useRouter } from 'next/router'
4 | import Link from 'next/link'
5 |
6 | import { config } from 'config/config'
7 | import { convertDates } from 'lib/data/firebase'
8 | import { showErrorNotification } from 'lib/showNotification'
9 | import { PageProps } from 'components/page/PageHead'
10 | import { Article, articlesCollection, ArticlesContextProvider } from 'hooks/useArticles'
11 | import useUser from 'hooks/useUser'
12 |
13 | import ArticleList from 'components/articles/ArticleList'
14 | import CreateArticleForm from 'components/articles/CreateArticleForm'
15 |
16 | interface ArticleListPageProps extends PageProps {
17 | articles: Article[]
18 | }
19 |
20 | function ArticleListPage ({ articles }: ArticleListPageProps) {
21 | // Note: 'query' contains both /:params and ?query=value from url
22 | const { query } = useRouter()
23 | const { user, signOut } = useUser()
24 | return (
25 | <>
26 | {config.appName}
27 |
28 | {config.appTagline}
29 |
30 |
34 |
35 |
36 |
37 |
38 | Routing
39 | Current query: {JSON.stringify(query)}
40 |
41 | Sign in (using Firebase Authentication)
42 | {(user != null)
43 | ? (
44 | <>
45 | You are signed in as {user.email ?? user.displayName}
46 | Sign out
47 | >
48 | )
49 | : (
50 |
51 | Click here to sign in
52 |
53 | )}
54 |
55 | Add to Home Screen
56 | You can add this to your Home Screen on iOS/Android, it should then start full screen.
57 |
58 | Source code
59 | Get the source code for nextjs-pwa-firebase-boilerplate
60 |
61 | Version {config.appVersion}
62 | >
63 | )
64 | }
65 |
66 | export default ArticleListPage
67 |
68 | // SSG
69 | export async function getStaticProps ({ params }: GetStaticPropsContext): Promise> {
70 | const articlesRaw = await articlesCollection()
71 | const articles = articlesRaw.map(convertDates) as Article[]
72 | return {
73 | props: {
74 | articles
75 | },
76 | revalidate: 10 * 60 // Refresh page every 10 minutes
77 | }
78 | }
79 |
80 | // SSR
81 | // export async function getServerSideProps ({ req, res, query: { slug } }) {
82 | // return {
83 | // articles
84 | // }
85 | // }
86 |
--------------------------------------------------------------------------------
/pages/robots.txt.tsx:
--------------------------------------------------------------------------------
1 | import { NextPage, GetServerSidePropsContext } from 'next'
2 | import { config } from '../config/config'
3 |
4 | const robotsTxt = `# robotstxt.org
5 |
6 | User-agent: *
7 |
8 | Sitemap: ${config.appUrl as string}sitemap.xml`
9 |
10 | const RobotsTxt: NextPage = () => null
11 |
12 | export async function getServerSideProps ({ res }: GetServerSidePropsContext): Promise<{ props: any }> {
13 | if (res !== undefined) {
14 | res.setHeader('Content-Type', 'text/plain')
15 | res.write(robotsTxt)
16 | res.end()
17 | }
18 | return { props: {} }
19 | }
20 |
21 | export default RobotsTxt
22 |
--------------------------------------------------------------------------------
/pages/signin/authenticate.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react'
2 | import type { GetStaticPropsResult } from 'next'
3 | import Router from 'next/router'
4 | import querystring from 'querystring'
5 | import { getAuth, signInWithEmailLink, isSignInWithEmailLink, User, updateProfile } from 'firebase/auth'
6 |
7 | import { config } from 'config/config'
8 | import { firebaseApp } from 'lib/data/firebase'
9 |
10 | const titleCase = (str: string): string => str.replace(/(?:^|\s|[-"'([{])+\S/g, (c) => c.toUpperCase())
11 | const emailToName = (email: string): string => titleCase(email.split('@')[0].replace(/\./g, ' '))
12 |
13 | interface EmailAuthenticatePageProps {
14 | title: string
15 | query?: { [key: string]: string }
16 | }
17 |
18 | const EmailAuthenticatePage: React.FC = ({ query }) => {
19 | const auth = getAuth(firebaseApp)
20 | useEffect(() => {
21 | async function signinUserAndRedirect () {
22 | if (isSignInWithEmailLink(auth, window.location.href)) {
23 | let email = window.localStorage.getItem('emailForSignIn')
24 | if (email === null) {
25 | email = window.prompt('Please provide your email again for confirmation (the email was opened in a new window):') ?? ''
26 | }
27 | try {
28 | const { user }: { user: User } = await signInWithEmailLink(auth, email, window.location.href)
29 | if ((user != null) && user.displayName === null) {
30 | void updateProfile(user, { displayName: emailToName(user.email ?? '') })
31 | }
32 | window.localStorage.removeItem('emailForSignIn')
33 | const { redirectTo } = querystring.parse(window.location.href.split('?')[1])
34 | void Router.push(redirectTo !== undefined ? decodeURIComponent(redirectTo as string) : '/')
35 | } catch (error) {
36 | console.warn(`Warning: ${(error as Error).message ?? error}`, error)
37 | }
38 | }
39 | }
40 | void signinUserAndRedirect()
41 | }, [query])
42 |
43 | return (
44 | <>
45 | Logging in to {config.appName}...
46 | >
47 | )
48 | }
49 |
50 | export default EmailAuthenticatePage
51 |
52 | export async function getStaticProps (): Promise> {
53 | return {
54 | props: {
55 | title: 'Logging in'
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/pages/signin/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import type { GetStaticPropsResult } from 'next'
3 |
4 | import { config } from 'config/config'
5 | import SigninWithEmailForm from 'components/user/SigninWithEmailForm'
6 | import SigninWithGoogleButton from 'components/user/SigninWithGoogleButton'
7 |
8 | interface SignInPageProps {
9 | title: string
10 | query?: { [key: string]: string }
11 | }
12 |
13 | function SigninPage (): React.ReactElement {
14 | return (
15 | <>
16 | Sign in to {config.appName}
17 |
18 | or sign in with email:
19 |
20 | >
21 | )
22 | }
23 |
24 | export default SigninPage
25 |
26 | export async function getStaticProps (): Promise> {
27 | return {
28 | props: {
29 | title: 'Sign in'
30 | }
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/pages/sitemap.xml.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { GetServerSidePropsContext, GetServerSidePropsResult } from 'next'
3 | import { ParsedUrlQuery } from 'querystring'
4 |
5 | import { config } from 'config/config'
6 | import { formatDate } from 'lib/formatDate'
7 |
8 | interface SiteUrlProps {
9 | path: string
10 | }
11 |
12 | const SiteUrl = ({ path }: SiteUrlProps): string => {
13 | const getDate = (): string => formatDate(new Date())
14 | return (
15 | `
16 | ${config.appUrl as string}${path.substring(1)}
17 | ${getDate()}
18 | `
19 | )
20 | }
21 |
22 | interface SitemapProps {
23 | pagePaths: string[]
24 | }
25 |
26 | const Sitemap = ({ pagePaths }: SitemapProps): string => {
27 | return (
28 | `
29 | ${pagePaths.map((path, index) => ).join('\n')}
30 | `
31 | )
32 | }
33 |
34 | const getPagePaths = async (): Promise => {
35 | return ['/']
36 | }
37 |
38 | interface SitemapPageParams extends ParsedUrlQuery {
39 | }
40 |
41 | export async function getServerSideProps ({ res }: GetServerSidePropsContext): Promise> {
42 | if (res !== undefined) {
43 | const pagePaths = await getPagePaths()
44 | res.setHeader('Content-Type', 'text/xml')
45 | res.write(
46 |
49 | )
50 | res.end()
51 | }
52 | return { props: {} }
53 | }
54 |
55 | export default Sitemap
56 |
--------------------------------------------------------------------------------
/public/app.css:
--------------------------------------------------------------------------------
1 | /* ===== Aether CSS as base: https://github.com/tomsoderlund/aether-css ===== */
2 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tomsoderlund/nextjs-pwa-firebase-boilerplate/8c2090150cbb392c6dae593ae1dcd1d4ac0cc3f1/public/favicon.ico
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tomsoderlund/nextjs-pwa-firebase-boilerplate/8c2090150cbb392c6dae593ae1dcd1d4ac0cc3f1/public/favicon.png
--------------------------------------------------------------------------------
/public/icons/feedback.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/icons/help.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/icons/home.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/icons/menu.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/icons/person.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/images/google_g.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Next.js Firebase PWA",
3 | "description": "Next.js serverless PWA with Firebase and React Hooks",
4 | "icons": [
5 | {
6 | "src": "favicon.png",
7 | "sizes": "512x512",
8 | "type": "image/png"
9 | }
10 | ],
11 | "display": "standalone",
12 | "orientation": "portrait",
13 | "scope": "/"
14 | }
--------------------------------------------------------------------------------
/public/share_preview.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tomsoderlund/nextjs-pwa-firebase-boilerplate/8c2090150cbb392c6dae593ae1dcd1d4ac0cc3f1/public/share_preview.jpg
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": true,
12 | "forceConsistentCasingInFileNames": true,
13 | "noEmit": true,
14 | "esModuleInterop": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "jsx": "preserve",
20 | "incremental": true,
21 | "baseUrl": ".",
22 | "plugins": [
23 | {
24 | "name": "next"
25 | }
26 | ]
27 | },
28 | "include": [
29 | "types/**/*.d.ts",
30 | "next-env.d.ts",
31 | "**/*.ts",
32 | "**/*.tsx",
33 | ".next/types/**/*.ts"
34 | ],
35 | "exclude": [
36 | "node_modules"
37 | ]
38 | }
39 |
--------------------------------------------------------------------------------
/types/global.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'react-toastify'
2 |
3 | declare global {
4 | interface Window {
5 | gtag: (event: string, action: string, options: any) => void
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 2,
3 | "rewrites": [
4 | {
5 | "source": "/service-worker.js",
6 | "destination": "/_next/static/service-worker.js"
7 | }
8 | ],
9 | "headers": [
10 | {
11 | "source": "/service-worker.js",
12 | "headers": [
13 | {
14 | "key": "Cache-Control",
15 | "value": "public, max-age=43200, immutable"
16 | },
17 | {
18 | "key": "Service-Worker-Allowed",
19 | "value": "/"
20 | }
21 | ]
22 | }
23 | ]
24 | }
--------------------------------------------------------------------------------